diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000000..5e4fc65a1d --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,210 @@ +name: Android CI + +on: + pull_request: + branches: + - master + types: + - opened + - synchronize + - reopened + push: + branches: + - master + tags: [ 'v*' ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + packages: write + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + fetch-tags: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Set up Android SDK/NDK + uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-34 build-tools;34.0.0 ndk;27.0.12077973 + + - name: Accept Android licenses + run: yes | sdkmanager --licenses > /dev/null + + - name: Remove local.properties (if present) + run: rm -f local.properties + + - name: Create google-services.json from secrets + shell: bash + run: | + if [ -n "${{ secrets.GOOGLE_SERVICES_JSON }}" ]; then + mkdir -p app + # 清理并验证 JSON 格式 + echo '${{ secrets.GOOGLE_SERVICES_JSON }}' | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > app/google-services.json + # 验证 JSON 格式 + if python3 -m json.tool app/google-services.json > /dev/null 2>&1; then + echo "Created valid google-services.json from secrets" + else + echo "ERROR: Invalid JSON format in GOOGLE_SERVICES_JSON secret" + exit 1 + fi + else + echo "GOOGLE_SERVICES_JSON not set; skipping file creation" + fi + + - name: Grant execute permission for Gradle + run: chmod +x gradlew + + - name: Get version from tag + if: startsWith(github.ref, 'refs/tags/') + run: | + VERSION=${GITHUB_REF#refs/tags/} + VERSION=${VERSION#v} + echo "VERSION_NAME=$VERSION" >> $GITHUB_ENV + echo "Using version from tag: $VERSION" + + - name: Update version in build.gradle + if: startsWith(github.ref, 'refs/tags/') + run: | + VERSION=${GITHUB_REF#refs/tags/} + # 移除版本号前缀 'v' 如果存在 + VERSION=${VERSION#v} + echo "Updating version to: $VERSION" + + # 读取当前 versionCode 并递增 + CURRENT_VERSION_CODE=$(grep -o 'versionCode = [0-9]*' app/build.gradle | grep -o '[0-9]*') + NEW_VERSION_CODE=$((CURRENT_VERSION_CODE + 1)) + + # 更新 versionName + sed -i "s/versionName \".*\"/versionName \"$VERSION\"/" app/build.gradle + + # 更新 versionCode + sed -i "s/versionCode = [0-9]*/versionCode = $NEW_VERSION_CODE/" app/build.gradle + + echo "Updated versionName to: $VERSION" + echo "Updated versionCode from $CURRENT_VERSION_CODE to $NEW_VERSION_CODE" + + - name: Build NonRoot Debug APK + run: ./gradlew :app:assembleNonRootDebug --no-daemon --stacktrace + + - name: Upload APK artifact + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: app-nonroot-debug-apk + path: | + app/build/outputs/apk/nonRoot/debug/*.apk + + # ===== Release (Signed) Build (tags only) ===== + - name: Decode keystore for Release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + echo "$KEYSTORE_BASE64" | base64 -d > my-release.keystore + ls -l my-release.keystore + + - name: Build signed NonRoot Release APK + if: startsWith(github.ref, 'refs/tags/') + env: + KEYSTORE_PATH: ${{ github.workspace }}/my-release.keystore + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: ./gradlew :app:assembleNonRootRelease --no-daemon --stacktrace + + - name: Rename Release APK to Moonlight.V+..apk + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -e + REL_DIR=app/build/outputs/apk/nonRoot/release + # 优先使用 tag 版本号,否则从 metadata 读取 + if [ -n "$VERSION_NAME" ]; then + VERSION="$VERSION_NAME" + echo "Using tag version: $VERSION" + else + VERSION=$(python3 -c "import json; data=json.load(open('$REL_DIR/output-metadata.json')); print(data['elements'][0]['versionName'])") + echo "Using metadata version: $VERSION" + fi + SRC_APK=$(ls "$REL_DIR"/*.apk | head -n 1) + DEST="$REL_DIR/Moonlight.V+.${VERSION}.apk" + mv "$SRC_APK" "$DEST" + echo "Renamed APK to: $DEST" + echo "REL_APK=$DEST" >> $GITHUB_ENV + + - name: Generate cute release notes + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -e + DATE=$(date +'%Y-%m-%d') + V="${VERSION_NAME:-${GITHUB_REF#refs/tags/}}"; V="${V#v}" + CUR_TAG="${GITHUB_REF#refs/tags/}" + PREV_TAG=$(git tag --sort=-creatordate | sed -n '2p' || true) + if [ -n "$PREV_TAG" ]; then + CHANGES=$(git log --pretty=format:'- %s (%h)' "${PREV_TAG}..${CUR_TAG}" || true) + else + CHANGES=$(git log --pretty=format:'- %s (%h)' -n 100 || true) + fi + [ -z "$CHANGES" ] && CHANGES="- 初始发布" + cat > release_notes.md << EOF + ## 🌙 Moonlight V+ ${V} + + 感谢你使用 Moonlight V+!如果喜欢,记得点亮一颗 ⭐~ + + ### ✨ 更新内容 + ${CHANGES} + + ### 📦 下载 + - APK 文件:Moonlight.V+.${V}.apk + + ### 🧭 另外 + - 如遇到问题,请在 Issues 反馈。祝你游戏顺利、低延迟满帧!🎮 + + —— 发布于 ${DATE} + EOF + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ env.VERSION_NAME != '' && env.VERSION_NAME || github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') }} + body_path: release_notes.md + files: | + ${{ env.REL_APK }} + app/build/outputs/mapping/nonRootRelease/mapping.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Release APK and mapping + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v4 + with: + name: app-nonroot-release-signed + path: | + app/build/outputs/apk/nonRoot/release/Moonlight.V+.*.apk + app/build/outputs/mapping/nonRootRelease/mapping.txt + diff --git a/.gitignore b/.gitignore index e0ab95a517..1dac643000 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ out/ bin/ gen/ -# Local configuration file (sdk path, etc) +# Local configurationItem file (sdk path, etc) local.properties # Windows thumbnail db @@ -41,4 +41,8 @@ build/ app/.externalNativeBuild/ # NDK stuff -.cxx/ \ No newline at end of file +.cxx/ +app/src/main/jni/moonlight-core/Build.txt +app/src/main/jni/moonlight-core/build-openssl.sh +.vscode/ +app/google-services.json diff --git a/.gitmodules b/.gitmodules index 577509631d..158b0e6701 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "app/src/main/jni/moonlight-core/moonlight-common-c"] path = app/src/main/jni/moonlight-core/moonlight-common-c - url = https://github.com/moonlight-stream/moonlight-common-c.git + url = https://github.com/qiin2333/moonlight-common-c.git + branch = mic diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..fad1c5f2b1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive", + "commentTranslate.targetLanguage": "zh-CN" +} \ No newline at end of file diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000000..d5f3a39481 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,120 @@ +# Moonlight Android 增强版隐私政策 + +## 概述 + +本隐私政策适用于 Moonlight Android 增强版应用(以下简称"本应用")。本应用基于开源的 Moonlight 项目开发,并增加了统计分析功能以改善用户体验。 + +> **注意**:本应用基于 [Moonlight](https://moonlight-stream.org/) 项目开发,您可以查看 [Moonlight 官方隐私政策](https://moonlight-stream.org/privacy.html) 了解更多信息。 + +## 数据收集 + +### 1. 统计分析数据 + +本应用集成了 Firebase Analytics 服务来收集匿名使用统计数据,包括: + +#### 应用使用统计 +- **应用启动次数**:记录应用被启动的次数 +- **使用时长**:记录每次应用会话的持续时间 +- **会话信息**:记录应用进入前台和后台的时间 + +#### 游戏流媒体统计 +- **流媒体时长**:记录每次游戏流媒体的持续时间 +- **电脑信息**:记录连接的电脑名称(仅用于统计,不包含个人身份信息) +- **应用信息**:记录启动的游戏应用名称(仅用于统计,不包含个人身份信息) + +#### 设备信息统计 +- **设备基本信息**:设备制造商、型号、Android版本等(用于兼容性分析) +- **SOC信息**:处理器制造商和型号(用于性能优化和兼容性分析) +- **硬件信息**:CPU核心数、内存大小、GPU类型等(用于性能分析) +- **网络信息**:网络类型和连接状态(用于网络优化) + +### 2. 数据匿名化 + +所有收集的统计数据都是完全匿名的: +- 不收集个人身份信息(如姓名、邮箱、电话号码等) +- 不收集设备唯一标识符 +- 不收集用户行为轨迹 +- 数据仅用于统计分析,不会用于其他目的 + +### 3. 数据收集频率 + +- **设备信息**:仅在应用首次启动或设备信息发生变化时收集,避免频繁上报 +- **使用统计**:每次应用启动和关闭时记录 +- **流媒体统计**:每次游戏流媒体开始和结束时记录 + +### 4. 第三方服务 + +#### Firebase Analytics +- **服务提供商**:Google LLC +- **用途**:应用使用统计和分析 +- **数据存储**:数据存储在 Google 的服务器上 +- **隐私政策**:[Firebase 隐私政策](https://firebase.google.com/support/privacy) + +## 数据使用 + +收集的统计数据仅用于: +- 了解应用使用情况 +- 改善用户体验 +- 优化应用性能 +- 识别最受欢迎的功能 + +## 数据保护 + +### 1. 数据安全 +- 所有数据传输都使用加密协议 +- 数据存储在安全的云服务器上 +- 定期审查数据安全措施 + +### 2. 数据保留 +- 统计数据保留期限为 26 个月 +- 用户可以随时删除其数据 +- 应用卸载后,相关数据将被自动删除 + +## 用户权利 + +### 1. 数据控制 +- 用户可以通过 Android 系统设置禁用应用的数据收集 +- 用户可以在应用设置中选择退出统计分析 +- 用户可以要求删除其数据 + +### 2. 透明度 +- 本隐私政策公开说明所有数据收集活动 +- 用户可以随时查看本隐私政策 +- 如有重大变更,将通知用户 + +## 儿童隐私 + +本应用不专门针对 13 岁以下儿童设计,也不会故意收集 13 岁以下儿童的个人信息。如果发现收集了儿童信息,将立即删除。 + +## 国际数据传输 + +数据可能传输到美国等其他国家进行处理。我们确保所有数据传输都符合适用的数据保护法律。 + +## 隐私政策更新 + +我们可能会不时更新本隐私政策。重大变更将通过以下方式通知用户: +- 应用内通知 +- 更新应用时在应用商店中说明 +- 在本页面发布更新 + +## 联系我们 + +如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们: + +- **GitHub Issues**:[项目 Issues 页面](https://github.com/qiin2333/moonlight-android/issues) +- **邮箱**:通过 GitHub 项目页面联系 + +## 相关链接 + +- **Moonlight 官方隐私政策**:[https://moonlight-stream.org/privacy.html](https://moonlight-stream.org/privacy.html) +- **Firebase 隐私政策**:[https://firebase.google.com/support/privacy](https://firebase.google.com/support/privacy) + +## 法律声明 + +本隐私政策受中华人民共和国法律管辖。如有争议,应通过友好协商解决。 + +--- + +**最后更新时间**:2025年9月 + +**版本**:1.0.1 diff --git a/README.md b/README.md index 0b1bbe780e..6dccf93eaf 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,171 @@ -# Moonlight Android +
+ Moonlight V+ Logo + + # Moonlight V+ 威力加强版 + + [![Version](https://img.shields.io/badge/version-12.4.7-blue.svg)](https://github.com/qiin2333/moonlight-android/releases/tag/shortcut) + [![Android](https://img.shields.io/badge/Android-5.0+-green.svg)](https://developer.android.com/about/versions) + [![License](https://img.shields.io/badge/license-GPL%20v3-orange.svg)](LICENSE.txt) + [![Stars](https://img.shields.io/github/stars/qiin2333/moonlight-android?style=social)](https://github.com/qiin2333/moonlight-android) + + **基于 Moonlight 的增强版 Android 串流客户端** 🎮 + + *让您的 Android 设备成为强大的游戏串流终端!Gawr!* ✨ +
-[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/232a8tadrrn8jv0k/branch/master?svg=true)](https://ci.appveyor.com/project/cgutman/moonlight-android/branch/master) -[![Translation Status](https://hosted.weblate.org/widgets/moonlight/-/moonlight-android/svg-badge.svg)](https://hosted.weblate.org/projects/moonlight/moonlight-android/) +## 📱 应用截图展示 -[Moonlight for Android](https://moonlight-stream.org) is an open source client for NVIDIA GameStream and [Sunshine](https://github.com/LizardByte/Sunshine). +
+ 主界面 +
+ 游戏列表 + 串流界面 + 设置界面 +
+
-Moonlight for Android will allow you to stream your full collection of games from your Windows PC to your Android device, -whether in your own home or over the internet. -Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios). +## ✨ 特性 -You can follow development on our [Discord server](https://moonlight-stream.org/discord) and help translate Moonlight into your language on [Weblate](https://hosted.weblate.org/projects/moonlight/moonlight-android/). +### 🎯 核心功能 +- **高性能串流**:解锁 144/165Hz 超高刷新率,支持最高 800Mbps 码率,动态自适应,畅享极致流畅画面。 +- **HDR 支持**:完整 HDR 内容串流,自动启用设备专属 HDR 校准文件,画质更真实,色彩更鲜明。 +- **自定义分辨率**:支持自定义分辨率、宽高比和不对称分辨率,满足各种显示需求,适配更多设备。 +- **多场景预设**:一键切换不同游戏场景的串流设置,右下角鲨牙长按即可保存/切换,轻松应对多种使用场景。 +- **功能卡片**:支持自定义功能卡片,快速访问常用操作、快捷指令、性能监控等,操作更高效。 +- **多设备支持**:支持手机、平板、电视盒子、掌机等多种 Android 设备,体验一致。 -## Downloads -* [Google Play Store](https://play.google.com/store/apps/details?id=com.limelight) -* [Amazon App Store](https://www.amazon.com/gp/product/B00JK4MFN2) -* [F-Droid](https://f-droid.org/packages/com.limelight) -* [APK](https://github.com/moonlight-stream/moonlight-android/releases) +### 🎮 游戏体验 +- **增强触控**:支持触控笔、手写笔和多点触控,内置触控板模式,触控体验顺滑精准,适配更多场景。 +- **自定义按键**:可自由拖动、缩放、隐藏按键布局,支持手柄瞄准、组合键、连发等高级功能,按键随心定制。 +- **体感助手**:内置陀螺仪体感辅助,支持体感瞄准、体感转视角,灵敏度可调,手柄无体感也能体验。 +- **快捷操作**:一键睡眠、快速切换输入法、常用 PC 指令一键发送,支持自定义快捷菜单,效率拉满。 +- **性能监控**:实时显示帧率、码率、延迟、丢包等串流性能指标,支持自由拖动和自定义显示位置,性能一目了然。 +- **多手柄支持**:支持多手柄同时连接,自动识别 Xbox/PS/Switch/国产手柄,按键映射灵活,联机更方便。 -## Building -* Install Android Studio and the Android NDK -* Run ‘git submodule update --init --recursive’ from within moonlight-android/ -* In moonlight-android/, create a file called ‘local.properties’. Add an ‘ndk.dir=’ property to the local.properties file and set it equal to your NDK directory. -* Build the APK using Android Studio or gradle +### 🎨 界面优化 +- **美化桌面**: 应用缩略图同步背景,自定义排序,桌面超好看! +- **菜单重构**: 与 Sunshine 应用编辑页风格统一,界面超协调! +- **实时调节**: 菜单集成码率调节面板,操作更便捷,调节超快速! -## Authors +### 🎤 音频功能 +- **麦克风重定向**: 支持远程语音(需 Sunshine 基地版 2025.0720+),音质好的不像在串流! -* [Cameron Gutman](https://github.com/cgutman) -* [Diego Waxemberg](https://github.com/dwaxemberg) -* [Aaron Neyer](https://github.com/Aaronneyer) -* [Andrew Hennessy](https://github.com/yetanothername) -Moonlight is the work of students at [Case Western](http://case.edu) and was -started as a project at [MHacks](http://mhacks.org). +## 🚀 快速开始 + +### 系统要求 +- Android 5.0 (API 22) 或更高版本 +- 支持 HEVC 解码的设备 +- 稳定的网络连接 + +### 安装方式 + +#### 方式一:下载 APK(最简单的方式!) +1. 从 [Releases](https://github.com/qiin2333/moonlight-android/releases) 页面下载最新版本 +2. 安装 APK 文件 +3. 按照应用内指引完成设置 + +#### 方式二:从源码编译 +```bash +# 克隆仓库 +git clone https://github.com/qiin2333/moonlight-android.git +cd moonlight-android + +# 编译项目 +./gradlew assembleRelease +``` + +--- + +## 📋 更新日志 + +### v12.3 最新版本 +- 🔄 **新增自动更新功能**:应用启动时自动检查更新,支持手动检查,智能提醒避免频繁打扰 +- ⚡ **延迟优化**:增加一种延迟与平滑全都要的帧速率模式 +- 🎮 **体感猪手**: 增加一个功能卡片,支持串流中的体感模拟操作,无需其他映射工具 +- 🔧 **捷径修复**: 解决从桌面捷径恢复串流无法识别APP的问题 + +### v12.2.6 +- 🎮 **发送特殊按键可自定义**,并支持键盘选取 +- 🖱️ **添加切换触控菜单**,可切换为触控板模式 +- ⚡ **菜单集成实时码率调节面板**,调节更快速 + +### v12.2 +- 🎨 **重构游戏菜单**,与 Sunshine 应用编辑页风格统一 +- 🔗 **优化连接体验**,分享串流最佳实践 +- 🎤 **快捷功能增强**:麦克风按钮显示控制 + 实时码率调节 +- 📱 **更友好的主机与APP详情展示** +- 🌍 **补全设置菜单英文翻译** + +### 2025/07/26 +- 🔧 **修复部分 Rockchip SOC 不能开启 HEVC HDR 的问题** +- ✏️ **开启增强式多点触摸后触控笔也可正常使用** +- 🎤 **优化麦克风长时间使用延迟增大的问题** + +### 2025/07/21 +- 🎤 **支持麦克风重定向**,需 Sunshine 基地版 2025.0720+ + +### 2025/07/17 +- 📺 **外接屏幕支持**:可选择复制或沉浸式投屏 +- 🔋 **沉浸式投屏性能覆盖层**:本机屏幕展示并添加实时电量 +- 🏷️ **支持 sunshine 端修改客户端配对名字** + +### 2025/07/06 +- ⚡ **优化部分联发科 SOC 的显示解码时间** (天玑9300以下可能有效果) +- 🔧 **修复 ColorOS 串流 HDR 内容时无法正确激发亮度** + +### 2025/06/22 +- 📊 **盯帧能力升级**:性能覆盖层可配置展示项目、位置、方向 +- 🖱️ **串流中可拖动性能覆盖层位置** + +### 2025/06/01 +- ⌨️ **外接物理键盘使用 ESC 键不首先弹出返回菜单** +- ⚡ **优化部分骁龙 SOC 的显示解码时间** (8Gen2+) + +### 2025/04/08 +- 🎮 **可移动按键增加手柄瞄准** +- 📈 **非线性码率调整** +- 🔧 **修复关闭增强触摸恢复经典鼠标模式** + +### 2025/03/24 +- 📱 **反转分辨率(竖屏)功能** +- 🎯 **串流画面位置设置**,支持八个方向加偏移量 + +### 2025/02/23 +- 💾 **增加多场景预设切换能力**:右下角鲨牙长按保存当前预设,点击应用对应预设 + + +## 🔧 高级功能(隐藏技能) + +### 需要 Sunshine 基地版支持的功能 +- 🎤 麦克风重定向 +- 〰️ 实时码率调整 +- 🎮 超级菜单指令 +- 🎨 应用桌面美化 +- 💻 主机自动优化 + + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request!感谢每一位贡献者! + +### 贡献者 +- [@cjcxj](https://github.com/cjcxj) - 特殊按键自定义、触控菜单(创意新星!) +- [@alonsojr1980](https://github.com/alonsojr1980) - SOC 解码优化(性能优化专家!) +- [@Xmqor](https://github.com/Xmqor) - 手柄瞄准功能(瞄准高手!) +- [@TrueZhuangJia](https://github.com/TrueZhuangJia) - 增强多点触控(搓屏专家!) +- [@WACrown](https://github.com/WACrown) - 最强自定义按键(按键之王!) + + + +## 🙏 致谢 + +- 基于 [Moonlight Android](https://github.com/moonlight-stream/moonlight-android) 项目(感谢原版!) +- 特别感谢 [Sunshine](https://github.com/LizardByte/Sunshine) 项目团队 + +--- + +
+ 如果这个项目对您有帮助,请给我们一个⭐ ! +
diff --git a/app/build.gradle b/app/build.gradle index 26d5f84c1e..4238a2624d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,18 +1,26 @@ apply plugin: 'com.android.application' +// 仅在存在 google-services.json 且满足条件时应用 Google Services 插件 +def hasGoogleServicesJson = file('google-services.json').exists() +if (hasGoogleServicesJson && (project.hasProperty('enableAnalytics') || gradle.startParameter.taskNames.any { it.contains('Release') })) { + apply plugin: 'com.google.gms.google-services' +} else if (!hasGoogleServicesJson) { + logger.lifecycle("google-services.json not found; skipping Google Services plugin") +} + android { ndkVersion "27.0.12077973" - compileSdk 34 + compileSdkVersion 36 namespace 'com.limelight' defaultConfig { - minSdk 21 - targetSdk 34 + minSdk 22 + targetSdk 35 - versionName "12.1" - versionCode = 314 + versionName "12.4.7" + versionCode = 352 // Generate native debug symbols to allow Google Play to symbolicate our native crashes ndk.debugSymbolLevel = 'FULL' @@ -79,11 +87,29 @@ android { } } + // 基于环境变量的可选签名配置(用于 CI 等环境) + def keystorePath = System.getenv("KEYSTORE_PATH") + def keystorePassword = System.getenv("KEYSTORE_PASSWORD") + def envKeyAlias = System.getenv("KEY_ALIAS") + def envKeyPassword = System.getenv("KEY_PASSWORD") + def hasSigningConfig = keystorePath != null && keystorePassword != null && envKeyAlias != null && envKeyPassword != null && new File(keystorePath).exists() + + signingConfigs { + if (hasSigningConfig) { + release { + storeFile file(keystorePath) + storePassword keystorePassword + keyAlias envKeyAlias + keyPassword envKeyPassword + } + } + } + buildTypes { debug { - applicationIdSuffix ".debug" - resValue "string", "app_label", "Moonlight (Debug)" - resValue "string", "app_label_root", "Moonlight (Root Debug)" + applicationIdSuffix ".vplus_debug" + resValue "string", "app_label", "月光-威力加强版" + resValue "string", "app_label_root", "月光-威力加强版" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' @@ -119,12 +145,15 @@ android { // is to please change the applicationId before you publish. // // TL;DR: Leave the following line alone! - applicationIdSuffix ".unofficial" + applicationIdSuffix ".qiin" resValue "string", "app_label", "Moonlight" resValue "string", "app_label_root", "Moonlight (Root)" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasSigningConfig && signingConfigs.hasProperty('release')) { + signingConfig signingConfigs.release + } } } @@ -136,10 +165,25 @@ android { } dependencies { - implementation 'org.bouncycastle:bcprov-jdk18on:1.77' - implementation 'org.bouncycastle:bcpkix-jdk18on:1.77' + implementation 'org.bouncycastle:bcprov-jdk18on:1.82' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.82' implementation 'org.jcodec:jcodec:0.2.5' - implementation 'com.squareup.okhttp3:okhttp:4.12.0' - implementation 'org.jmdns:jmdns:3.5.9' + implementation 'com.squareup.okhttp3:okhttp:5.2.1' + implementation 'org.jmdns:jmdns:3.6.2' implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.github.ZeyuKeithFu:KeyboardHeaderLayout:v1.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.github.bumptech.glide:glide:5.0.5' + implementation 'jp.wasabeef:glide-transformations:4.3.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.4.0' + annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5' + implementation 'com.squareup:seismic:1.0.3' + implementation 'com.google.android.flexbox:flexbox:3.0.0' + + // Firebase Analytics + implementation platform('com.google.firebase:firebase-bom:32.7.0') + implementation 'com.google.firebase:firebase-analytics' } diff --git a/app/nonRoot/release/baselineProfiles/0/app-nonRoot-release.dm b/app/nonRoot/release/baselineProfiles/0/app-nonRoot-release.dm new file mode 100644 index 0000000000..55f91a24b0 Binary files /dev/null and b/app/nonRoot/release/baselineProfiles/0/app-nonRoot-release.dm differ diff --git a/app/nonRoot/release/baselineProfiles/1/app-nonRoot-release.dm b/app/nonRoot/release/baselineProfiles/1/app-nonRoot-release.dm new file mode 100644 index 0000000000..8cf89a4c70 Binary files /dev/null and b/app/nonRoot/release/baselineProfiles/1/app-nonRoot-release.dm differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7be9a20bf1..20fb3857d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,11 +1,21 @@ - + + + + + + @@ -41,6 +51,16 @@ android:name="android.hardware.type.pc" android:required="false"/> + + + + + + + + + + android:theme="@style/AppTheme" + tools:targetApi="vanillaIceCream"> + android:exported="false"> @@ -144,15 +165,16 @@ + android:preferMinimalPostProcessing="true" + android:multiprocess="true"> @@ -177,6 +199,29 @@ android:name=".binding.input.driver.UsbDriverService" android:label="Usb Driver Service" /> + + + + + + + + + + + + + currentProxyCidrs = new ArrayList<>(); + private volatile Intent vpnServiceIntent = null; + private volatile String lastNetworkInfoJson = null; + + private final Runnable monitorRunnable = new Runnable() { + @Override + public void run() { + if (isRunning) { + monitorNetworkStatus(); + handler.postDelayed(this, MONITOR_INTERVAL); + } + } + }; + + public EasyTierManager(Activity activity, String instanceName, String networkConfig) { + this.activity = activity; + this.instanceName = instanceName; + this.networkConfig = networkConfig; + } + + public void start() { + if (isRunning) return; + try { + if (EasyTierJNI.runNetworkInstance(networkConfig) == 0) { + isRunning = true; + Log.i(TAG, "EasyTier 实例启动成功: " + instanceName); + handler.post(monitorRunnable); + } else { + Log.e(TAG, "EasyTier 实例启动失败: " + EasyTierJNI.getLastError()); + } + } catch (Exception e) { + Log.e(TAG, "启动 EasyTier 实例时发生异常", e); + } + } + + public void stop() { + if (!isRunning) return; + isRunning = false; + handler.removeCallbacks(monitorRunnable); + try { + stopVpnService(); + EasyTierJNI.stopAllInstances(); + lastNetworkInfoJson = null; + currentIpv4 = null; + currentProxyCidrs.clear(); + } catch (Exception e) { + Log.e(TAG, "停止 EasyTier 实例时发生异常", e); + } + } + + public String getLatestNetworkInfoJson() { + return lastNetworkInfoJson; + } + + private void monitorNetworkStatus() { + try { + String infosJson = EasyTierJNI.collectNetworkInfos(10); + this.lastNetworkInfoJson = infosJson; + + if (infosJson == null || infosJson.isEmpty()) { + if (currentIpv4 != null) { + Log.w(TAG, "网络信息为空,停止VPN服务。"); + stopVpnService(); + currentIpv4 = null; + currentProxyCidrs.clear(); + } + return; + } + + String newIpv4 = null; + List newProxyCidrs = new ArrayList<>(); + + try { + JSONObject root = new JSONObject(infosJson); + JSONObject instance = root.getJSONObject("map").getJSONObject(instanceName); + + // --- 1. 解析本机 IP 和前缀 --- + String myIp = null; + int myPrefix = 0; + + JSONObject myNodeInfo = instance.optJSONObject("my_node_info"); + if (myNodeInfo != null) { + JSONObject virtualIpv4 = myNodeInfo.optJSONObject("virtual_ipv4"); + if (virtualIpv4 != null) { + int myAddrInt = virtualIpv4.getJSONObject("address").getInt("addr"); + myPrefix = virtualIpv4.getInt("network_length"); + myIp = ipFromInt(myAddrInt); + newIpv4 = myIp + "/" + myPrefix; + } + } + + // --- 2. 检查对等节点网段 & 解析 proxyCidrs --- + JSONArray routes = instance.optJSONArray("routes"); + if (routes != null) { + for (int i = 0; i < routes.length(); i++) { + JSONObject route = routes.getJSONObject(i); + + // A. 检查对等节点网段是否一致 + if (myIp != null && myPrefix > 0) { + JSONObject ipv4AddrJson = route.optJSONObject("ipv4_addr"); + if (ipv4AddrJson != null) { + int peerAddrInt = ipv4AddrJson.getJSONObject("address").getInt("addr"); + String peerIp = ipFromInt(peerAddrInt); + } + } + + // B. 解析 proxyCidrs + JSONArray proxyCidrsArray = route.optJSONArray("proxy_cidrs"); + if (proxyCidrsArray != null) { + for (int j = 0; j < proxyCidrsArray.length(); j++) { + newProxyCidrs.add(proxyCidrsArray.getString(j)); + } + } + } + } + + } catch (JSONException e) { + Log.e(TAG, "解析网络信息失败", e); + if (currentIpv4 != null) { + stopVpnService(); + currentIpv4 = null; + currentProxyCidrs.clear(); + } + return; + } + + // --- 3. 比较状态变化,并决定是否重启 VPN --- + boolean ipv4Changed = !Objects.equals(newIpv4, currentIpv4); + boolean proxyCidrsChanged = !newProxyCidrs.equals(currentProxyCidrs); + + if (ipv4Changed || proxyCidrsChanged) { + Log.i(TAG, "网络拓扑变化,需要重启 VpnService。"); + + this.currentIpv4 = newIpv4; + this.currentProxyCidrs = new ArrayList<>(newProxyCidrs); + + if (newIpv4 != null) { + restartVpnService(newIpv4, newProxyCidrs); + } else { + stopVpnService(); + } + } + + } catch (Exception e) { + Log.e(TAG, "监控网络状态时发生严重异常", e); + this.lastNetworkInfoJson = null; + if (currentIpv4 != null) { + stopVpnService(); + currentIpv4 = null; + currentProxyCidrs.clear(); + } + } + } + + private void restartVpnService(String ipv4, List proxyCidrs) { + stopVpnService(); + startVpnService(ipv4, proxyCidrs); + } + + private void startVpnService(String ipv4, List proxyCidrs) { + Intent intent = new Intent(activity, EasyTierVpnService.class); + intent.putExtra("ipv4_address", ipv4); + intent.putStringArrayListExtra("proxy_cidrs", new ArrayList<>(proxyCidrs)); + intent.putExtra("instance_name", this.instanceName); + activity.startService(intent); + vpnServiceIntent = intent; + } + + private void stopVpnService() { + Intent stopIntent = new Intent(EasyTierVpnService.ACTION_STOP_VPN); + activity.sendBroadcast(stopIntent); + Log.i(TAG, "停止发送VPN广播。"); + vpnServiceIntent = null; + } + + private String ipFromInt(int addr) { + return ((addr >>> 24) & 0xFF) + "." + ((addr >>> 16) & 0xFF) + "." + ((addr >>> 8) & 0xFF) + "." + (addr & 0xFF); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/easytier/jni/EasyTierVpnService.java b/app/src/main/java/com/easytier/jni/EasyTierVpnService.java new file mode 100644 index 0000000000..d3a564bcf1 --- /dev/null +++ b/app/src/main/java/com/easytier/jni/EasyTierVpnService.java @@ -0,0 +1,186 @@ +package com.easytier.jni; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.VpnService; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class EasyTierVpnService extends VpnService { + + private static final String TAG = "EasyTierVpnService"; + public static final String ACTION_STOP_VPN = "com.easytier.jni.ACTION_STOP_VPN"; + + private ParcelFileDescriptor vpnInterface = null; + private volatile boolean isRunning = false; + private String instanceName = null; + private Thread vpnThread = null; + + private final BroadcastReceiver stopVpnReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (Objects.equals(intent.getAction(), ACTION_STOP_VPN)) { + Log.i(TAG, "收到停止广播。正在清理并停止自身。"); + cleanupAndStop(); + } + } + }; + + /** + * 使用 @SuppressLint 注解来抑制 "UnspecifiedRegisterReceiverFlag" 警告。 + * 这告诉 Lint 工具,我们已经知晓并手动处理了这个问题。 + */ + @SuppressLint("UnspecifiedRegisterReceiverFlag") + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "已创建VPN服务。"); + + IntentFilter filter = new IntentFilter(ACTION_STOP_VPN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(stopVpnReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(stopVpnReceiver, filter); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || Objects.equals(intent.getAction(), ACTION_STOP_VPN)) { + cleanupAndStop(); + return START_NOT_STICKY; + } + + vpnThread = new Thread(() -> { + try { + String ipv4Address = intent.getStringExtra("ipv4_address"); + ArrayList proxyCidrs = intent.getStringArrayListExtra("proxy_cidrs"); + instanceName = intent.getStringExtra("instance_name"); + + if (ipv4Address == null || instanceName == null) { + cleanupAndStop(); + return; + } + if (proxyCidrs == null) proxyCidrs = new ArrayList<>(); + + setupVpnInterface(ipv4Address, proxyCidrs); + } catch (Throwable t) { + Log.e(TAG, "VPN设置线程失败", t); + cleanupAndStop(); + } + }, "VpnSetupThread"); + + vpnThread.start(); + return START_NOT_STICKY; + } + + private void setupVpnInterface(String ipv4Address, List proxyCidrs) { + try { + IpAddressInfo addressInfo = parseIpv4Address(ipv4Address); + + Builder builder = new Builder(); + builder.setSession("EasyTier VPN") + .addAddress(addressInfo.ip, addressInfo.networkLength) + .addDnsServer("223.5.5.5"); + + try { + builder.addAddress("fd00::1", 128); + Log.i(TAG, "已激活 VPN 接口 IPv6 协议栈 (fd00::1/128) 以支持双栈通信"); + } catch (Exception e) { + Log.w(TAG, "添加 IPv6 地址失败", e); + } + + Log.i(TAG, "为虚拟网络添加了VPN路由:" + addressInfo.ip + "/" + addressInfo.networkLength); + + for (String cidr : proxyCidrs) { + Log.i(TAG, "为虚拟网络添加代理CIDR:" + cidr); + try { + IpAddressInfo routeInfo = parseCidr(cidr); + builder.addRoute(routeInfo.ip, routeInfo.networkLength); + Log.i(TAG, "为虚拟网络添加了VPN路由:" + routeInfo.ip + "/" + routeInfo.networkLength); + } catch (Exception e) { + Log.w(TAG, "解析代理CIDR失败:" + cidr, e); + } + } + + vpnInterface = builder.establish(); + if (vpnInterface == null) return; + Log.i(TAG, "已建立VPN接口。"); + isRunning = true; + + EasyTierJNI.setTunFd(instanceName, vpnInterface.getFd()); + + while (isRunning) { + Thread.sleep(Long.MAX_VALUE); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + Log.e(TAG, "VPN接口设置过程中出错", t); + } finally { + cleanup(); + } + } + + private void cleanupAndStop() { + cleanup(); + stopSelf(); + } + + private void cleanup() { + if (!isRunning) return; + isRunning = false; + + if (vpnThread != null) { + vpnThread.interrupt(); + vpnThread = null; + } + + try { + if (vpnInterface != null) { + vpnInterface.close(); + } + } catch (IOException e) { + Log.e(TAG, "关闭VPN接口时出错", e); + } + vpnInterface = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + try { + unregisterReceiver(stopVpnReceiver); + } catch (IllegalArgumentException e) { + // Ignore + } + cleanup(); + Log.d(TAG, "VPN服务已损坏。"); + } + + private static class IpAddressInfo { + final String ip; final int networkLength; + IpAddressInfo(String ip, int len) { this.ip = ip; this.networkLength = len; } + } + + private IpAddressInfo parseIpv4Address(String addr) { + String[] parts = addr.split("/"); + return new IpAddressInfo(parts[0], parts.length > 1 ? Integer.parseInt(parts[1]) : 24); + } + + private IpAddressInfo parseCidr(String cidr) { + String[] parts = cidr.split("/"); + if (parts.length != 2) throw new IllegalArgumentException("Invalid CIDR: " + cidr); + return new IpAddressInfo(parts[0], Integer.parseInt(parts[1])); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/AppView.java b/app/src/main/java/com/limelight/AppView.java index 1337627e2e..e967ea0d5f 100644 --- a/app/src/main/java/com/limelight/AppView.java +++ b/app/src/main/java/com/limelight/AppView.java @@ -2,26 +2,38 @@ import java.io.IOException; import java.io.StringReader; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import com.limelight.computers.ComputerManagerListener; import com.limelight.computers.ComputerManagerService; import com.limelight.grid.AppGridAdapter; +import com.limelight.grid.assets.CachedAppAssetLoader; +import com.limelight.grid.assets.ScaledBitmap; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.nvstream.http.NvApp; import com.limelight.nvstream.http.NvHTTP; import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.http.NvHTTP.DisplayInfo; +import com.limelight.binding.PlatformBinding; import com.limelight.preferences.PreferenceConfiguration; import com.limelight.ui.AdapterFragment; import com.limelight.ui.AdapterFragmentCallbacks; +import com.limelight.ui.AdapterRecyclerBridge; +import com.limelight.ui.SelectionIndicatorAnimator; +import com.limelight.utils.BackgroundImageManager; import com.limelight.utils.CacheHelper; import com.limelight.utils.Dialog; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; import com.limelight.utils.SpinnerDialog; import com.limelight.utils.UiHelper; +import com.limelight.utils.AppSettingsManager; +import com.limelight.LimeLog; +import com.limelight.Game; +import com.limelight.binding.PlatformBinding; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.Service; import android.content.ComponentName; @@ -34,18 +46,28 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.RelativeSizeSpan; import android.view.ContextMenu; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ContextMenu.ContextMenuInfo; +import android.view.ViewGroup; import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; +import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; -import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.os.Handler; +import android.os.Looper; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.GridLayoutManager; import org.xmlpull.v1.XmlPullParserException; @@ -63,13 +85,41 @@ public class AppView extends Activity implements AdapterFragmentCallbacks { private boolean inForeground; private boolean showHiddenApps; private HashSet hiddenAppIds = new HashSet<>(); + private ImageView appBackgroundImage; + private BackgroundImageManager backgroundImageManager; + private int selectedPosition = -1; // 跟踪当前选中的位置 + private String computerName; // 存储计算机名称 + + // 选中框动画相关 + private SelectionIndicatorAnimator selectionAnimator; + private RecyclerView currentRecyclerView; + private AdapterRecyclerBridge currentAdapterBridge; + private boolean isFirstFocus = true; // 跟踪是否是第一次获得焦点 + + // 防抖相关变量 + private final Handler backgroundChangeHandler = new Handler(Looper.getMainLooper()); + private Runnable backgroundChangeRunnable; + private static final int BACKGROUND_CHANGE_DELAY = 300; // 300ms防抖延迟 + + // 上一次设置相关 + private AppSettingsManager appSettingsManager; + private LinearLayout lastSettingsInfo; + private TextView lastSettingsText; + private CheckBox useLastSettingsCheckbox; + + // 显示器选择相关 + private LinearLayout displaySelectionInfo; + private android.widget.RadioGroup displayRadioGroup; + private List availableDisplays; private final static int START_OR_RESUME_ID = 1; private final static int QUIT_ID = 2; + private final static int START_WITH_VDD = 3; private final static int START_WITH_QUIT = 4; private final static int VIEW_DETAILS_ID = 5; private final static int CREATE_SHORTCUT_ID = 6; private final static int HIDE_APP_ID = 7; + private final static int START_WITH_LAST_SETTINGS_ID = 8; public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps"; @@ -77,6 +127,12 @@ public class AppView extends Activity implements AdapterFragmentCallbacks { public final static String UUID_EXTRA = "UUID"; public final static String NEW_PAIR_EXTRA = "NewPair"; public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps"; + public final static String SELECTED_ADDRESS_EXTRA = "SelectedAddress"; + public final static String SELECTED_PORT_EXTRA = "SelectedPort"; + + private final static int DEFAULT_VERTICAL_SPAN_COUNT = 2; + private final static int DEFAULT_HORIZONTAL_SPAN_COUNT = 1; + private final static int VERTICAL_SINGLE_ROW_THRESHOLD = 5; // 竖屏时,app数量小于等于4个时使用1行 private ComputerManagerService.ComputerManagerBinder managerBinder; private final ServiceConnection serviceConnection = new ServiceConnection() { @@ -97,6 +153,13 @@ public void run() { finish(); return; } + + // 如果Intent中传递了选中的地址,则使用该地址覆盖activeAddress + String selectedAddress = getIntent().getStringExtra(SELECTED_ADDRESS_EXTRA); + int selectedPort = getIntent().getIntExtra(SELECTED_PORT_EXTRA, -1); + if (selectedAddress != null && selectedPort > 0) { + computer.activeAddress = new ComputerDetails.AddressTuple(selectedAddress, selectedPort); + } // Add a launcher shortcut for this PC (forced, since this is user interaction) shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false)); @@ -129,23 +192,20 @@ public void run() { // Start updates startComputerUpdates(); - runOnUiThread(new Runnable() { - @Override - public void run() { - if (isFinishing() || isChangingConfigurations()) { - return; - } - - // Despite my best efforts to catch all conditions that could - // cause the activity to be destroyed when we try to commit - // I haven't been able to, so we have this try-catch block. - try { - getFragmentManager().beginTransaction() - .replace(R.id.appFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } + runOnUiThread(() -> { + if (isFinishing() || isChangingConfigurations()) { + return; + } + + // Despite my best efforts to catch all conditions that could + // cause the activity to be destroyed when we try to commit + // I haven't been able to, so we have this try-catch block. + try { + getFragmentManager().beginTransaction() + .replace(R.id.appFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + } catch (IllegalStateException e) { + e.printStackTrace(); } }); } @@ -172,6 +232,13 @@ public void onConfigurationChanged(Configuration newConfig) { getFragmentManager().beginTransaction() .replace(R.id.appFragmentContainer, new AdapterFragment()) .commitAllowingStateLoss(); + + // 延迟检查布局,等待Fragment重新创建完成 + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (currentRecyclerView != null) { + checkAndUpdateLayout(currentRecyclerView); + } + }, 100); } catch (IllegalStateException e) { e.printStackTrace(); } @@ -184,78 +251,69 @@ private void startComputerUpdates() { return; } - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - // Do nothing if updates are suspended - if (suspendGridUpdates) { - return; - } + managerBinder.startPolling(details -> { + // Do nothing if updates are suspended + if (suspendGridUpdates) { + return; + } - // Don't care about other computers - if (!details.uuid.equalsIgnoreCase(uuidString)) { - return; - } + // Don't care about other computers + if (!details.uuid.equalsIgnoreCase(uuidString)) { + return; + } - if (details.state == ComputerDetails.State.OFFLINE) { - // The PC is unreachable now - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); - finish(); - } - }); + if (details.state == ComputerDetails.State.OFFLINE) { + // The PC is unreachable now + runOnUiThread(() -> { + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); + finish(); + }); - return; - } + return; + } - // Close immediately if the PC is no longer paired - if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Disable shortcuts referencing this PC for now - shortcutHelper.disableComputerShortcut(details, - getResources().getString(R.string.scut_not_paired)); - - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); - finish(); - } - }); + // Close immediately if the PC is no longer paired + if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { + runOnUiThread(() -> { + // Disable shortcuts referencing this PC for now + shortcutHelper.disableComputerShortcut(details, + getResources().getString(R.string.scut_not_paired)); - return; - } + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); + finish(); + }); - // App list is the same or empty - if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { + return; + } - // Let's check if the running app ID changed - if (details.runningGameId != lastRunningAppId) { - // Update the currently running game using the app ID - lastRunningAppId = details.runningGameId; - updateUiWithServerinfo(details); - } + // App list is the same or empty + if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { - return; + // Let's check if the running app ID changed + if (details.runningGameId != lastRunningAppId) { + // Update the currently running game using the app ID + lastRunningAppId = details.runningGameId; + updateUiWithServerinfo(details); } - lastRunningAppId = details.runningGameId; - lastRawApplist = details.rawAppList; + return; + } - try { - updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); - updateUiWithServerinfo(details); + lastRunningAppId = details.runningGameId; + lastRawApplist = details.rawAppList; - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); + try { + updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); + updateUiWithServerinfo(details); + + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; } + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); } }); @@ -291,8 +349,41 @@ protected void onCreate(Bundle savedInstanceState) { UiHelper.setLocale(this); + getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + setContentView(R.layout.activity_app_view); + // Initialize background image view + appBackgroundImage = findViewById(R.id.appBackgroundImage); + backgroundImageManager = new BackgroundImageManager(this, appBackgroundImage); + + // Initialize app settings manager and UI components + appSettingsManager = new AppSettingsManager(this); + lastSettingsInfo = findViewById(R.id.lastSettingsInfo); + lastSettingsText = findViewById(R.id.lastSettingsText); + useLastSettingsCheckbox = findViewById(R.id.useLastSettingsCheckbox); + + // Initialize display selection UI components + displaySelectionInfo = findViewById(R.id.displaySelectionInfo); + displayRadioGroup = findViewById(R.id.displayRadioGroup); + + // Set up event listeners + useLastSettingsCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + appSettingsManager.setUseLastSettingsEnabled(isChecked); + }); + + // Initialize selection indicator animator + View selectionIndicator = findViewById(R.id.selectionIndicator); + selectionAnimator = new SelectionIndicatorAnimator( + selectionIndicator, + null, // RecyclerView will be set later + null, // Adapter will be set later + findViewById(android.R.id.content) + ); + selectionAnimator.setPositionProvider(() -> selectedPosition); + // Allow floating expanded PiP overlays while browsing apps if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { setShouldDockBigOverlays(false); @@ -304,19 +395,31 @@ protected void onCreate(Bundle savedInstanceState) { uuidString = getIntent().getStringExtra(UUID_EXTRA); SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE); - for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet())) { + for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet<>())) { hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr)); } - String computerName = getIntent().getStringExtra(NAME_EXTRA); + computerName = getIntent().getStringExtra(NAME_EXTRA); TextView label = findViewById(R.id.appListText); setTitle(computerName); label.setText(computerName); + // Setup settings button + ImageButton settingsButton = findViewById(R.id.settingsButton); + settingsButton.setOnClickListener(v -> { + Intent intent = new Intent(AppView.this, com.limelight.preferences.StreamSettings.class); + startActivity(intent); + }); + // Bind to the computer manager service bindService(new Intent(this, ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE); + + // Delay checking displays to allow service connection to complete + new Handler(Looper.getMainLooper()).postDelayed(() -> { + checkDisplaysAndUpdateUI(); + }, 500); } private void updateHiddenApps(boolean hideImmediately) { @@ -334,13 +437,360 @@ private void updateHiddenApps(boolean hideImmediately) { appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately); } + @SuppressLint("SetTextI18n") + private void updateTitle(String appName) { + TextView label = findViewById(R.id.appListText); + if (appName != null && !appName.isEmpty()) { + // 检查当前是否为横屏 + boolean isLandscape = getResources().getConfiguration().orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE; + + // 根据屏幕方向选择分隔符 + String separator = isLandscape ? " - " : "\n"; + String text = computerName + separator + appName; + + SpannableString spannableString = new SpannableString(text); + int appNameStart = computerName.length() + 1; // +1 是分隔符 + + // 设置应用名称的字体大小 + spannableString.setSpan( + new RelativeSizeSpan(0.85f), + appNameStart, + text.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + + label.setText(spannableString); + } else { + label.setText(computerName); + } + } + + /** + * 防抖的背景切换方法 + * + * @param app 要切换背景的应用对象 + */ + private void changeBackgroundWithDebounce(AppView.AppObject app) { + // 取消之前的延迟任务 + if (backgroundChangeRunnable != null) { + backgroundChangeHandler.removeCallbacks(backgroundChangeRunnable); + } + + // 创建新的延迟任务 + backgroundChangeRunnable = () -> { + if (app != null && appGridAdapter != null && appGridAdapter.getLoader() != null) { + setAppAsBackground(app); + } + backgroundChangeRunnable = null; + }; + + // 延迟执行背景切换 + backgroundChangeHandler.postDelayed(backgroundChangeRunnable, BACKGROUND_CHANGE_DELAY); + } + + /** + * 设置指定应用为背景 + * + * @param appObject 应用对象 + */ + private void setAppAsBackground(AppView.AppObject appObject) { + if (isFinishing() || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && isDestroyed())) { + return; + } + + if (backgroundImageManager != null && appBackgroundImage != null) { + CachedAppAssetLoader loader = appGridAdapter.getLoader(); + CachedAppAssetLoader.LoaderTuple tuple = new CachedAppAssetLoader.LoaderTuple(computer, appObject.app); + + // 尝试从内存缓存获取bitmap + ScaledBitmap cachedBitmap = loader.getBitmapFromCache(tuple); + if (cachedBitmap != null && cachedBitmap.bitmap != null) { + backgroundImageManager.setBackgroundSmoothly(cachedBitmap.bitmap); + } else { + // 如果缓存中没有,异步加载 + ImageView tempImageView = new ImageView(this); + loader.populateImageView(appObject, tempImageView, null, false, () -> { + if (tempImageView.getDrawable() instanceof BitmapDrawable) { + Bitmap bitmap = ((BitmapDrawable) tempImageView.getDrawable()).getBitmap(); + if (bitmap != null) { + backgroundImageManager.setBackgroundSmoothly(bitmap); + } + } + }); + } + } + } + + /** + * 计算最优的spanCount + * + * @param orientation 屏幕方向 + * @return 最优的行数 + */ + private int calculateOptimalSpanCount(int orientation) { + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + return DEFAULT_HORIZONTAL_SPAN_COUNT; + } else { + // 竖屏:根据app数量固定阈值判断 + if (appGridAdapter == null) { + return DEFAULT_VERTICAL_SPAN_COUNT; + } + + int appCount = appGridAdapter.getCount(); + if (appCount == 0) { + return DEFAULT_VERTICAL_SPAN_COUNT; + } + + if (appCount <= VERTICAL_SINGLE_ROW_THRESHOLD) { + return DEFAULT_HORIZONTAL_SPAN_COUNT; + } else { + return DEFAULT_VERTICAL_SPAN_COUNT; + } + } + } + + /** + * 处理选中项变化 + * + * @param position 选中位置 + * @param app 选中的应用对象 + */ + private void handleSelectionChange(int position, AppObject app) { + selectedPosition = position; + updateTitle(app.app.getAppName()); + appGridAdapter.setSelectedPosition(position); + appGridAdapter.notifyDataSetChanged(); + + // 防抖切换背景 + changeBackgroundWithDebounce(app); + + // 移动选中框动画 + if (selectionAnimator != null) { + selectionAnimator.moveToPosition(position, isFirstFocus); + isFirstFocus = false; // 第一次后设置为false + } + + updateLastSettingsInfo(app); + } + + /** + * 更新上一次设置信息显示 + * + * @param app 应用对象 + */ + private void updateLastSettingsInfo(AppObject app) { + if (appSettingsManager == null || computer == null) { + return; + } + + String settingsSummary = appSettingsManager.getSettingsSummary(computer.uuid, app.app); + String noneSettingsText = getString(R.string.app_last_settings_none); + + boolean hasValidSettings = settingsSummary != null && !settingsSummary.equals(noneSettingsText); + + if (hasValidSettings) { + String displayText = getString(R.string.app_last_settings_title) + " " + settingsSummary; + lastSettingsText.setText(displayText); + lastSettingsInfo.setVisibility(View.VISIBLE); + + // 同步复选框状态(避免不必要的更新) + boolean useLastSettings = appSettingsManager.isUseLastSettingsEnabled(); + if (useLastSettingsCheckbox.isChecked() != useLastSettings) { + useLastSettingsCheckbox.setChecked(useLastSettings); + } + } else { + lastSettingsInfo.setVisibility(View.GONE); + } + } + + /** + * 启动串流,如果勾选了使用上一次设置则使用上一次设置 + * + * @param app 应用对象 + */ + private void startStreamWithLastSettingsIfEnabled(AppObject app) { + String displayGuid = null; + if (displaySelectionInfo.getVisibility() == View.VISIBLE + && availableDisplays != null) { + int selectedId = displayRadioGroup.getCheckedRadioButtonId(); + if (selectedId >= 0 && selectedId < availableDisplays.size()) { + DisplayInfo selectedDisplay = availableDisplays.get(selectedId); + displayGuid = selectedDisplay.guid != null && !selectedDisplay.guid.isEmpty() + ? selectedDisplay.guid : selectedDisplay.name; + } + } + + doStartStream(app, displayGuid); + } + + /** + * 检查显示器并更新UI + */ + private void checkDisplaysAndUpdateUI() { + if (computer == null || computer.activeAddress == null || managerBinder == null) { + displaySelectionInfo.setVisibility(View.GONE); + return; + } + + new Thread(() -> { + try { + NvHTTP httpConn = new NvHTTP(computer.activeAddress, computer.httpsPort, + managerBinder.getUniqueId(), "", computer.serverCert, + PlatformBinding.getCryptoProvider(this)); + + List displays = httpConn.getDisplays(); + + runOnUiThread(() -> { + if (displays != null && displays.size() > 1) { + updateDisplaySelectionUI(displays); + } else { + displaySelectionInfo.setVisibility(View.GONE); + } + }); + } catch (Exception e) { + LimeLog.warning("Failed to get displays: " + e.getMessage()); + runOnUiThread(() -> displaySelectionInfo.setVisibility(View.GONE)); + } + }).start(); + } + + /** + * 更新显示器选择UI + * + * @param displays 显示器列表 + */ + private void updateDisplaySelectionUI(List displays) { + availableDisplays = displays; + + // 清除之前的单选按钮 + displayRadioGroup.removeAllViews(); + + LimeLog.info("Displays: " + displays.size()); + for (int i = 0; i < displays.size(); i++) { + DisplayInfo display = displays.get(i); + // 使用友好名字显示 + String displayName = display.name != null && !display.name.isEmpty() + ? display.name : "Display " + (display.index + 1); + LimeLog.info("Display " + (display.index + 1) + ": " + display.name + " (guid: " + display.guid + ")"); + + // 创建单选按钮 + android.widget.RadioButton radioButton = new android.widget.RadioButton(this); + radioButton.setId(i); + radioButton.setText(displayName); + radioButton.setTextColor(0xCCFFFFFF); + radioButton.setTextSize(12); + radioButton.setTypeface(android.graphics.Typeface.create("sans-serif-light", android.graphics.Typeface.NORMAL)); + radioButton.setButtonTintList(android.content.res.ColorStateList.valueOf(0xFFFFFFFF)); + radioButton.setPadding(0, 0, 20, 0); // 右边距 + + displayRadioGroup.addView(radioButton); + } + + // 默认不选择任何显示器 + displayRadioGroup.clearCheck(); + + displaySelectionInfo.setVisibility(View.VISIBLE); + } + + /** + * 执行启动串流 + * + * @param app 应用对象 + * @param displayName 选择的显示器名称,如果为null则不指定显示器 + */ + private void doStartStream(AppObject app, String displayName) { + if (appSettingsManager != null && computer != null) { + // 使用AppSettingsManager统一管理启动逻辑 + Intent startIntent = appSettingsManager.createStartIntentWithLastSettingsIfEnabled( + this, app.app, computer, managerBinder); + if (displayName != null) { + startIntent.putExtra(Game.EXTRA_DISPLAY_NAME, displayName); + } + startActivity(startIntent); + } else { + // 回退到默认方式启动 + if (displayName != null) { + Intent startIntent = ServerHelper.createStartIntent(this, app.app, computer, managerBinder); + startIntent.putExtra(Game.EXTRA_DISPLAY_NAME, displayName); + startActivity(startIntent); + } else { + if (computer != null) { + ServerHelper.doStart(this, app.app, computer, managerBinder); + } + } + } + } + + /** + * 获取当前使用的item宽度 + * + * @return item宽度(像素) + */ + private int getCurrentItemWidth() { + // 获取当前显示模式 + boolean isLargeMode = isLargeItemMode(); + + // 根据模式返回对应的宽度 + if (isLargeMode) { + // 大图标模式:180dp + return (int) (180 * getResources().getDisplayMetrics().density); + } else { + // 小图标模式:120dp + return (int) (120 * getResources().getDisplayMetrics().density); + } + } + + /** + * 判断当前是否为大图标模式 + * + * @return true为大图标模式,false为小图标模式 + */ + private boolean isLargeItemMode() { + // 根据PreferenceConfiguration判断显示模式 + PreferenceConfiguration prefs = PreferenceConfiguration.readPreferences(this); + return !prefs.smallIconMode; // smallIconMode为false表示大图标模式 + } + + /** + * 检查并更新布局(竖屏时根据app数量调整行数) + */ + private void checkAndUpdateLayout(RecyclerView recyclerView) { + if (recyclerView == null || appGridAdapter == null) { + return; + } + + // 检查LayoutManager是否已经设置 + if (recyclerView.getLayoutManager() == null) { + return; + } + + int orientation = getResources().getConfiguration().orientation; + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + int currentSpanCount = ((GridLayoutManager) recyclerView.getLayoutManager()).getSpanCount(); + int optimalSpanCount = calculateOptimalSpanCount(orientation); + + if (currentSpanCount != optimalSpanCount) { + // 需要更新布局 + GridLayoutManager newGlm = new GridLayoutManager(this, optimalSpanCount, GridLayoutManager.HORIZONTAL, false); + recyclerView.setLayoutManager(newGlm); + } + } + + // 屏幕旋转后,延迟重新计算选中框位置,等待布局完成 + if (selectionAnimator != null && selectedPosition >= 0) { + recyclerView.post(() -> { + selectionAnimator.moveToPosition(selectedPosition, false); + }); + } + } + private void populateAppGridWithCache() { try { // Try to load from cache lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); List applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist)); updateUiWithAppList(applist); - LimeLog.info("Loaded applist from cache"); + LimeLog.info("Loaded applist from cache xxxx"); } catch (IOException | XmlPullParserException e) { if (lastRawApplist != null) { LimeLog.warning("Saved applist corrupted: "+lastRawApplist); @@ -364,9 +814,31 @@ protected void onDestroy() { SpinnerDialog.closeDialogs(this); Dialog.closeDialogs(); + // Cancel any pending image loading operations + if (appGridAdapter != null) { + appGridAdapter.cancelQueuedOperations(); + } + + // Clear background image to prevent memory leaks + if (backgroundImageManager != null) { + backgroundImageManager.clearBackground(); + } + + // 清理防抖Handler + if (backgroundChangeRunnable != null) { + backgroundChangeHandler.removeCallbacks(backgroundChangeRunnable); + backgroundChangeRunnable = null; + } + if (managerBinder != null) { unbindService(serviceConnection); } + + // 清理AdapterRecyclerBridge + if (currentAdapterBridge != null) { + currentAdapterBridge.cleanup(); + currentAdapterBridge = null; + } } @Override @@ -378,6 +850,9 @@ protected void onResume() { inForeground = true; startComputerUpdates(); + + // 重置焦点状态 + // resetFocusState(); } @Override @@ -392,8 +867,31 @@ protected void onPause() { public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position); + int position = -1; + View targetView = null; + + if (menuInfo instanceof AdapterContextMenuInfo) { + // AbsListView的情况 + AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; + position = info.position; + targetView = info.targetView; + } else if (v instanceof RecyclerView) { + // RecyclerView的情况,需要从当前选中的位置获取 + if (selectedPosition >= 0 && selectedPosition < appGridAdapter.getCount()) { + position = selectedPosition; + RecyclerView rv = (RecyclerView) v; + RecyclerView.ViewHolder viewHolder = rv.findViewHolderForAdapterPosition(selectedPosition); + if (viewHolder != null) { + targetView = viewHolder.itemView; + } + } + } else if (selectedPosition >= 0) { + position = selectedPosition; + } + + if (position < 0 || position >= appGridAdapter.getCount()) return; + + AppObject selectedApp = (AppObject) appGridAdapter.getItem(position); menu.setHeaderTitle(selectedApp.app.getAppName()); @@ -409,6 +907,12 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn // Only show the hide checkbox if this is not the currently running app or it's already hidden if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) { + menu.add(Menu.NONE, START_WITH_VDD, 1, getResources().getString(R.string.applist_menu_start_with_vdd)); + // Add "Start with Last Settings" option if last settings exist + if (appSettingsManager != null && appSettingsManager.hasLastSettings(computer.uuid, selectedApp.app)) { + menu.add(Menu.NONE, START_WITH_LAST_SETTINGS_ID, 2, getResources().getString(R.string.applist_menu_start_with_last_settings)); + } + MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app)); hideAppItem.setCheckable(true); hideAppItem.setChecked(selectedApp.isHidden); @@ -419,13 +923,15 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Only add an option to create shortcut if box art is loaded // and when we're in grid-mode (not list-mode). - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - if (appImageView != null) { - // We have a grid ImageView, so we must be in grid-mode - BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable(); - if (drawable != null && drawable.getBitmap() != null) { - // We have a bitmap loaded too - menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); + if (targetView != null) { + ImageView appImageView = targetView.findViewById(R.id.grid_image); + if (appImageView != null) { + // We have a grid ImageView, so we must be in grid-mode + BitmapDrawable drawable = (BitmapDrawable) appImageView.getDrawable(); + if (drawable != null && drawable.getBitmap() != null) { + // We have a bitmap loaded too + menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); + } } } } @@ -437,47 +943,66 @@ public void onContextMenuClosed(Menu menu) { @Override public boolean onContextItemSelected(MenuItem item) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - final AppObject app = (AppObject) appGridAdapter.getItem(info.position); + int position = -1; + View targetView = null; + + ContextMenuInfo menuInfo = item.getMenuInfo(); + if (menuInfo instanceof AdapterContextMenuInfo) { + // AbsListView的情况 + AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; + position = info.position; + targetView = info.targetView; + } else { + // RecyclerView的情况,使用当前选中的位置 + position = selectedPosition; + } + + if (position < 0 || position >= appGridAdapter.getCount()) return false; + + final AppObject app = (AppObject) appGridAdapter.getItem(position); switch (item.getItemId()) { case START_WITH_QUIT: // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - } - }, null); + UiHelper.displayQuitConfirmationDialog(this, + () -> startStreamWithLastSettingsIfEnabled(app), + null); return true; case START_OR_RESUME_ID: // Resume is the same as start for us - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); + startStreamWithLastSettingsIfEnabled(app); + return true; + + case START_WITH_VDD: + computer.useVdd = true; + startStreamWithLastSettingsIfEnabled(app); + return true; + + case START_WITH_LAST_SETTINGS_ID: + // Start with last settings (force use last settings for this launch) + if (appSettingsManager != null && computer != null) { + Intent startIntent = appSettingsManager.createStartIntentWithLastSettingsIfEnabled( + this, app.app, computer, managerBinder); + startActivity(startIntent); + } return true; case QUIT_ID: // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - suspendGridUpdates = true; - ServerHelper.doQuit(AppView.this, computer, - app.app, managerBinder, new Runnable() { - @Override - public void run() { - // Trigger a poll immediately - suspendGridUpdates = false; - if (poller != null) { - poller.pollNow(); - } - } - }); - } + UiHelper.displayQuitConfirmationDialog(this, () -> { + suspendGridUpdates = true; + ServerHelper.doQuit(this, computer, app.app, managerBinder, () -> { + // Trigger a poll immediately + suspendGridUpdates = false; + if (poller != null) { + poller.pollNow(); + } + }); }, null); return true; case VIEW_DETAILS_ID: - Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); + Dialog.displayDetailsDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); return true; case HIDE_APP_ID: @@ -493,9 +1018,33 @@ public void run() { return true; case CREATE_SHORTCUT_ID: - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - Bitmap appBits = ((BitmapDrawable)appImageView.getDrawable()).getBitmap(); - if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) { + // 对于RecyclerView,我们需要从缓存中获取bitmap + Bitmap appBitmap = null; + + // 首先尝试从目标视图获取bitmap + if (targetView != null) { + ImageView appImageView = targetView.findViewById(R.id.grid_image); + if (appImageView != null && appImageView.getDrawable() instanceof BitmapDrawable) { + BitmapDrawable drawable = (BitmapDrawable) appImageView.getDrawable(); + appBitmap = drawable.getBitmap(); + } + } + + // 如果从视图获取失败,尝试从缓存获取 + if (appBitmap == null && appGridAdapter != null && appGridAdapter.getLoader() != null) { + CachedAppAssetLoader.LoaderTuple tuple = new CachedAppAssetLoader.LoaderTuple(computer, app.app); + ScaledBitmap cachedBitmap = appGridAdapter.getLoader().getBitmapFromCache(tuple); + if (cachedBitmap != null) { + appBitmap = cachedBitmap.bitmap; + } + } + + // 创建快捷方式 + if (appBitmap != null) { + if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBitmap)) { + Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); + } + } else { Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); } return true; @@ -506,10 +1055,9 @@ public void run() { } private void updateUiWithServerinfo(final ComputerDetails details) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { + runOnUiThread(() -> { boolean updated = false; + boolean hasRunningApp = false; // Look through our current app list to tag the running app for (int i = 0; i < appGridAdapter.getCount(); i++) { @@ -523,6 +1071,7 @@ public void run() { } else if (existingApp.app.getAppId() == details.runningGameId) { // This app wasn't running but now is + hasRunningApp = true; existingApp.isRunning = true; updated = true; } @@ -536,113 +1085,403 @@ else if (existingApp.isRunning) { } } + // if (!hasRunningApp) loadDefaultImage(); + if (updated) { appGridAdapter.notifyDataSetChanged(); + // Also refresh RecyclerView if it exists - use more efficient update + if (currentRecyclerView != null && currentRecyclerView.getAdapter() != null) { + // 使用更精确的更新方式,只更新可见的项目 + currentRecyclerView.getAdapter().notifyItemRangeChanged(0, appGridAdapter.getCount()); + } } - } }); } private void updateUiWithAppList(final List appList) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - boolean updated = false; - - // First handle app updates and additions - for (NvApp app : appList) { - boolean foundExistingApp = false; - - // Try to update an existing app in the list first - for (int i = 0; i < appGridAdapter.getCount(); i++) { - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - if (existingApp.app.getAppId() == app.getAppId()) { - // Found the app; update its properties - if (!existingApp.app.getAppName().equals(app.getAppName())) { - existingApp.app.setAppName(app.getAppName()); - updated = true; - } - - foundExistingApp = true; - break; + runOnUiThread(() -> { + // Prepare list of AppObjects in server order + List newAppObjects = new ArrayList<>(); + + // Create AppObjects from server list, preserving order + for (NvApp app : appList) { + // Look for existing AppObject to preserve running state + AppObject existingApp = null; + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject candidate = (AppObject) appGridAdapter.getItem(i); + if (candidate.app.getAppId() == app.getAppId()) { + existingApp = candidate; + // Update app properties if needed + if (!candidate.app.getAppName().equals(app.getAppName())) { + candidate.app.setAppName(app.getAppName()); } - } - - if (!foundExistingApp) { - // This app must be new - appGridAdapter.addApp(new AppObject(app)); - - // We could have a leftover shortcut from last time this PC was paired - // or if this app was removed then added again. Enable those shortcuts - // again if present. - shortcutHelper.enableAppShortcut(computer, app); - - updated = true; + break; } } - // Next handle app removals - int i = 0; - while (i < appGridAdapter.getCount()) { - boolean foundExistingApp = false; - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + if (existingApp != null) { + // Use existing AppObject to preserve state (like isRunning) + newAppObjects.add(existingApp); + } else { + // Create new AppObject for new app + AppObject newAppObject = new AppObject(app); + newAppObjects.add(newAppObject); - // Check if this app is in the latest list - for (NvApp app : appList) { - if (existingApp.app.getAppId() == app.getAppId()) { - foundExistingApp = true; - break; - } - } + // Enable shortcuts for new apps + shortcutHelper.enableAppShortcut(computer, app); + } + } - // This app was removed in the latest app list - if (!foundExistingApp) { - shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); - appGridAdapter.removeApp(existingApp); - updated = true; + // Handle removed apps - disable shortcuts + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + boolean stillExists = false; - // Check this same index again because the item at i+1 is now at i after - // the removal - continue; + for (NvApp app : appList) { + if (existingApp.app.getAppId() == app.getAppId()) { + stillExists = true; + break; } - - // Move on to the next item - i++; } - if (updated) { - appGridAdapter.notifyDataSetChanged(); + if (!stillExists) { + shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); } } + + // Rebuild the entire list in server order + appGridAdapter.rebuildAppList(newAppObjects); + appGridAdapter.notifyDataSetChanged(); + + // Set first app's cover as background if no current background + setFirstAppAsBackground(newAppObjects); + + // 检查并更新布局(竖屏时根据app数量调整行数) + if (currentRecyclerView != null) { + checkAndUpdateLayout(currentRecyclerView); + + // 重新计算居中布局 + int orientation = getResources().getConfiguration().orientation; + int spanCount = calculateOptimalSpanCount(orientation); + setupCenterAlignment(currentRecyclerView, spanCount); + } }); } + private void setFirstAppAsBackground(List appObjects) { + // Check if activity is still valid + if (isFinishing() || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && isDestroyed())) { + return; + } + + // Only set background if we don't have one already and there are apps + if (backgroundImageManager.getCurrentBackground() == null && + !appObjects.isEmpty() && + appBackgroundImage != null) { + + AppObject firstApp = appObjects.get(0); + + // Don't set background for hidden apps unless we're showing hidden apps + if (!firstApp.isHidden || showHiddenApps) { + if (appGridAdapter != null && appGridAdapter.getLoader() != null) { + setFirstAppBackgroundImage(firstApp); + } + } + } + } + + private void setFirstAppBackgroundImage(AppObject firstApp) { + CachedAppAssetLoader loader = appGridAdapter.getLoader(); + CachedAppAssetLoader.LoaderTuple tuple = new CachedAppAssetLoader.LoaderTuple(computer, firstApp.app); + + // Try memory cache first for immediate display + ScaledBitmap cachedBitmap = loader.getBitmapFromCache(tuple); + if (cachedBitmap != null && cachedBitmap.bitmap != null) { + backgroundImageManager.setBackgroundSmoothly(cachedBitmap.bitmap); + } else { + // Load asynchronously if not in cache + ImageView tempImageView = new ImageView(this); + loader.populateImageView(firstApp, tempImageView, null, false, () -> { + if (tempImageView.getDrawable() instanceof BitmapDrawable) { + Bitmap bitmap = ((BitmapDrawable) tempImageView.getDrawable()).getBitmap(); + if (bitmap != null) { + backgroundImageManager.setBackgroundSmoothly(bitmap); + } + } + }); + } + } + @Override public int getAdapterFragmentLayoutId() { return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ? R.layout.app_grid_view_small : R.layout.app_grid_view; } - @Override public void receiveAbsListView(AbsListView listView) { - listView.setAdapter(appGridAdapter); - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView arg0, View arg1, int pos, - long id) { - AppObject app = (AppObject) appGridAdapter.getItem(pos); + // Backwards-compatible wrapper: if a RecyclerView was passed as a View, + // AdapterFragmentCallbacks signature was generalized but compile-time this + // method remains for binary compat. Delegate to the View-based method. + receiveAdapterView(listView); + } - // Only open the context menu if something is running, otherwise start it - if (lastRunningAppId != 0) { - openContextMenu(arg1); - } else { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); + @Override + public void receiveAbsListView(View view) { + // Implementation for the generalized interface method + receiveAdapterView(view); + } + + // New generalized receiver to accept RecyclerView or legacy AbsListView + public void receiveAdapterView(View view) { + if (view instanceof RecyclerView) { + setupRecyclerView((RecyclerView) view); + } else if (view instanceof AbsListView) { + setupAbsListView((AbsListView) view); + } + } + + private void setupRecyclerView(RecyclerView rv) { + currentRecyclerView = rv; + + // 更新selectionAnimator的RecyclerView和Adapter引用 + if (selectionAnimator != null) { + selectionAnimator.updateReferences(rv, appGridAdapter); + } + + // 创建并设置bridge adapter + setupBridgeAdapter(rv); + + // 配置布局管理器 + setupLayoutManager(rv); + + // 优化RecyclerView性能 + optimizeRecyclerViewPerformance(rv); + + // 设置事件监听器 + setupRecyclerViewListeners(rv); + + // 应用UI配置 + UiHelper.applyStatusBarPadding(rv); + registerForContextMenu(rv); + } + + private void setupBridgeAdapter(RecyclerView rv) { + AdapterRecyclerBridge bridge = new AdapterRecyclerBridge(this, appGridAdapter); + rv.setAdapter(bridge); + + // 清理之前的bridge并保存新的引用 + if (currentAdapterBridge != null) { + currentAdapterBridge.cleanup(); + } + currentAdapterBridge = bridge; + + // 设置点击监听器 + bridge.setOnItemClickListener(this::handleItemClick); + + // 设置按键监听器 + bridge.setOnItemKeyListener(this::handleItemKey); + + // 设置长按监听器 + bridge.setOnItemLongClickListener(this::handleItemLongClick); + } + + private void setupLayoutManager(RecyclerView rv) { + int orientation = getResources().getConfiguration().orientation; + int spanCount = calculateOptimalSpanCount(orientation); + GridLayoutManager glm = new GridLayoutManager(this, spanCount, GridLayoutManager.HORIZONTAL, false); + rv.setLayoutManager(glm); + + // 设置预加载 + glm.setInitialPrefetchItemCount(4); + + // 设置居中布局 + setupCenterAlignment(rv, spanCount); + } + + /** + * 设置RecyclerView的居中对齐 + */ + private void setupCenterAlignment(RecyclerView rv, int spanCount) { + rv.post(() -> { + if (appGridAdapter == null) { + return; + } + + int itemCount = appGridAdapter.getCount(); + int totalRows = (int) Math.ceil((double) itemCount / spanCount); + int screenWidth = getResources().getDisplayMetrics().widthPixels; + int actualItemSize = getCurrentItemWidth(); + + // 如果RecyclerView已经有子视图,优先使用实际测量的尺寸 + if (rv.getChildCount() > 0) { + View firstChild = rv.getChildAt(0); + if (firstChild != null && firstChild.getWidth() > 0) { + actualItemSize = firstChild.getWidth(); } } + + // 计算并设置居中padding + int totalWidth = actualItemSize * totalRows; + int horizontalPadding = totalWidth < screenWidth ? (screenWidth - totalWidth) / 2 : 0; + rv.setPadding(horizontalPadding, rv.getPaddingTop(), horizontalPadding, rv.getPaddingBottom()); }); + } + + private void optimizeRecyclerViewPerformance(RecyclerView rv) { + // 基础性能优化 + rv.setHasFixedSize(true); + rv.setItemViewCacheSize(15); + rv.setDrawingCacheEnabled(true); + rv.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_HIGH); + rv.setNestedScrollingEnabled(false); + + // 滑动性能优化 + rv.setOverScrollMode(View.OVER_SCROLL_NEVER); + rv.setItemAnimator(null); + + // 硬件加速 + rv.setLayerType(View.LAYER_TYPE_HARDWARE, null); + + // 回收池优化 + RecyclerView.RecycledViewPool pool = rv.getRecycledViewPool(); + pool.setMaxRecycledViews(0, 20); + } + + private void setupRecyclerViewListeners(RecyclerView rv) { + // 添加滚动监听器 + rv.addOnScrollListener(createScrollListener()); + + // 添加子项焦点变化监听 + rv.addOnChildAttachStateChangeListener(createChildAttachStateChangeListener(rv)); + } + + private RecyclerView.OnScrollListener createScrollListener() { + return new RecyclerView.OnScrollListener() { + private long lastUpdateTime = 0; + private static final long MIN_UPDATE_INTERVAL = 16; // 约60fps + + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + if (selectionAnimator != null) { + selectionAnimator.showIndicator(); + updateSelectionPosition(); + } + } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING || + newState == RecyclerView.SCROLL_STATE_SETTLING) { + if (selectionAnimator != null) { + selectionAnimator.hideIndicator(); + } + } + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + lastUpdateTime = System.currentTimeMillis(); + } + }; + } + + private RecyclerView.OnChildAttachStateChangeListener createChildAttachStateChangeListener(RecyclerView rv) { + return new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(View view) { + view.setOnFocusChangeListener((v, hasFocus) -> { + if (!hasFocus) return; + + // 延迟处理焦点变化,确保点击事件优先处理 + v.post(() -> { + if (!v.hasFocus()) return; + + int pos = rv.getChildAdapterPosition(v); + if (pos < 0 || pos >= appGridAdapter.getCount()) return; + + AppObject app = (AppObject) appGridAdapter.getItem(pos); + handleSelectionChange(pos, app); + }); + }); + } + + @Override + public void onChildViewDetachedFromWindow(View view) { + view.setOnFocusChangeListener(null); + } + }; + } + + private void handleItemClick(int position, Object item) { + AppObject app = (AppObject) item; + handleSelectionChange(position, app); + + if (lastRunningAppId != 0) { + showContextMenuForPosition(position); + } else { + startStreamWithLastSettingsIfEnabled(app); + } + } + + private boolean handleItemKey(int position, Object item, int keyCode, android.view.KeyEvent event) { + if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) { + return false; + } + + if (keyCode == android.view.KeyEvent.KEYCODE_BUTTON_X || + keyCode == android.view.KeyEvent.KEYCODE_BUTTON_Y) { + AppObject app = (AppObject) item; + handleSelectionChange(position, app); + showContextMenuForPosition(position); + return true; + } + + return false; + } + + private boolean handleItemLongClick(int position, Object item) { + AppObject app = (AppObject) item; + handleSelectionChange(position, app); + return showContextMenuForPosition(position); + } + + private boolean showContextMenuForPosition(int position) { + if (currentRecyclerView == null) return false; + + RecyclerView.ViewHolder viewHolder = currentRecyclerView.findViewHolderForAdapterPosition(position); + if (viewHolder != null) { + openContextMenu(viewHolder.itemView); + return true; + } + return false; + } + + private void updateSelectionPosition() { + if (selectedPosition >= 0 && selectionAnimator != null) { + // 尝试更新到当前选中位置 + boolean positionUpdated = selectionAnimator.updatePosition(selectedPosition); + + // 如果更新失败(item滑出屏幕外),隐藏焦点框 + if (!positionUpdated) { + selectionAnimator.hideIndicator(); + } + } + } + + private void setupAbsListView(AbsListView listView) { + listView.setAdapter(appGridAdapter); + listView.setOnItemClickListener((arg0, arg1, pos, id) -> { + AppObject app = (AppObject) appGridAdapter.getItem(pos); + handleSelectionChange(pos, app); + + if (lastRunningAppId != 0) { + openContextMenu(arg1); + } else { + startStreamWithLastSettingsIfEnabled(app); + } + }); + UiHelper.applyStatusBarPadding(listView); registerForContextMenu(listView); - listView.requestFocus(); } public static class AppObject { diff --git a/app/src/main/java/com/limelight/BitrateCardController.java b/app/src/main/java/com/limelight/BitrateCardController.java new file mode 100644 index 0000000000..43b74402d9 --- /dev/null +++ b/app/src/main/java/com/limelight/BitrateCardController.java @@ -0,0 +1,153 @@ +package com.limelight; + +import android.app.AlertDialog; +import android.os.Handler; +import android.view.View; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.nvstream.NvConnection; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * Encapsulates the bitrate adjustment card logic shown in the Game Menu dialog. + */ +public class BitrateCardController { + private final Game game; + private final NvConnection conn; + + public BitrateCardController(Game game, NvConnection conn) { + this.game = game; + this.conn = conn; + } + + public void setup(View customView, AlertDialog dialog) { + View bitrateContainer = customView.findViewById(R.id.bitrateAdjustmentContainer); + SeekBar bitrateSeekBar = customView.findViewById(R.id.bitrateSeekBar); + TextView currentBitrateText = customView.findViewById(R.id.currentBitrateText); + TextView bitrateValueText = customView.findViewById(R.id.bitrateValueText); + ImageView bitrateTipIcon = customView.findViewById(R.id.bitrateTipIcon); + + if (bitrateContainer == null || bitrateSeekBar == null || + currentBitrateText == null || bitrateValueText == null || bitrateTipIcon == null) { + return; + } + + int currentBitrate = conn.getCurrentBitrate(); + int currentBitrateMbps = currentBitrate / 1000; + + currentBitrateText.setText(String.format(game.getResources().getString(R.string.game_menu_bitrate_current), currentBitrateMbps)); + + // Configure seekbar range: 500 kbps .. 200000 kbps (step 100) + bitrateSeekBar.setMax(1995); + bitrateSeekBar.setProgress((currentBitrate - 500) / 100); + + bitrateValueText.setText(String.format("%d Mbps", currentBitrateMbps)); + + bitrateTipIcon.setOnClickListener(v -> { + new AlertDialog.Builder(game, R.style.AppDialogStyle) + .setMessage(game.getResources().getString(R.string.game_menu_bitrate_tip)) + .setPositiveButton("懂了", null) + .show(); + }); + + // Debounced apply + final Handler bitrateHandler = new Handler(); + final Runnable bitrateApplyRunnable = new Runnable() { + @Override + public void run() { + int newBitrate = (bitrateSeekBar.getProgress() * 100) + 500; + adjustBitrate(newBitrate); + } + }; + + bitrateSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + int newBitrate = (progress * 100) + 500; + int newBitrateMbps = newBitrate / 1000; + bitrateValueText.setText(String.format("%d Mbps", newBitrateMbps)); + + bitrateHandler.removeCallbacks(bitrateApplyRunnable); + bitrateHandler.postDelayed(bitrateApplyRunnable, 500); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + bitrateHandler.removeCallbacks(bitrateApplyRunnable); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + bitrateHandler.removeCallbacks(bitrateApplyRunnable); + int newBitrate = (seekBar.getProgress() * 100) + 500; + adjustBitrate(newBitrate); + } + }); + + bitrateSeekBar.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT || + keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT) { + bitrateHandler.removeCallbacks(bitrateApplyRunnable); + bitrateHandler.postDelayed(bitrateApplyRunnable, 300); + return false; + } + } + return false; + }); + } + + private void adjustBitrate(int bitrateKbps) { + try { + Toast.makeText(game, "正在调整码率...", Toast.LENGTH_SHORT).show(); + + conn.setBitrate(bitrateKbps, new NvConnection.BitrateAdjustmentCallback() { + @Override + public void onSuccess(int newBitrate) { + game.runOnUiThread(() -> { + try { + // Update prefConfig with the new bitrate so it gets saved when streaming ends + game.prefConfig.bitrate = newBitrate; + + String successMessage = String.format(game.getResources().getString(R.string.game_menu_bitrate_adjustment_success), newBitrate / 1000); + Toast.makeText(game, successMessage, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + LimeLog.warning("Failed to show success toast: " + e.getMessage()); + } + }); + } + + @Override + public void onFailure(String errorMessage) { + game.runOnUiThread(() -> { + try { + String errorMsg = game.getResources().getString(R.string.game_menu_bitrate_adjustment_failed) + ": " + errorMessage; + Toast.makeText(game, errorMsg, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + LimeLog.warning("Failed to show error toast: " + e.getMessage()); + } + }); + } + }); + + } catch (Exception e) { + game.runOnUiThread(() -> { + try { + Toast.makeText(game, game.getResources().getString(R.string.game_menu_bitrate_adjustment_failed) + ": " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Exception toastException) { + LimeLog.warning("Failed to show error toast: " + toastException.getMessage()); + } + }); + } + } +} + + diff --git a/app/src/main/java/com/limelight/DisplayPositionManager.java b/app/src/main/java/com/limelight/DisplayPositionManager.java new file mode 100644 index 0000000000..4b42e8d401 --- /dev/null +++ b/app/src/main/java/com/limelight/DisplayPositionManager.java @@ -0,0 +1,114 @@ +package com.limelight; + +import android.app.Activity; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.StreamView; + +/** + * 屏幕位置管理器 + * 负责根据用户偏好设置(位置与偏移)调整串流视图的位置与边距。 + * 保留原有注释与行为。 + */ +public class DisplayPositionManager { + + private final Activity activity; + private final PreferenceConfiguration prefConfig; + private final StreamView streamView; + + public DisplayPositionManager(Activity activity, PreferenceConfiguration prefConfig, StreamView streamView) { + this.activity = activity; + this.prefConfig = prefConfig; + this.streamView = streamView; + } + + public void setupDisplayPosition() { + // 获取当前偏好设置 + PreferenceConfiguration config = PreferenceConfiguration.readPreferences(activity); + + // 获取视图容器 + ViewGroup.LayoutParams layoutParams = streamView.getLayoutParams(); + if (layoutParams instanceof FrameLayout.LayoutParams) { + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) layoutParams; + + // 根据屏幕位置设置重力属性 + switch (config.screenPosition) { + case TOP_LEFT: + params.gravity = Gravity.TOP | Gravity.LEFT; + break; + case TOP_CENTER: + params.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; + break; + case TOP_RIGHT: + params.gravity = Gravity.TOP | Gravity.RIGHT; + break; + case CENTER_LEFT: + params.gravity = Gravity.CENTER_VERTICAL | Gravity.LEFT; + break; + case CENTER: + params.gravity = Gravity.CENTER; + break; + case CENTER_RIGHT: + params.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; + break; + case BOTTOM_LEFT: + params.gravity = Gravity.BOTTOM | Gravity.LEFT; + break; + case BOTTOM_CENTER: + params.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + break; + case BOTTOM_RIGHT: + params.gravity = Gravity.BOTTOM | Gravity.RIGHT; + break; + } + + // 计算偏移量的像素值 + DisplayMetrics metrics = new DisplayMetrics(); + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + int streamWidth = prefConfig.width; + int streamHeight = prefConfig.height; + + // 将0-100的偏移百分比转换为实际像素值 + int xOffset = (streamWidth * config.screenOffsetX) / 100; + int yOffset = (streamHeight * config.screenOffsetY) / 100; + + // 应用偏移量 + if (params.gravity == Gravity.TOP || + params.gravity == (Gravity.TOP | Gravity.CENTER_HORIZONTAL) || + params.gravity == (Gravity.TOP | Gravity.RIGHT)) { + params.topMargin = yOffset; + } else if (params.gravity == Gravity.BOTTOM || + params.gravity == (Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL) || + params.gravity == (Gravity.BOTTOM | Gravity.LEFT)) { + params.bottomMargin = yOffset; + } + + if (params.gravity == Gravity.LEFT || + params.gravity == (Gravity.CENTER_VERTICAL | Gravity.LEFT) || + params.gravity == (Gravity.BOTTOM | Gravity.LEFT)) { + params.leftMargin = xOffset; + } else if (params.gravity == Gravity.RIGHT || + params.gravity == (Gravity.CENTER_VERTICAL | Gravity.RIGHT) || + params.gravity == (Gravity.TOP | Gravity.RIGHT)) { + params.rightMargin = xOffset; + } + + // 应用更新后的布局参数 + streamView.setLayoutParams(params); + } + } + + // 更新刷新显示位置方法 + public void refreshDisplayPosition(boolean surfaceCreated) { + if (surfaceCreated) { + setupDisplayPosition(); + } + } +} + + diff --git a/app/src/main/java/com/limelight/ExternalDisplayManager.java b/app/src/main/java/com/limelight/ExternalDisplayManager.java new file mode 100644 index 0000000000..7003f1c9ae --- /dev/null +++ b/app/src/main/java/com/limelight/ExternalDisplayManager.java @@ -0,0 +1,380 @@ +package com.limelight; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Presentation; +import android.content.Context; + +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.util.TypedValue; +import android.view.Display; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.binding.video.MediaCodecDecoderRenderer; +import com.limelight.nvstream.NvConnection; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.StreamView; +import com.limelight.LimeLog; +import com.limelight.utils.UiHelper; + +/** + * 外接显示器管理器 + * 负责管理外接显示器的检测、连接、断开和内容显示 + */ +public class ExternalDisplayManager { + + private final Activity activity; + private final PreferenceConfiguration prefConfig; + private final NvConnection conn; + private final MediaCodecDecoderRenderer decoderRenderer; + private final String pcName; + private final String appName; + + private DisplayManager displayManager; + private Display externalDisplay; + private boolean useExternalDisplay = false; + private DisplayManager.DisplayListener displayListener; + private ExternalDisplayPresentation externalPresentation; + + // 回调接口 + public interface ExternalDisplayCallback { + void onExternalDisplayConnected(Display display); + void onExternalDisplayDisconnected(); + void onStreamViewReady(StreamView streamView); + } + + private ExternalDisplayCallback callback; + + public ExternalDisplayManager(Activity activity, PreferenceConfiguration prefConfig, + NvConnection conn, MediaCodecDecoderRenderer decoderRenderer, + String pcName, String appName) { + this.activity = activity; + this.prefConfig = prefConfig; + this.conn = conn; + this.decoderRenderer = decoderRenderer; + this.pcName = pcName; + this.appName = appName; + } + + public void setCallback(ExternalDisplayCallback callback) { + this.callback = callback; + } + + /** + * 初始化外接显示器管理器 + */ + public void initialize() { + // 初始化显示管理器 + displayManager = (DisplayManager) activity.getSystemService(Context.DISPLAY_SERVICE); + + // 设置显示器监听器 + setupDisplayListener(); + + // 检查是否有外接显示器 + checkForExternalDisplay(); + + // 如果有外接显示器,启动外接显示器演示,并降低内建屏幕亮度到30% + if (useExternalDisplay) { + Window window = activity.getWindow(); + if (window != null) { + WindowManager.LayoutParams layoutParams = window.getAttributes(); + layoutParams.screenBrightness = 0.3f; + window.setAttributes(layoutParams); + } + startExternalDisplayPresentation(); + } + } + + /** + * 清理资源 + */ + public void cleanup() { + // 清理外接显示器演示 + if (externalPresentation != null) { + externalPresentation.dismiss(); + externalPresentation = null; + } + + // 取消注册显示器监听器 + if (displayListener != null && displayManager != null) { + displayManager.unregisterDisplayListener(displayListener); + displayListener = null; + } + } + + /** + * 获取要使用的显示器 + */ + public Display getTargetDisplay() { + if (useExternalDisplay && externalDisplay != null) { + return externalDisplay; + } + return activity.getWindowManager().getDefaultDisplay(); + } + + /** + * 检查是否正在使用外接显示器 + */ + public boolean isUsingExternalDisplay() { + return useExternalDisplay && externalDisplay != null; + } + + /** + * 检查是否有外接显示器连接 + */ + public static boolean hasExternalDisplay(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + if (displayManager != null) { + Display[] displays = displayManager.getDisplays(); + for (Display display : displays) { + if (display.getDisplayId() != Display.DEFAULT_DISPLAY) { + return true; + } + } + } + } + return false; + } + + /** + * 设置显示器监听器 + */ + private void setupDisplayListener() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + displayListener = new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + LimeLog.info("Display added: " + displayId); + if (prefConfig.useExternalDisplay && displayId != Display.DEFAULT_DISPLAY) { + // 外接显示器已连接 + checkForExternalDisplay(); + if (useExternalDisplay) { + startExternalDisplayPresentation(); + } + } + } + + @Override + public void onDisplayRemoved(int displayId) { + LimeLog.info("Display removed: " + displayId); + if (externalDisplay != null && displayId == externalDisplay.getDisplayId()) { + // 外接显示器已断开 + if (externalPresentation != null) { + externalPresentation.dismiss(); + externalPresentation = null; + } + externalDisplay = null; + useExternalDisplay = false; + + // 显示主屏幕内容 + View surfaceView = activity.findViewById(R.id.surfaceView); + if (surfaceView != null) { + surfaceView.setVisibility(View.VISIBLE); + } + Toast.makeText(activity, "外接显示器已断开,切换到主屏幕", Toast.LENGTH_SHORT).show(); + + if (callback != null) { + callback.onExternalDisplayDisconnected(); + } + } + } + + @Override + public void onDisplayChanged(int displayId) { + LimeLog.info("Display changed: " + displayId); + } + }; + + displayManager.registerDisplayListener(displayListener, null); + } + } + + /** + * 检查并配置外接显示器 + */ + private void checkForExternalDisplay() { + // 如果用户没有启用外接显示器选项,直接返回 + if (!prefConfig.useExternalDisplay) { + LimeLog.info("External display disabled by user preference"); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + Display[] displays = displayManager.getDisplays(); + + // 查找外接显示器(不是主显示器) + for (Display display : displays) { + if (display.getDisplayId() != Display.DEFAULT_DISPLAY) { + externalDisplay = display; + useExternalDisplay = true; + LimeLog.info("Found external display: " + display.getName() + + " (ID: " + display.getDisplayId() + ")"); + + if (callback != null) { + callback.onExternalDisplayConnected(display); + } + break; + } + } + + if (!useExternalDisplay) { + LimeLog.info("No external display found, using default display"); + } + } + } + + /** + * 将Activity移动到外接显示器 + */ + private void moveToExternalDisplay() { + if (useExternalDisplay && externalDisplay != null && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + // 创建WindowManager.LayoutParams for external display + WindowManager.LayoutParams params = activity.getWindow().getAttributes(); + params.preferredDisplayModeId = externalDisplay.getMode().getModeId(); + activity.getWindow().setAttributes(params); + + // 或者使用Presentation来在外接显示器上显示 + // 这需要重新设计Activity结构 + } + } + + /** + * 外接显示器演示类 + */ + private class ExternalDisplayPresentation extends Presentation { + + public ExternalDisplayPresentation(Context outerContext, Display display) { + super(outerContext, display); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 设置全屏 + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + + + + // 设置内容视图 + setContentView(R.layout.activity_game); + + // 初始化StreamView + StreamView externalStreamView = findViewById(R.id.surfaceView); + if (externalStreamView != null) { + // 通知回调StreamView已准备就绪 + if (callback != null) { + callback.onStreamViewReady(externalStreamView); + } + } + } + + @Override + public void onDisplayRemoved() { + super.onDisplayRemoved(); + // 外接显示器被移除时,关闭串流 + activity.finish(); + } + } + + /** + * 启动外接显示器演示 + */ + @SuppressLint({"ResourceAsColor", "SetTextI18n"}) + private void startExternalDisplayPresentation() { + if (!(useExternalDisplay && externalDisplay != null && externalPresentation == null)) { + return; + } + + externalPresentation = new ExternalDisplayPresentation(activity, externalDisplay); + externalPresentation.show(); + + // 隐藏主Activity的内容 + View surfaceView = activity.findViewById(R.id.surfaceView); + if (surfaceView != null) { + surfaceView.setVisibility(View.GONE); + } + + if (prefConfig.enablePerfOverlay) { + // 创建电量显示TextView + final TextView batteryTextView = new TextView(activity); + batteryTextView.setGravity(Gravity.CENTER); + batteryTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 48); + batteryTextView.setTextColor(activity.getResources().getColor(R.color.scene_color_1)); + + // 设置布局参数(居中显示) + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + params.gravity = Gravity.CENTER; + batteryTextView.setLayoutParams(params); + + // 添加到内建屏幕(主Activity)视图的中间 + FrameLayout rootView = activity.findViewById(android.R.id.content); + if (rootView != null) { + rootView.addView(batteryTextView); + } + + // 创建定时更新任务 + final Handler handler = new Handler(); + final Runnable updateBatteryTask = new Runnable() { + private final int[] gravityOptions = { + Gravity.CENTER, + Gravity.TOP | Gravity.CENTER_HORIZONTAL, + Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, + Gravity.CENTER_VERTICAL | Gravity.LEFT, + Gravity.CENTER_VERTICAL | Gravity.RIGHT, + Gravity.TOP | Gravity.LEFT, + Gravity.TOP | Gravity.RIGHT, + Gravity.BOTTOM | Gravity.LEFT, + Gravity.BOTTOM | Gravity.RIGHT + }; + + @Override + public void run() { + // 更新电量显示 + batteryTextView.setText(String.format("🔋 %d%%", UiHelper.getBatteryLevel(activity))); + + // 随机选择位置和参数以避免烧屏 + int randomGravity = gravityOptions[(int) (Math.random() * gravityOptions.length)]; + + // 随机生成边距参数(-200到200像素之间) + int randomMarginLeft = (int) (Math.random() * 401) - 200; + int randomMarginTop = (int) (Math.random() * 401) - 200; + int randomMarginRight = (int) (Math.random() * 401) - 200; + int randomMarginBottom = (int) (Math.random() * 401) - 200; + + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) batteryTextView.getLayoutParams(); + params.gravity = randomGravity; + params.setMargins(randomMarginLeft, randomMarginTop, randomMarginRight, randomMarginBottom); + batteryTextView.setLayoutParams(params); + + // 每分钟更新一次 + handler.postDelayed(this, 60000); + } + }; + + // 立即执行首次更新并启动定时器 + updateBatteryTask.run(); + } + + Toast.makeText(activity, "串流已切换到外接显示器, 若某些外接设备不能正常横屏显示,请翻滚主机。", Toast.LENGTH_LONG).show(); + } +} diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 5d214508a3..a6c790c093 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1,13 +1,19 @@ package com.limelight; - import com.limelight.binding.PlatformBinding; import com.limelight.binding.audio.AndroidAudioRenderer; +import com.limelight.binding.audio.AudioDiagnostics; +import com.limelight.binding.audio.MicrophoneManager; import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.GameInputDevice; import com.limelight.binding.input.KeyboardTranslator; +import com.limelight.binding.input.advance_setting.ControllerManager; +import com.limelight.binding.input.advance_setting.TouchController; import com.limelight.binding.input.capture.InputCaptureManager; import com.limelight.binding.input.capture.InputCaptureProvider; import com.limelight.binding.input.touch.AbsoluteTouchContext; +import com.limelight.binding.input.touch.LocalCursorRenderer; +import com.limelight.binding.input.touch.NativeTouchContext; import com.limelight.binding.input.touch.RelativeTouchContext; import com.limelight.binding.input.driver.UsbDriverService; import com.limelight.binding.input.evdev.EvdevListener; @@ -17,25 +23,31 @@ import com.limelight.binding.video.MediaCodecDecoderRenderer; import com.limelight.binding.video.MediaCodecHelper; import com.limelight.binding.video.PerfOverlayListener; +import com.limelight.binding.video.PerformanceInfo; import com.limelight.nvstream.NvConnection; import com.limelight.nvstream.NvConnectionListener; import com.limelight.nvstream.StreamConfiguration; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.nvstream.http.NvApp; import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.input.ControllerPacket; import com.limelight.nvstream.input.KeyboardPacket; import com.limelight.nvstream.input.MouseButtonPacket; import com.limelight.nvstream.jni.MoonBridge; import com.limelight.preferences.GlPreferences; import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.CursorView; import com.limelight.ui.GameGestures; import com.limelight.ui.StreamView; import com.limelight.utils.Dialog; +import com.limelight.utils.PanZoomHandler; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; -import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.FullscreenProgressOverlay; import com.limelight.utils.UiHelper; +import com.limelight.utils.NetHelper; +import com.limelight.utils.AnalyticsManager; +import com.limelight.utils.AppCacheManager; +import com.limelight.utils.AppSettingsManager; import android.annotation.SuppressLint; import android.annotation.TargetApi; @@ -55,27 +67,32 @@ import android.hardware.input.InputManager; import android.media.AudioManager; import android.net.ConnectivityManager; +import android.net.TrafficStats; import android.net.wifi.WifiManager; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; +import android.preference.PreferenceManager; import android.util.Rational; import android.view.Display; import android.view.InputDevice; import android.view.KeyCharacterMap; import android.view.KeyEvent; import android.view.MotionEvent; +import android.view.PointerIcon; import android.view.Surface; import android.view.SurfaceHolder; import android.view.View; import android.view.View.OnGenericMotionListener; import android.view.View.OnSystemUiVisibilityChangeListener; import android.view.View.OnTouchListener; +import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; import android.view.inputmethod.InputMethodManager; +import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; @@ -85,21 +102,31 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.Map; import java.util.Locale; +import com.limelight.services.KeyboardAccessibilityService; public class Game extends Activity implements SurfaceHolder.Callback, OnGenericMotionListener, OnTouchListener, NvConnectionListener, EvdevListener, OnSystemUiVisibilityChangeListener, GameGestures, StreamView.InputCallbacks, - PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { + PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener,KeyboardAccessibilityService.KeyEventCallback { private int lastButtonState = 0; + // 这个标志位用于区分事件是来自无障碍服务还是来自UI(如StreamView) + private boolean isEventFromAccessibilityService = false; + private static final int TOUCH_CONTEXT_LENGTH = 2; // Only 2 touches are supported - private final TouchContext[] touchContextMap = new TouchContext[2]; - private long threeFingerDownTime = 0; - - private static final int REFERENCE_HORIZ_RES = 1280; - private static final int REFERENCE_VERT_RES = 720; + private TouchContext[] touchContextMap = new TouchContext[TOUCH_CONTEXT_LENGTH]; + private final TouchContext[] absoluteTouchContextMap = new TouchContext[TOUCH_CONTEXT_LENGTH]; + private final TouchContext[] relativeTouchContextMap = new TouchContext[TOUCH_CONTEXT_LENGTH]; + private long multiFingerDownTime = 0; + + public static final int REFERENCE_HORIZ_RES = 1280; + public static final int REFERENCE_VERT_RES = 720; private static final int STYLUS_DOWN_DEAD_ZONE_DELAY = 100; private static final int STYLUS_DOWN_DEAD_ZONE_RADIUS = 20; @@ -107,28 +134,44 @@ public class Game extends Activity implements SurfaceHolder.Callback, private static final int STYLUS_UP_DEAD_ZONE_DELAY = 150; private static final int STYLUS_UP_DEAD_ZONE_RADIUS = 50; - private static final int THREE_FINGER_TAP_THRESHOLD = 300; + private static final int MULTI_FINGER_TAP_THRESHOLD = 300; private ControllerHandler controllerHandler; private KeyboardTranslator keyboardTranslator; private VirtualController virtualController; + private PanZoomHandler panZoomHandler; + + public interface PerformanceInfoDisplay{ + void display(Map performanceAttrs); + } + private ControllerManager controllerManager; + private List performanceInfoDisplays = new ArrayList<>(); - private PreferenceConfiguration prefConfig; + private MicrophoneManager microphoneManager; + + // 麦克风按钮 + private ImageButton micButton; + + PreferenceConfiguration prefConfig; private SharedPreferences tombstonePrefs; private NvConnection conn; - private SpinnerDialog spinner; + private FullscreenProgressOverlay progressOverlay; private boolean displayedFailureDialog = false; private boolean connecting = false; private boolean connected = false; private boolean autoEnterPip = false; private boolean surfaceCreated = false; private boolean attemptedConnection = false; + private AnalyticsManager analyticsManager; + private long streamStartTime; private int suppressPipRefCount = 0; private String pcName; private String appName; private NvApp app; private float desiredRefreshRate; + private AppSettingsManager appSettingsManager; + private String computerUuid; private InputCaptureProvider inputCaptureProvider; private int modifierFlags = 0; @@ -137,27 +180,117 @@ public class Game extends Activity implements SurfaceHolder.Callback, private boolean waitingForAllModifiersUp = false; private int specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; private StreamView streamView; + private StreamView externalStreamView; // 外接显示器的StreamView private long lastAbsTouchUpTime = 0; private long lastAbsTouchDownTime = 0; private float lastAbsTouchUpX, lastAbsTouchUpY; private float lastAbsTouchDownX, lastAbsTouchDownY; + private long previousTimeMillis = 0; + private long previousRxBytes = 0; + + // ESC键双击相关变量 + private static final long ESC_DOUBLE_PRESS_INTERVAL = 500; // 500毫秒内按第二次ESC才有效 + private long lastEscPressTime = 0; + private boolean hasShownEscHint = false; private boolean isHidingOverlays; - private TextView notificationOverlayView; + private androidx.cardview.widget.CardView notificationOverlayView; + private TextView notificationTextView; private int requestedNotificationOverlayVisibility = View.GONE; - private TextView performanceOverlayView; + + // 性能覆盖层管理器 + private PerformanceOverlayManager performanceOverlayManager; private MediaCodecDecoderRenderer decoderRenderer; private boolean reportedCrash; private WifiManager.WifiLock highPerfWifiLock; private WifiManager.WifiLock lowLatencyWifiLock; + private Map nativeTouchPointerMap = new HashMap<>(); + private String currentHostAddress; // 保存当前连接的IP + private boolean shouldResumeSession = false; + + public enum BackKeyMenuMode { + GAME_MENU, // 游戏菜单模式 + CROWN_MODE, // 王冠模式 + NO_MENU // 无菜单模式 + } + + + private BackKeyMenuMode currentBackKeyMenu = BackKeyMenuMode.GAME_MENU; // 默认为游戏菜单模式 + + public void setcurrentBackKeyMenu(BackKeyMenuMode currentBackKeyMenu) { + this.currentBackKeyMenu = currentBackKeyMenu; + } + + public BackKeyMenuMode getCurrentBackKeyMenu() { + return currentBackKeyMenu; + } + + + private boolean areElementsVisible = true; // 用于追踪显隐状态 + + + /** + * 切换虚拟控制器(虚拟按键)的可见性。 + */ + public void toggleVirtualControllerVisibility() { + if (controllerManager != null) { + areElementsVisible = !areElementsVisible; + if (areElementsVisible) { + controllerManager.getElementController().showAllElementsForTest(); + Toast.makeText(this, getString(R.string.toast_elements_visible), Toast.LENGTH_SHORT).show(); + } else { + controllerManager.getElementController().hideAllElementsForTest(); + Toast.makeText(this, getString(R.string.toast_elements_hidden), Toast.LENGTH_SHORT).show(); + } + } + } + + /** + * 王冠功能配置切换 + */ + public void toggleBackKeyMenuType() { + switch (currentBackKeyMenu) { + case GAME_MENU: + currentBackKeyMenu = BackKeyMenuMode.CROWN_MODE; + areElementsVisible = true; + controllerManager.getElementController().showAllElementsForTest(); + Toast.makeText(this, getString(R.string.toast_back_key_menu_switch_2), Toast.LENGTH_SHORT).show(); + break; + case CROWN_MODE: + currentBackKeyMenu = BackKeyMenuMode.GAME_MENU; + Toast.makeText(this, getString(R.string.toast_back_key_menu_switch_1), Toast.LENGTH_SHORT).show(); + break; + case NO_MENU: + currentBackKeyMenu = BackKeyMenuMode.GAME_MENU; + break; + } + } + + /** + * 提供对 ControllerManager 的公共访问。 + * @return ControllerManager 实例,如果未初始化则可能为 null。 + */ + public ControllerManager getControllerManager() { + return this.controllerManager; + } + + public boolean isTouchOverrideEnabled = false; + public boolean getisTouchOverrideEnabled() { + return isTouchOverrideEnabled; + } + public void setisTouchOverrideEnabled(boolean isTouchOverrideEnabled){ + this.isTouchOverrideEnabled = isTouchOverrideEnabled; + } private boolean connectedToUsbDriverService = false; + private UsbDriverService.UsbDriverBinder usbDriverBinder; private ServiceConnection usbDriverServiceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName componentName, IBinder iBinder) { UsbDriverService.UsbDriverBinder binder = (UsbDriverService.UsbDriverBinder) iBinder; + usbDriverBinder = binder; binder.setListener(controllerHandler); binder.setStateListener(Game.this); binder.start(); @@ -167,9 +300,27 @@ public void onServiceConnected(ComponentName componentName, IBinder iBinder) { @Override public void onServiceDisconnected(ComponentName componentName) { connectedToUsbDriverService = false; + usbDriverBinder = null; } }; + private void stopAndUnbindUsbDriverService() { + if (connectedToUsbDriverService) { + if (usbDriverBinder != null) { + try { + usbDriverBinder.stop(); + } catch (Exception ignored) {} + } + try { + unbindService(usbDriverServiceConnection); + } catch (Exception ignored) {} + connectedToUsbDriverService = false; + usbDriverBinder = null; + } + } + + // 性能覆盖层的各项视图由 PerformanceOverlayManager 管理 + public static final String EXTRA_HOST = "Host"; public static final String EXTRA_PORT = "Port"; public static final String EXTRA_HTTPS_PORT = "HttpsPort"; @@ -178,13 +329,19 @@ public void onServiceDisconnected(ComponentName componentName) { public static final String EXTRA_UNIQUEID = "UniqueId"; public static final String EXTRA_PC_UUID = "UUID"; public static final String EXTRA_PC_NAME = "PcName"; + public static final String EXTRA_PAIR_NAME = "PairName"; public static final String EXTRA_APP_HDR = "HDR"; public static final String EXTRA_SERVER_CERT = "ServerCert"; + public static final String EXTRA_PC_USEVDD = "usevdd"; + public static final String EXTRA_APP_CMD = "CmdList"; + public static final String EXTRA_DISPLAY_NAME = "DisplayName"; + + private ExternalDisplayManager externalDisplayManager; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - + UiHelper.setLocale(this); // We don't want a title bar @@ -211,13 +368,33 @@ protected void onCreate(Bundle savedInstanceState) { // Inflate the content setContentView(R.layout.activity_game); - // Start the spinner - spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), - getResources().getString(R.string.conn_establishing_msg), true); - + // Hack: allows use keyboard by dpad or controller + getWindow().getDecorView().findViewById(android.R.id.content).setFocusable(true); + // Read the stream preferences prefConfig = PreferenceConfiguration.readPreferences(this); tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0); + + // Initialize app settings manager + appSettingsManager = new AppSettingsManager(this); + + // Save computer UUID for later use + computerUuid = getIntent().getStringExtra(EXTRA_PC_UUID); + + // 检查是否使用上一次设置并应用(不覆盖全局配置) + applyLastSettingsToCurrentSession(); + + // Set flat region size for long press jitter elimination. + NativeTouchContext.INTIAL_ZONE_PIXELS = prefConfig.longPressflatRegionPixels; + NativeTouchContext.ENABLE_ENHANCED_TOUCH = prefConfig.enableEnhancedTouch; + if(prefConfig.enhancedTouchOnWhichSide){ + NativeTouchContext.ENHANCED_TOUCH_ON_RIGHT = -1; + }else{ + NativeTouchContext.ENHANCED_TOUCH_ON_RIGHT = 1; + } + NativeTouchContext.ENHANCED_TOUCH_ZONE_DIVIDER = prefConfig.enhanceTouchZoneDivider * 0.01f; + NativeTouchContext.POINTER_VELOCITY_FACTOR = prefConfig.pointerVelocityFactor * 0.01f; + // NativeTouchContext.POINTER_FIXED_X_VELOCITY = prefConfig.pointerFixedXVelocity; // Enter landscape unless we're on a square screen setPreferredOrientationForCurrentDisplay(); @@ -237,17 +414,36 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // Listen for non-touch events on the game surface streamView = findViewById(R.id.surfaceView); - streamView.setOnGenericMotionListener(this); - streamView.setOnKeyListener(this); - streamView.setInputCallbacks(this); + streamView.setOnGenericMotionListener(this); + streamView.setOnKeyListener(this); + streamView.setInputCallbacks(this); + + panZoomHandler = new PanZoomHandler(this, this, streamView, prefConfig); + + // 1. 添加监听器 (应对屏幕旋转、大小变化) + streamView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + // 只有当尺寸或位置真的变了才执行 + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + syncCursorWithStream(); + } + }); + // 2. 手动强制执行一次 + // 使用 post 确保在 UI 绘制队列的下一个节拍执行,此时 View 的宽/高已经计算好了 + streamView.post(new Runnable() { + @Override + public void run() { + syncCursorWithStream(); + } + }); + // Listen for touch events on the background touch view to enable trackpad mode // to work on areas outside of the StreamView itself. We use a separate View // for this rather than just handling it at the Activity level, because that // allows proper touch splitting, which the OSC relies upon. View backgroundTouchView = findViewById(R.id.backgroundTouchView); - backgroundTouchView.setOnTouchListener(this); - + backgroundTouchView.setOnTouchListener(this); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Request unbuffered input event dispatching for all input classes we handle here. // Without this, input events are buffered to be delivered in lock-step with VBlank, @@ -269,8 +465,13 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { } notificationOverlayView = findViewById(R.id.notificationOverlay); + notificationTextView = findViewById(R.id.notificationText); + + micButton = findViewById(R.id.micButton); - performanceOverlayView = findViewById(R.id.performanceOverlay); + // 初始化性能覆盖层管理器 + performanceOverlayManager = new PerformanceOverlayManager(this, prefConfig); + performanceOverlayManager.initialize(); inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this, this); @@ -309,16 +510,44 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { appName = Game.this.getIntent().getStringExtra(EXTRA_APP_NAME); pcName = Game.this.getIntent().getStringExtra(EXTRA_PC_NAME); + + // 初始化统计分析管理器 + analyticsManager = AnalyticsManager.getInstance(this); String host = Game.this.getIntent().getStringExtra(EXTRA_HOST); int port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); int httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown int appId = Game.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); String uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + String pairName = Game.this.getIntent().getStringExtra(EXTRA_PAIR_NAME); boolean appSupportsHdr = Game.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); + boolean pcUseVdd = Game.this.getIntent().getBooleanExtra(EXTRA_PC_USEVDD, false); byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); + String cmdList = Game.this.getIntent().getStringExtra(EXTRA_APP_CMD); + String displayName = Game.this.getIntent().getStringExtra(EXTRA_DISPLAY_NAME); app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); + if (cmdList != null) { + app.setCmdList(cmdList); + } + + // 保存应用信息到SharedPreferences,供下次从捷径恢复时使用 + if (appId != StreamConfiguration.INVALID_APP_ID && appName != null && !appName.equals("app")) { + AppCacheManager cacheManager = new AppCacheManager(this); + cacheManager.saveAppInfo(getIntent().getStringExtra(EXTRA_PC_UUID), app); + } + + // Start the progress overlay + progressOverlay = new FullscreenProgressOverlay(this, app); + + // 设置computer信息 + ComputerDetails computer = new ComputerDetails(); + computer.name = pcName; + computer.uuid = getIntent().getStringExtra(EXTRA_PC_UUID); + progressOverlay.setComputer(computer); + + progressOverlay.show(getResources().getString(R.string.conn_establishing_title), + getResources().getString(R.string.conn_establishing_msg)); X509Certificate serverCert = null; try { @@ -344,7 +573,8 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { if (prefConfig.enableHdr) { // Start our HDR checklist if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Display display = getWindowManager().getDefaultDisplay(); + Display display = externalDisplayManager != null ? + externalDisplayManager.getTargetDisplay() : getWindowManager().getDefaultDisplay(); Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); // We must now ensure our display is compatible with HDR10 @@ -368,11 +598,6 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { } } - // Check if the user has enabled performance stats overlay - if (prefConfig.enablePerfOverlay) { - performanceOverlayView.setVisibility(View.VISIBLE); - } - decoderRenderer = new MediaCodecDecoderRenderer( this, prefConfig, @@ -394,7 +619,7 @@ public void notifyCrash(Exception e) { this); // Don't stream HDR if the decoder can't support it - if (willStreamHdr && !decoderRenderer.isHevcMain10Hdr10Supported() && !decoderRenderer.isAv1Main10Supported()) { + if (willStreamHdr && !decoderRenderer.isHevcMain10Supported() && !decoderRenderer.isAv1Main10Supported()) { willStreamHdr = false; Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); } @@ -413,7 +638,7 @@ public void notifyCrash(Exception e) { int supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; if (decoderRenderer.isHevcSupported()) { supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265; - if (willStreamHdr && decoderRenderer.isHevcMain10Hdr10Supported()) { + if (willStreamHdr && decoderRenderer.isHevcMain10Supported()) { supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265_MAIN10; } } @@ -443,7 +668,7 @@ public void notifyCrash(Exception e) { // If the user requested frame pacing using a capped FPS, we will need to change our // desired FPS setting here in accordance with the active display refresh rate. int roundedRefreshRate = Math.round(displayRefreshRate); - int chosenFrameRate = prefConfig.fps; + int chosenFrameRate = prefConfig.fps; //将此处chosenFrameRate赋值为5时, 视频刷新率降低到5,但直接观察远端桌面可知,触控刷新率并未下降,窗口仍可流畅拖动。 if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { if (prefConfig.fps >= roundedRefreshRate) { if (prefConfig.fps > roundedRefreshRate + 3) { @@ -465,9 +690,10 @@ public void notifyCrash(Exception e) { StreamConfiguration config = new StreamConfiguration.Builder() .setResolution(prefConfig.width, prefConfig.height) .setLaunchRefreshRate(prefConfig.fps) - .setRefreshRate(chosenFrameRate) + .setRefreshRate(chosenFrameRate) //将此处chosenFrameRate替换为5时, 视频刷新率降低到5,但直接观察远端桌面可知,触控刷新率并未下降,窗口仍可流畅拖动。 .setApp(app) .setBitrate(prefConfig.bitrate) + .setResolutionScale(prefConfig.resolutionScale) .setEnableSops(prefConfig.enableSops) .enableLocalAudioPlayback(prefConfig.playHostAudio) .setMaxPacketSize(1392) @@ -479,29 +705,35 @@ public void notifyCrash(Exception e) { .setColorSpace(decoderRenderer.getPreferredColorSpace()) .setColorRange(decoderRenderer.getPreferredColorRange()) .setPersistGamepadsAfterDisconnect(!prefConfig.multiController) + .setUseVdd(pcUseVdd) + .setEnableMic(prefConfig.enableMic) + .setControlOnly(prefConfig.controlOnly) + .setCustomScreenMode(prefConfig.screenCombinationMode) .build(); // Initialize the connection conn = new NvConnection(getApplicationContext(), new ComputerDetails.AddressTuple(host, port), - httpsPort, uniqueId, config, - PlatformBinding.getCryptoProvider(this), serverCert); + httpsPort, uniqueId, pairName, config, + PlatformBinding.getCryptoProvider(this), serverCert, displayName); controllerHandler = new ControllerHandler(this, conn, this, prefConfig); keyboardTranslator = new KeyboardTranslator(); InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); inputManager.registerInputDeviceListener(keyboardTranslator, null); + // Initialize touch contexts - for (int i = 0; i < touchContextMap.length; i++) { - if (!prefConfig.touchscreenTrackpad) { - touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); - } - else { - touchContextMap[i] = new RelativeTouchContext(conn, i, - REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, - streamView, prefConfig); - } + for (int i = 0; i < TOUCH_CONTEXT_LENGTH; i++) { + absoluteTouchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); + relativeTouchContextMap[i] = new RelativeTouchContext(conn, i, + streamView, prefConfig); + } + if (!prefConfig.touchscreenTrackpad) { + touchContextMap = absoluteTouchContextMap; + } + else { + touchContextMap = relativeTouchContextMap; } if (prefConfig.onscreenController) { @@ -511,66 +743,514 @@ public void notifyCrash(Exception e) { this); virtualController.refreshLayout(); virtualController.show(); + + virtualController.setGyroEnabled(true); + } + + if (prefConfig.onscreenKeyboard) { + // create virtual onscreen keyboard + controllerManager = new ControllerManager((FrameLayout)streamView.getParent(),this); + controllerManager.refreshLayout(); + } + + if (prefConfig.usbDriver) { + // Start the USB driver + bindService(new Intent(this, UsbDriverService.class), + usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + } + + if (!decoderRenderer.isAvcSupported()) { + if (progressOverlay != null) { + progressOverlay.dismiss(); + progressOverlay = null; + } + + // If we can't find an AVC decoder, we can't proceed + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), + "This device or ROM doesn't support hardware accelerated H.264 playback.", true); + return; + } + + // The connection will be started when the surface gets created + streamView.getHolder().addCallback(this); + + // 允许内容延伸到刘海区域 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + + // Set up display position + new DisplayPositionManager(this, prefConfig, streamView).setupDisplayPosition(); + + // 初始化外接显示器管理器 + externalDisplayManager = new ExternalDisplayManager(this, prefConfig, conn, decoderRenderer, pcName, appName); + externalDisplayManager.setCallback(new ExternalDisplayManager.ExternalDisplayCallback() { + @Override + public void onExternalDisplayConnected(Display display) { + // 外接显示器连接时的处理 + LimeLog.info("External display connected, reinitializing input capture provider"); + + // 重新初始化输入捕获提供者以支持外接显示器 + if (inputCaptureProvider != null) { + inputCaptureProvider.disableCapture(); + } + inputCaptureProvider = InputCaptureManager.getInputCaptureProviderForExternalDisplay(Game.this, Game.this); + } + + @Override + public void onExternalDisplayDisconnected() { + // 外接显示器断开时的处理 + externalStreamView = null; + LimeLog.info("External display disconnected, cleared externalStreamView"); + + // 重新初始化输入捕获提供者回到标准模式 + if (inputCaptureProvider != null) { + inputCaptureProvider.disableCapture(); + } + inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(Game.this, Game.this); + } + + @Override + public void onStreamViewReady(StreamView streamView) { + // 保存外接显示器的StreamView引用 + externalStreamView = streamView; + + // 外接显示器StreamView准备就绪时的处理 + streamView.setOnGenericMotionListener(Game.this); + streamView.setOnKeyListener(Game.this); + streamView.setInputCallbacks(Game.this); + + // 设置触摸监听 + View backgroundTouchView = findViewById(R.id.backgroundTouchView); + if (backgroundTouchView != null) { + backgroundTouchView.setOnTouchListener(Game.this); + } + + // 设置Surface回调 + streamView.getHolder().addCallback(Game.this); + + LimeLog.info("External display StreamView ready: " + streamView.getWidth() + "x" + streamView.getHeight()); + } + }); + externalDisplayManager.initialize(); + } + + private void prepareConnection() { + // 1. 清理旧的光标资源 + destroyLocalCursorRenderers(); + runOnUiThread(() -> { + CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay != null) { + cursorOverlay.resetToDefault(); + cursorOverlay.hide(); + } + + // 清理可能残留的网络质量提示 + if (notificationOverlayView != null) { + notificationOverlayView.setVisibility(View.GONE); + } + }); + // 重置状态变量 + requestedNotificationOverlayVisibility = View.GONE; + + // 2. 获取 Intent 参数 + String host = Game.this.getIntent().getStringExtra(EXTRA_HOST); + int port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); + int httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); + String uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + String pairName = Game.this.getIntent().getStringExtra(EXTRA_PAIR_NAME); + boolean pcUseVdd = Game.this.getIntent().getBooleanExtra(EXTRA_PC_USEVDD, false); + byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); + + X509Certificate serverCert = null; + try { + if (derCertData != null) { + serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + + // 3. 重新初始化解码器环境 + // 我们必须重新读取首选项和网络状态,因为这些可能在后台发生了变化 + GlPreferences glPrefs = GlPreferences.readPreferences(this); + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + + // Check if the user has enabled HDR + boolean willStreamHdr = false; + if (prefConfig.enableHdr) { + // Start our HDR checklist + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Display display = externalDisplayManager != null ? + externalDisplayManager.getTargetDisplay() : getWindowManager().getDefaultDisplay(); + Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + + // We must now ensure our display is compatible with HDR10 + if (hdrCaps != null) { + // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 + for (int hdrType : hdrCaps.getSupportedHdrTypes()) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { + willStreamHdr = true; + break; + } + } + } + + if (!willStreamHdr) { + // Nope, no HDR for us :( + Toast.makeText(this, "Display does not support HDR10", Toast.LENGTH_LONG).show(); + } + } + else { + Toast.makeText(this, "HDR requires Android 7.0 or later", Toast.LENGTH_LONG).show(); + } + } + + // 销毁旧的解码器(如果存在)并创建新的实例 + // 旧的 renderer 内部的 MediaCodec 可能处于 Released 状态,无法复用 + if (decoderRenderer != null) { + // 确保旧的资源被清理 (虽然 onStop 可能已经清理过,但双重保险) + try { decoderRenderer.prepareForStop(); } catch (Exception ignored) {} + } + + // 创建全新的渲染器实例 + decoderRenderer = new MediaCodecDecoderRenderer( + this, + prefConfig, + new CrashListener() { + @Override + public void notifyCrash(Exception e) { + // The MediaCodec instance is going down due to a crash + // let's tell the user something when they open the app again + + // We must use commit because the app will crash when we return from this function + tombstonePrefs.edit().putInt("CrashCount", tombstonePrefs.getInt("CrashCount", 0) + 1).commit(); + reportedCrash = true; + } + }, + tombstonePrefs.getInt("CrashCount", 0), + connMgr.isActiveNetworkMetered(), + willStreamHdr, + glPrefs.glRenderer, + this); + + // Don't stream HDR if the decoder can't support it + if (willStreamHdr && !decoderRenderer.isHevcMain10Supported() && !decoderRenderer.isAv1Main10Supported()) { + willStreamHdr = false; + Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if HEVC was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC && !decoderRenderer.isHevcSupported()) { + Toast.makeText(this, "No HEVC decoder found", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if AV1 was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1 && !decoderRenderer.isAv1Supported()) { + Toast.makeText(this, "No AV1 decoder found", Toast.LENGTH_LONG).show(); + } + + // H.264 is always supported + int supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; + if (decoderRenderer.isHevcSupported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265; + if (willStreamHdr && decoderRenderer.isHevcMain10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265_MAIN10; + } + } + if (decoderRenderer.isAv1Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN8; + if (willStreamHdr && decoderRenderer.isAv1Main10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN10; + } + } + + int gamepadMask = ControllerHandler.getAttachedControllerMask(this); + if (!prefConfig.multiController) { + // Always set gamepad 1 present for when multi-controller is + // disabled for games that don't properly support detection + // of gamepads removed and replugged at runtime. + gamepadMask = 1; + } + if (prefConfig.onscreenController) { + // If we're using OSC, always set at least gamepad 1. + gamepadMask |= 1; + } + + // Set to the optimal mode for streaming + float displayRefreshRate = prepareDisplayForRendering(); + LimeLog.info("Display refresh rate: "+displayRefreshRate); + + // If the user requested frame pacing using a capped FPS, we will need to change our + // desired FPS setting here in accordance with the active display refresh rate. + int roundedRefreshRate = Math.round(displayRefreshRate); + int chosenFrameRate = prefConfig.fps; //将此处chosenFrameRate赋值为5时, 视频刷新率降低到5,但直接观察远端桌面可知,触控刷新率并未下降,窗口仍可流畅拖动。 + if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { + if (prefConfig.fps >= roundedRefreshRate) { + if (prefConfig.fps > roundedRefreshRate + 3) { + // Use frame drops when rendering above the screen frame rate + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Using drop mode for FPS > Hz"); + } else if (roundedRefreshRate <= 49) { + // Let's avoid clearly bogus refresh rates and fall back to legacy rendering + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Bogus refresh rate: " + roundedRefreshRate); + } + else { + chosenFrameRate = roundedRefreshRate - 1; + LimeLog.info("Adjusting FPS target for screen to " + chosenFrameRate); + } + } + } + + StreamConfiguration config = new StreamConfiguration.Builder() + .setResolution(prefConfig.width, prefConfig.height) + .setLaunchRefreshRate(prefConfig.fps) + .setRefreshRate(chosenFrameRate) //将此处chosenFrameRate替换为5时, 视频刷新率降低到5,但直接观察远端桌面可知,触控刷新率并未下降,窗口仍可流畅拖动。 + .setApp(app) + .setBitrate(prefConfig.bitrate) + .setResolutionScale(prefConfig.resolutionScale) + .setEnableSops(prefConfig.enableSops) + .enableLocalAudioPlayback(prefConfig.playHostAudio) + .setMaxPacketSize(1392) + .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection + .setSupportedVideoFormats(supportedVideoFormats) + .setAttachedGamepadMask(gamepadMask) + .setClientRefreshRateX100((int)(displayRefreshRate * 100)) + .setAudioConfiguration(prefConfig.audioConfiguration) + .setColorSpace(decoderRenderer.getPreferredColorSpace()) + .setColorRange(decoderRenderer.getPreferredColorRange()) + .setPersistGamepadsAfterDisconnect(!prefConfig.multiController) + .setUseVdd(pcUseVdd) + .setEnableMic(prefConfig.enableMic) + .build(); + + // Initialize the connection + conn = new NvConnection(getApplicationContext(), + new ComputerDetails.AddressTuple(host, port), + httpsPort, uniqueId, pairName, config, + PlatformBinding.getCryptoProvider(this), serverCert); + controllerHandler = new ControllerHandler(this, conn, this, prefConfig); + + // 重新创建 ControllerHandler + if (controllerHandler != null) controllerHandler.stop(); + controllerHandler = new ControllerHandler(this, conn, this, prefConfig); + + // 重新绑定 USB 驱动服务 + // 因为 stopConnection 时解绑了,这里必须重新 bind,而不是直接 setListener + if (prefConfig.usbDriver) { + // 如果旧的连接还没断开(理论上 stopConnection 已断开),先断开以防万一 + stopAndUnbindUsbDriverService(); + + // 重新绑定服务 + bindService(new Intent(this, UsbDriverService.class), + usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + } + + if (connectedToUsbDriverService && usbDriverBinder != null) { + usbDriverBinder.setListener(controllerHandler); + } + + // 重新初始化触控 + // 必须在 ControllerManager 初始化之前完成,因为 ControllerManager 会调用它来设置灵敏度 + // Initialize touch contexts + for (int i = 0; i < TOUCH_CONTEXT_LENGTH; i++) { + absoluteTouchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); + relativeTouchContextMap[i] = new RelativeTouchContext(conn, i, + streamView, prefConfig); + } + if (!prefConfig.touchscreenTrackpad) { + touchContextMap = absoluteTouchContextMap; + } + else { + touchContextMap = relativeTouchContextMap; + } + + // 重建虚拟手柄和屏幕键盘管理器 + // 必须这样做,因为它们需要绑定新的 controllerHandler 和 conn + if (virtualController != null) { + if (prefConfig.onscreenController) { + // 这里调用 refreshLayout 确保位置正确 + virtualController.refreshLayout(); + virtualController.show(); + virtualController.setGyroEnabled(true); + } + } + + if(controllerManager != null) { + // 处理王冠模式/虚拟键盘 + if (prefConfig.onscreenKeyboard) { + controllerManager.refreshLayout(); + } else { + // 如果配置变成了关闭,确保变量被清空 + controllerManager = null; + } + } + + // 重建麦克风管理器 (绑定新连接) + if (microphoneManager != null) { + microphoneManager.stopMicrophoneStream(); + } + microphoneManager = new MicrophoneManager(this, conn, prefConfig.enableMic); + microphoneManager.setStateListener(new MicrophoneManager.MicrophoneStateListener() { + @Override + public void onMicrophoneStateChanged(boolean isActive) { + LimeLog.info("麦克风状态改变: " + (isActive ? "激活" : "暂停")); + } + @Override + public void onPermissionRequested() { + LimeLog.info("麦克风权限请求已发送"); + } + }); + + // 初始化外接显示器管理器 + externalDisplayManager = new ExternalDisplayManager(this, prefConfig, conn, decoderRenderer, pcName, appName); + externalDisplayManager.setCallback(new ExternalDisplayManager.ExternalDisplayCallback() { + @Override + public void onExternalDisplayConnected(Display display) { + // 外接显示器连接时的处理 + LimeLog.info("External display connected, reinitializing input capture provider"); + + // 重新初始化输入捕获提供者以支持外接显示器 + if (inputCaptureProvider != null) { + inputCaptureProvider.disableCapture(); + } + inputCaptureProvider = InputCaptureManager.getInputCaptureProviderForExternalDisplay(Game.this, Game.this); + } + + @Override + public void onExternalDisplayDisconnected() { + // 外接显示器断开时的处理 + externalStreamView = null; + LimeLog.info("External display disconnected, cleared externalStreamView"); + + // 重新初始化输入捕获提供者回到标准模式 + if (inputCaptureProvider != null) { + inputCaptureProvider.disableCapture(); + } + inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(Game.this, Game.this); + } + + @Override + public void onStreamViewReady(StreamView streamView) { + // 保存外接显示器的StreamView引用 + externalStreamView = streamView; + + // 外接显示器StreamView准备就绪时的处理 + streamView.setOnGenericMotionListener(Game.this); + streamView.setOnKeyListener(Game.this); + streamView.setInputCallbacks(Game.this); + + // 设置触摸监听 + View backgroundTouchView = findViewById(R.id.backgroundTouchView); + if (backgroundTouchView != null) { + backgroundTouchView.setOnTouchListener(Game.this); + } + + // 设置Surface回调 + streamView.getHolder().addCallback(Game.this); + + LimeLog.info("External display StreamView ready: " + streamView.getWidth() + "x" + streamView.getHeight()); + } + }); + externalDisplayManager.initialize(); + } + + @Override + protected void onResume() { + super.onResume(); + + // 当 Activity 回到前台时,通知服务开始拦截键盘事件。 + KeyboardAccessibilityService.setIntercepting(true); + + // 获取服务实例并注册回调,这样我们就能收到从服务传来的按键事件。 + KeyboardAccessibilityService service = KeyboardAccessibilityService.getInstance(); + if (service != null) { + service.setKeyEventCallback(this); + } else { + LimeLog.warning("KeyboardAccessibilityService is not running."); } + // END: ACCESSIBILITY SERVICE INTEGRATION - if (prefConfig.usbDriver) { - // Start the USB driver - bindService(new Intent(this, UsbDriverService.class), - usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + // 刷新麦克风按钮图标(以便应用最新的颜色配置) + if (microphoneManager != null && micButton != null) { + microphoneManager.updateMicrophoneButtonState(); } + } - if (!decoderRenderer.isAvcSupported()) { - if (spinner != null) { - spinner.dismiss(); - spinner = null; - } + /** + * 实现 KeyEventCallback 接口的方法。 + * 所有被无障碍服务拦截的按键事件最终都会通过这个方法到达这里。 + * @param event 从服务传来的按键事件。 + */ + @Override + public void onKeyEvent(KeyEvent event) { + // 在调用处理方法之前,设置标志位 + isEventFromAccessibilityService = true; - // If we can't find an AVC decoder, we can't proceed - Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), - "This device or ROM doesn't support hardware accelerated H.264 playback.", true); - return; + // 将事件分发到已有的处理逻辑中 + if (event.getAction() == KeyEvent.ACTION_DOWN) { + onKeyDown(event.getKeyCode(), event); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onKeyUp(event.getKeyCode(), event); } - // The connection will be started when the surface gets created - streamView.getHolder().addCallback(this); + // 处理完毕后,重置标志位 + isEventFromAccessibilityService = false; } private void setPreferredOrientationForCurrentDisplay() { - Display display = getWindowManager().getDefaultDisplay(); - - // For semi-square displays, we use more complex logic to determine which orientation to use (if any) - if (PreferenceConfiguration.isSquarishScreen(display)) { - int desiredOrientation = Configuration.ORIENTATION_UNDEFINED; - - // OSC doesn't properly support portrait displays, so don't use it in portrait mode by default - if (prefConfig.onscreenController) { + Display display = externalDisplayManager != null ? + externalDisplayManager.getTargetDisplay() : getWindowManager().getDefaultDisplay(); + + // 首先确定基于分辨率的所需方向 + int desiredOrientation = Configuration.ORIENTATION_UNDEFINED; + + // 根据配置的宽高比确定横屏或竖屏 + if (prefConfig.width > prefConfig.height) { + desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; + } else if (prefConfig.height > prefConfig.width) { + desiredOrientation = Configuration.ORIENTATION_PORTRAIT; + } else { + // 宽高相等的情况 + // 如果使用屏幕控制器,默认使用横屏 + if (prefConfig.onscreenController || prefConfig.onscreenKeyboard) { desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; } + } - // For native resolution, we will lock the orientation to the one that matches the specified resolution - if (PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height)) { - if (prefConfig.width > prefConfig.height) { - desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; - } - else { - desiredOrientation = Configuration.ORIENTATION_PORTRAIT; - } - } - + if (prefConfig.rotableScreen) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER); + } else if (PreferenceConfiguration.isSquarishScreen(display)) { + // 对于接近正方形的屏幕,应用更复杂的逻辑 if (desiredOrientation == Configuration.ORIENTATION_LANDSCAPE) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); - } - else if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { + } else if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); - } - else { - // If we don't have a reason to lock to portrait or landscape, allow any orientation + } else { + // 没有明确的理由锁定为横屏或竖屏时,允许任意方向 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER); } + } else { + // 对于非方形屏幕,按照分辨率决定方向 + if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); + } else { + // 默认或横屏情况 + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + } } - else { - // For regular displays, we always request landscape - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + // 将权限结果传递给麦克风管理器 + if (microphoneManager != null) { + microphoneManager.onRequestPermissionsResult(requestCode, permissions, grantResults); } } @@ -585,6 +1265,10 @@ public void onConfigurationChanged(Configuration newConfig) { // Refresh layout of OSC for possible new screen size virtualController.refreshLayout(); } + if (controllerManager != null) { + // Refresh layout of OSC for possible new screen size + controllerManager.refreshLayout(); + } // Hide on-screen overlays in PiP mode if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -595,11 +1279,20 @@ public void onConfigurationChanged(Configuration newConfig) { virtualController.hide(); } - performanceOverlayView.setVisibility(View.GONE); + if (performanceOverlayManager != null) { + performanceOverlayManager.hideOverlayImmediate(); + } notificationOverlayView.setVisibility(View.GONE); + // 隐藏麦克风按钮 + if (microphoneManager != null) { + microphoneManager.setEnableMic(false); + } + // Disable sensors while in PiP mode - controllerHandler.disableSensors(); + if (controllerHandler != null) { + controllerHandler.disableSensors(); + } // Update GameManager state to indicate we're in PiP (still gaming, but interruptible) UiHelper.notifyStreamEnteringPiP(this); @@ -613,19 +1306,35 @@ public void onConfigurationChanged(Configuration newConfig) { virtualController.show(); } - if (prefConfig.enablePerfOverlay) { - performanceOverlayView.setVisibility(View.VISIBLE); + if (performanceOverlayManager != null) { + performanceOverlayManager.applyRequestedVisibility(); + } + if (requestedNotificationOverlayVisibility == View.VISIBLE) { + notificationOverlayView.setVisibility(View.VISIBLE); + } else { + notificationOverlayView.setVisibility(View.GONE); } - notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + // 恢复麦克风按钮 + if (microphoneManager != null) { + microphoneManager.setEnableMic(prefConfig.enableMic); + } // Enable sensors again after exiting PiP - controllerHandler.enableSensors(); + if (controllerHandler != null) { + controllerHandler.enableSensors(); + + // 恢复陀螺仪功能(如果之前启用了) + controllerHandler.onSensorsReenabled(); + } // Update GameManager state to indicate we're out of PiP (gaming, non-interruptible) UiHelper.notifyStreamExitingPiP(this); } } + + // Re-apply display position + refreshDisplayPosition(); } @TargetApi(Build.VERSION_CODES.O) @@ -657,7 +1366,7 @@ else if (pcName != null) { return builder.build(); } - private void updatePipAutoEnter() { + public void updatePipAutoEnter() { if (!prefConfig.enablePip) { return; } @@ -691,13 +1400,10 @@ public void setMetaKeyCaptureState(boolean enabled) { LimeLog.warning("SemWindowManager.getInstance() returned null"); } } catch (ClassNotFoundException e) { - e.printStackTrace(); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); + // This is expected on non-Samsung devices - silently ignore + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + // Log other unexpected errors + LimeLog.warning("Failed to set meta key capture state: " + e.getMessage()); } } @@ -705,6 +1411,22 @@ public void setMetaKeyCaptureState(boolean enabled) { public void onUserLeaveHint() { super.onUserLeaveHint(); + // 获取用户设置,判断是否启用“快速恢复串流” + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + boolean isResumeEnabled = prefs.getBoolean("checkbox_resume_stream", false); + + // 只有在开关开启时,才允许标记 resume + if (isResumeEnabled) { + // 如果没有进入画中画模式,则标记为需要在回来时恢复会话 + if (!autoEnterPip && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + shouldResumeSession = true; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Android 12+ 自动 PiP,如果系统没有触发 PiP,我们假设是后台 + // 注意:如果 Android 12 自动进入了 PiP,Activity 不会 Stop,也就不会触发恢复逻辑,这是符合预期的 + shouldResumeSession = true; + } + } + // PiP is only supported on Oreo and later, and we don't need to manually enter PiP on // Android S and later. On Android R, we will use onPictureInPictureRequested() instead. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { @@ -781,7 +1503,8 @@ private boolean mayReduceRefreshRate() { } private float prepareDisplayForRendering() { - Display display = getWindowManager().getDefaultDisplay(); + Display display = externalDisplayManager != null ? + externalDisplayManager.getTargetDisplay() : getWindowManager().getDefaultDisplay(); WindowManager.LayoutParams windowLayoutParams = getWindow().getAttributes(); float displayRefreshRate; @@ -880,7 +1603,7 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { // If we only changed refresh rate and we're on an OS that supports Surface.setFrameRate() // use that instead of using preferredDisplayModeId to avoid the possibility of triggering // bugs that can cause the system to switch from 4K60 to 4K24 on Chromecast 4K. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || UiHelper.isColorOS() || display.getMode().getPhysicalWidth() != bestMode.getPhysicalWidth() || display.getMode().getPhysicalHeight() != bestMode.getPhysicalHeight()) { // Apply the display mode change @@ -971,28 +1694,28 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { @SuppressLint("InlinedApi") private final Runnable hideSystemUi = new Runnable() { - @Override - public void run() { - // TODO: Do we want to use WindowInsetsController here on R+ instead of - // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... - - // In multi-window mode on N+, we need to drop our layout flags or we'll - // be drawing underneath the system UI. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - else { - // Use immersive mode - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); - } + @Override + public void run() { + // TODO: Do we want to use WindowInsetsController here on R+ instead of + // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... + + // In multi-window mode on N+, we need to drop our layout flags or we'll + // be drawing underneath the system UI. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + else { + // Use immersive mode + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); } + } }; private void hideSystemUi(int delay) { @@ -1044,17 +1767,33 @@ protected void onDestroy() { highPerfWifiLock.release(); } - if (connectedToUsbDriverService) { - // Unbind from the discovery service - unbindService(usbDriverServiceConnection); - } + stopAndUnbindUsbDriverService(); // Destroy the capture provider inputCaptureProvider.destroy(); + + // 清理外接显示器管理器 + if (externalDisplayManager != null) { + externalDisplayManager.cleanup(); + } + + // 清理麦克风流 + if (microphoneManager != null) { + microphoneManager.stopMicrophoneStream(); + } } @Override protected void onPause() { + // 当 Activity 进入后台时,必须停止拦截,否则会影响手机的正常使用! + KeyboardAccessibilityService.setIntercepting(false); + + // 注销回调,防止内存泄漏。 + KeyboardAccessibilityService service = KeyboardAccessibilityService.getInstance(); + if (service != null) { + service.setKeyEventCallback(null); + } + if (isFinishing()) { // Stop any further input device notifications before we lose focus (and pointer capture) if (controllerHandler != null) { @@ -1072,16 +1811,45 @@ protected void onPause() { protected void onStop() { super.onStop(); - SpinnerDialog.closeDialogs(this); + // 检查是否是因为进入后台(包括锁屏、滑到任务栏、Home键)导致的应用停止 + // 只要 Activity 不是正在 Finishing(即不是用户点了退出或崩溃),且开启了快速恢复,就标记为需要恢复 + if (!shouldResumeSession && !isFinishing()) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + boolean isResumeEnabled = prefs.getBoolean("checkbox_resume_stream", false); + + if (isResumeEnabled) { + shouldResumeSession = true; + LimeLog.info("检测到应用进入后台(非主动退出),已标记为待恢复会话"); + } + } + + if (progressOverlay != null) { + progressOverlay.dismiss(); + progressOverlay = null; + } Dialog.closeDialogs(); if (virtualController != null) { virtualController.hide(); + virtualController.cleanup(); // 清理陀螺仪传感器监听 } - if (conn != null) { + String decoderMessage = "UNKNOWN"; + if (decoderRenderer != null) { int videoFormat = decoderRenderer.getActiveVideoFormat(); + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + decoderMessage = "H.264"; + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + decoderMessage = "HEVC"; + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + decoderMessage = "AV1"; + } + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0) { + decoderMessage += " HDR"; + } + } + if (conn != null) { displayedFailureDialog = true; stopConnection(); @@ -1102,25 +1870,28 @@ else if (averageDecoderLat > 0) { // Add the video codec to the post-stream toast if (message != null) { message += " ["; + message += decoderMessage; + message += "]"; + } - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - message += "H.264"; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { - message += "HEVC"; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { - message += "AV1"; - } - else { - message += "UNKNOWN"; + // Add microphone quality statistics if microphone was enabled and used + if (prefConfig.enableMic && microphoneManager != null) { + String micStats = AudioDiagnostics.getCurrentStats(this); + if (message != null) { + message += " [mic]" + micStats; + } else { + message = micStats; } + } - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0) { - message += " HDR"; + // Add Surface Flinger Raw mode frame skip statistics + String surfaceFlingerStats = decoderRenderer.getSurfaceFlingerStats(); + if (surfaceFlingerStats != null) { + if (message != null) { + message += "\n" + surfaceFlingerStats; + } else { + message = surfaceFlingerStats; } - - message += "]"; } if (message != null) { @@ -1137,7 +1908,33 @@ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { } } - finish(); + // 记录游戏流媒体结束事件 + if (analyticsManager != null && pcName != null && streamStartTime > 0) { + long streamDuration = System.currentTimeMillis() - streamStartTime; + + // 收集性能数据 + int resolutionWidth = 0; + int resolutionHeight = 0; + int averageEndToEndLatency = 0; + int averageDecoderLatency = 0; + + if (decoderRenderer != null) { + resolutionWidth = prefConfig.width; + resolutionHeight = prefConfig.height; + averageEndToEndLatency = decoderRenderer.getAverageEndToEndLatency(); + averageDecoderLatency = decoderRenderer.getAverageDecoderLatency(); + } + + analyticsManager.logGameStreamEnd(pcName, appName, streamDuration, + decoderMessage, resolutionWidth, resolutionHeight, + averageEndToEndLatency, averageDecoderLatency); + } + + if (shouldResumeSession) { + LimeLog.info("应用进入后台,保持 Activity 存活以备快速恢复。连接已断开。"); + } else { + finish(); + } } private void setInputGrabState(boolean grab) { @@ -1307,6 +2104,26 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { @Override public boolean handleKeyDown(KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_HOME: + case KeyEvent.KEYCODE_APP_SWITCH: + // 如果是系统导航键,则跳过我们的去重逻辑, + // 让事件继续被正常处理。 + break; + default: + // 只有当事件不是来自服务、服务正在运行、且事件源不是虚拟键盘(即来自物理键盘)时, + // 才将其判定为重复事件并忽略。 + InputDevice device = event.getDevice(); + if (!isEventFromAccessibilityService && + KeyboardAccessibilityService.getInstance() != null && + (device != null && !device.isVirtual())) { + + return true; + } + break; + } + // Pass-through virtual navigation keys if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { return false; @@ -1389,6 +2206,43 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { @Override public boolean handleKeyUp(KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_HOME: + case KeyEvent.KEYCODE_APP_SWITCH: + // 如果是系统导航键,则跳过我们的去重逻辑。 + break; + default: + // 如果是普通游戏按键,则执行去重逻辑。 + InputDevice device = event.getDevice(); + if (!isEventFromAccessibilityService && + KeyboardAccessibilityService.getInstance() != null && + (device != null && !device.isVirtual())) { + + return true; + } + break; + } + + if (isPhysicalKeyboardConnected()) { + // ESC键双击逻辑 + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE && prefConfig.enableEscMenu) { + long currentTime = System.currentTimeMillis(); + + if (currentTime - lastEscPressTime <= ESC_DOUBLE_PRESS_INTERVAL && hasShownEscHint) { + // 第二次按ESC,弹出游戏菜单 + onBackPressed(); + lastEscPressTime = 0; + hasShownEscHint = false; + return true; // 消费事件,不发送给主机 + } else { + // 第一次按ESC,显示提示但透传给主机 + Toast.makeText(this, "再次按 ESC 键打开串流菜单", Toast.LENGTH_SHORT).show(); + lastEscPressTime = currentTime; + hasShownEscHint = true; + } + } + } // Pass-through virtual navigation keys if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { return false; @@ -1478,13 +2332,104 @@ private TouchContext getTouchContext(int actionIndex) } } + public TouchContext[] getTouchContextMap() { + return touchContextMap; + } + + public RelativeTouchContext[] getRelativeTouchContextMap(){ + RelativeTouchContext[] result = new RelativeTouchContext[relativeTouchContextMap.length]; + for (int i = 0; i < relativeTouchContextMap.length; i++) { + if (relativeTouchContextMap[i] instanceof RelativeTouchContext) { + result[i] = (RelativeTouchContext) relativeTouchContextMap[i]; + } + } + return result; + } + + /** + * false : AbsoluteTouchContext + * true : RelativeTouchContext + */ + public void setTouchMode(boolean enableRelativeTouch){ + + for (int i = 0; i < touchContextMap.length; i++) { + if (enableRelativeTouch) { + prefConfig.touchscreenTrackpad = true; + prefConfig.enableNativeMousePointer = false; + touchContextMap = relativeTouchContextMap; + refreshLocalCursorState(prefConfig.enableLocalCursorRendering); //如果本地光标处于开启状态,则开启本地光标 + } + else { + prefConfig.touchscreenTrackpad = false; + touchContextMap = absoluteTouchContextMap; + refreshLocalCursorState(false); //关闭本地光标 + } + } + } + + public void setEnhancedTouch(boolean enableRelativeTouch){ + prefConfig.enableEnhancedTouch = enableRelativeTouch; + if(prefConfig.enableEnhancedTouch){ + prefConfig.enableNativeMousePointer = false; + } + + } + @Override public void toggleKeyboard() { LimeLog.info("Toggling keyboard overlay"); + + // Hack: allows use keyboard by dpad or controller + streamView.clearFocus(); + InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputManager.toggleSoftInput(0, 0); } + /** + * 启用或禁用安卓本地鼠标指针 + */ + public void enableNativeMousePointer(boolean enable) { + LimeLog.info("Setting native mouse pointer: " + enable); + + prefConfig.enableNativeMousePointer = enable; + + if (enable) { + // 启用本地鼠标指针:释放鼠标捕获但保持键盘捕获 + inputCaptureProvider.disableCapture(); + cursorVisible = true; + + // 显示系统鼠标指针 + if (inputCaptureProvider != null) { + inputCaptureProvider.showCursor(); + } + + // 保持键盘快捷键捕获,确保Ctrl+Alt+Shift等组合键仍然工作 + setMetaKeyCaptureState(true); + + // 注意:我们不设置 grabbedInput = false,这样按键事件仍能正常处理 + + refreshLocalCursorState(true);//开启本地光标服务 + + // 切换 CursorView 的可见性 + CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay != null) { + cursorOverlay.hide(); + } + } else { + // 禁用本地鼠标指针:恢复正常的输入捕获状态 + cursorVisible = false; + + // 隐藏系统鼠标指针 + if (inputCaptureProvider != null) { + inputCaptureProvider.hideCursor(); + } + + setInputGrabState(true); + } + + } + private byte getLiTouchTypeFromEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: @@ -1526,25 +2471,49 @@ private byte getLiTouchTypeFromEvent(MotionEvent event) { } } + + /** + * getStreamViewRelativeNormalizedXY + * 正确地处理了视图的平移(Pan)和缩放(Zoom)。 + */ private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, int pointerIndex) { - float normalizedX = event.getX(pointerIndex); - float normalizedY = event.getY(pointerIndex); + StreamView activeStreamView = getActiveStreamView(); + if (activeStreamView == null) { + return new float[] { 0.0f, 0.0f }; + } - // For the containing background view, we must subtract the origin - // of the StreamView to get video-relative coordinates. - if (view != streamView) { - normalizedX -= streamView.getX(); - normalizedY -= streamView.getY(); + // --- 第一步:获取原始屏幕坐标 --- + float rawX = event.getX(pointerIndex); + float rawY = event.getY(pointerIndex); + + // --- 第二步:进行正确的坐标逆变换(同时处理平移和缩放)--- + float scaleX = activeStreamView.getScaleX(); + float scaleY = activeStreamView.getScaleY(); + + if (scaleX == 0 || scaleY == 0) { + return new float[] { 0.0f, 0.0f }; + } + + // 计算出在游戏画面中的【绝对像素坐标】 + float absoluteX = (rawX - activeStreamView.getX()) / scaleX; + float absoluteY = (rawY - activeStreamView.getY()) / scaleY; + + // --- 第三步:将绝对像素坐标归一化为 0-1 的比例 --- + int streamWidth = activeStreamView.getWidth(); + int streamHeight = activeStreamView.getHeight(); + + if (streamWidth == 0 || streamHeight == 0) { + return new float[] { 0.0f, 0.0f }; } - normalizedX = Math.max(normalizedX, 0.0f); - normalizedY = Math.max(normalizedY, 0.0f); + float normalizedX = absoluteX / streamWidth; + float normalizedY = absoluteY / streamHeight; + + // 确保坐标在 [0.0, 1.0] 的范围内,防止越界 + normalizedX = Math.max(0.0f, Math.min(1.0f, normalizedX)); + normalizedY = Math.max(0.0f, Math.min(1.0f, normalizedY)); - normalizedX = Math.min(normalizedX, streamView.getWidth()); - normalizedY = Math.min(normalizedY, streamView.getHeight()); - normalizedX /= streamView.getWidth(); - normalizedY /= streamView.getHeight(); return new float[] { normalizedX, normalizedY }; } @@ -1700,6 +2669,14 @@ private boolean trySendPenEvent(View view, MotionEvent event) { handledStylusEvent = true; } + // 为触控笔事件添加增强触控支持 + if (prefConfig.enableEnhancedTouch) { + NativeTouchContext.Pointer pointer = nativeTouchPointerMap.get(event.getPointerId(i)); + if (pointer != null) { + pointer.updatePointerCoords(event, i); // 更新指针坐标 + } + } + if (!sendPenEventForPointer(view, event, eventType, toolType, i)) { // Pen events aren't supported by the host return false; @@ -1720,12 +2697,40 @@ else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { // Not a stylus event return false; } + + // 为触控笔事件添加增强触控支持 + if (prefConfig.enableEnhancedTouch) { + int actionIndex = event.getActionIndex(); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_HOVER_ENTER: + // 创建新的Pointer实例 + NativeTouchContext.Pointer pointer = new NativeTouchContext.Pointer(event); + nativeTouchPointerMap.put(pointer.getPointerId(), pointer); + break; + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_HOVER_EXIT: + // 移除Pointer实例 + nativeTouchPointerMap.remove(event.getPointerId(actionIndex)); + break; + case MotionEvent.ACTION_HOVER_MOVE: + // 更新悬空指针的坐标 + NativeTouchContext.Pointer hoverPointer = nativeTouchPointerMap.get(event.getPointerId(actionIndex)); + if (hoverPointer != null) { + hoverPointer.updatePointerCoords(event, actionIndex); + } + break; + } + } + return sendPenEventForPointer(view, event, eventType, toolType, event.getActionIndex()); } } private boolean sendTouchEventForPointer(View view, MotionEvent event, byte eventType, int pointerIndex) { - float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); + float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); // normalized Coords就是坐标占长或宽的比例,最小0,最大1 float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); return conn.sendTouchEvent(eventType, event.getPointerId(pointerIndex), normalizedCoords[0], normalizedCoords[1], @@ -1742,25 +2747,94 @@ private boolean trySendTouchEvent(View view, MotionEvent event) { if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { // Move events may impact all active pointers - for (int i = 0; i < event.getPointerCount(); i++) { - if (!sendTouchEventForPointer(view, event, eventType, i)) { - return false; + int pointerCount = event.getPointerCount(); + if (prefConfig.enableEnhancedTouch) { + for (int i = 0; i < pointerCount; i++) { + NativeTouchContext.Pointer pointer = nativeTouchPointerMap.get(event.getPointerId(i)); + if (pointer != null) { + pointer.updatePointerCoords(event, i); // 更新指针坐标 + } + if (!sendTouchEventForPointer(view, event, eventType, i)) { + return false; + } + } + } else { + for (int i = 0; i < pointerCount; i++) { + if (!sendTouchEventForPointer(view, event, eventType, i)) { + return false; + } } } return true; - } - else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + } else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { // Cancel impacts all active pointers return conn.sendTouchEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, 0, 0, 0, 0, 0, 0, MoonBridge.LI_ROT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; - } - else { + } else { + int actionIndex = event.getActionIndex(); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_POINTER_DOWN: + multiFingerTapChecker(event); + case MotionEvent.ACTION_DOWN: // first & following finger down. + if (prefConfig.enableEnhancedTouch) { + NativeTouchContext.Pointer pointer = new NativeTouchContext.Pointer(event); //create a Pointer Instance for new touch pointer, put it into the map. + nativeTouchPointerMap.put(pointer.getPointerId(), pointer); + } + break; + case MotionEvent.ACTION_UP: // all fingers up + // toggle keyboard when all fingers lift up, just like how it works in trackpad mode. + if (event.getEventTime() - multiFingerDownTime < MULTI_FINGER_TAP_THRESHOLD) { + toggleKeyboard(); + } + break; + case MotionEvent.ACTION_POINTER_UP: + if (prefConfig.enableEnhancedTouch) { + nativeTouchPointerMap.remove(event.getPointerId(actionIndex)); + } + break; + } // Up, Down, and Hover events are specific to the action index - return sendTouchEventForPointer(view, event, eventType, event.getActionIndex()); + return sendTouchEventForPointer(view, event, eventType, actionIndex); } } + private void multiFingerTapChecker (MotionEvent event) { + if (event.getPointerCount() == prefConfig.nativeTouchFingersToToggleKeyboard) { + // number of fingers to tap is defined by prefConfig.nativeTouchFingersToToggleKeyboard, configurable from 3 to 10, and -1(disabled) in menu. + + // Cancel the first and second touches to avoid + // erroneous events + // for (TouchContext aTouchContext : touchContextMap) { + // aTouchContext.cancelTouch(); + // } + multiFingerDownTime = event.getEventTime(); + } + } + + // 处理缩放下的经典鼠标模式 + /** + * 核心坐标转换函数 + * 将屏幕上的原始触摸坐标,根据 streamView 的平移和缩放状态,转换为游戏内的“真实”坐标。 + */ + private float[] getNormalizedCoordinates(View streamView, float rawX, float rawY) { + if (streamView == null) { + return new float[] { rawX, rawY }; + } + float scaleX = streamView.getScaleX(); + float scaleY = streamView.getScaleY(); + + // 防止除以零 + if (scaleX == 0 || scaleY == 0) { + return new float[] { rawX, rawY }; + } + + float normalizedX = (rawX - streamView.getX()) / scaleX; + float normalizedY = (rawY - streamView.getY()) / scaleY; + + return new float[] { normalizedX, normalizedY }; + } + // Returns true if the event was consumed // NB: View is only present if called from a view callback private boolean handleMotionEvent(View view, MotionEvent event) { @@ -1771,7 +2845,64 @@ private boolean handleMotionEvent(View view, MotionEvent event) { int eventSource = event.getSource(); int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0; - if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + + // 本地鼠标指针模式的特殊处理 + if (prefConfig.enableNativeMousePointer && (eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0) { + // 检查是否为真正的鼠标设备(而不是触摸屏) + boolean isActualMouse = (eventSource == InputDevice.SOURCE_MOUSE) || + (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) || + (event.getPointerCount() >= 1 && + event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) || + (eventSource == 12290); // Samsung DeX mode + + if (isActualMouse) { + LimeLog.info("Native mouse event (processing): " + event.getActionMasked() + + ", source: " + eventSource + + ", x: " + event.getX() + + ", y: " + event.getY() + + ", buttons: " + event.getButtonState()); + + // 在本地鼠标指针模式下,直接处理鼠标事件 + updateMousePosition(view, event); + + int buttonState = event.getButtonState(); + int changedButtons = buttonState ^ lastButtonState; + + if ((changedButtons & MotionEvent.BUTTON_PRIMARY) != 0) { + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } + if ((changedButtons & MotionEvent.BUTTON_SECONDARY) != 0) { + if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + if ((changedButtons & MotionEvent.BUTTON_TERTIARY) != 0) { + if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_MIDDLE); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); + } + } + + // 处理滚轮事件 + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + conn.sendMouseHighResScroll((short)(event.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120)); + conn.sendMouseHighResHScroll((short)(event.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120)); + } + + lastButtonState = buttonState; + return true; + } + // 如果不是真正的鼠标设备(比如触摸屏),继续让后续代码处理 + } + + if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { //手柄所属条件 if (controllerHandler.handleMotionEvent(event)) { return true; } @@ -1780,12 +2911,12 @@ else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerH return true; } else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) { - // This case is for mice and non-finger touch devices + // This case is for mice and non-finger touch devices, 非手指触控功能所属判断条件 if (eventSource == InputDevice.SOURCE_MOUSE || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD虚拟手柄 eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || (event.getPointerCount() >= 1 && (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || @@ -1834,7 +2965,8 @@ else if (event.getAction() == MotionEvent.ACTION_UP) { if (prefConfig.absoluteMouseMode) { // NB: view may be null, but we can unconditionally use streamView because we don't need to adjust // relative axis deltas for the position of the streamView within the parent's coordinate system. - conn.sendMouseMoveAsMousePosition(deltaX, deltaY, (short)streamView.getWidth(), (short)streamView.getHeight()); + StreamView activeStreamView = getActiveStreamView(); + conn.sendMouseMoveAsMousePosition(deltaX, deltaY, (short)activeStreamView.getWidth(), (short)activeStreamView.getHeight()); } else { conn.sendMouseMove(deltaX, deltaY); @@ -1972,8 +3104,24 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask lastButtonState = buttonState; } // This case is for fingers - else + else //abs touch 和 屏幕虚拟手柄所属的判断条件 { + // 如果处于手势模式,则消费事件用于视图操作,然后立即返回 + if (isTouchOverrideEnabled) { + panZoomHandler.handleTouchEvent(event); + return true; // 事件被完全消费,不传递给游戏 + } + // TODO: Re-enable native touch when have a better solution for handling + // cancelled touches from Android gestures and 3 finger taps to activate + // the software keyboard. + // --- 多点触控模式 --- + // 检查是否启用了多点触控,并调用 trySendTouchEvent。 + if (!prefConfig.touchscreenTrackpad && prefConfig.enableEnhancedTouch && trySendTouchEvent(view, event)) { + // If this host supports touch events and absolute touch is enabled, + // send it directly as a touch event. + return true; + } + if (virtualController != null && (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons || virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)) { @@ -1983,26 +3131,13 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask // If this is the parent view, we'll offset our coordinates to appear as if they // are relative to the StreamView like our StreamView touch events are. - float xOffset, yOffset; - if (view != streamView && !prefConfig.touchscreenTrackpad) { - xOffset = -streamView.getX(); - yOffset = -streamView.getY(); - } - else { - xOffset = 0.f; - yOffset = 0.f; - } - int actionIndex = event.getActionIndex(); - int eventX = (int)(event.getX(actionIndex) + xOffset); - int eventY = (int)(event.getY(actionIndex) + yOffset); - // Special handling for 3 finger gesture if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && event.getPointerCount() == 3) { // Three fingers down - threeFingerDownTime = event.getEventTime(); + multiFingerDownTime = event.getEventTime(); // Cancel the first and second touches to avoid // erroneous events @@ -2029,68 +3164,66 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask switch (event.getActionMasked()) { - case MotionEvent.ACTION_POINTER_DOWN: - case MotionEvent.ACTION_DOWN: - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount()); - } - context.touchDownEvent(eventX, eventY, event.getEventTime(), true); - break; - case MotionEvent.ACTION_POINTER_UP: - case MotionEvent.ACTION_UP: - if (event.getPointerCount() == 1 && - (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { - // All fingers up - if (event.getEventTime() - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { - // This is a 3 finger tap to bring up the keyboard - toggleKeyboard(); - return true; + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: { + float[] normalizedCoords = getNormalizedCoordinates(streamView, event.getX(actionIndex), event.getY(actionIndex)); + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount()); } + context.touchDownEvent((int) normalizedCoords[0], (int) normalizedCoords[1], event.getEventTime(), true); + break; } + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: { + // 对主触摸点进行转换 + float[] normalizedCoords = getNormalizedCoordinates(streamView, event.getX(actionIndex), event.getY(actionIndex)); + if (event.getPointerCount() == 1 && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { + // All fingers up + if (event.getEventTime() - multiFingerDownTime < MULTI_FINGER_TAP_THRESHOLD) { + // This is a 3 finger tap to bring up the keyboard + // multiFingerDownTime, previously threeFingerDowntime, is also used in native-touch for keyboard toggle. + toggleKeyboard(); + return true; + } + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { - context.cancelTouch(); - } - else { - context.touchUpEvent(eventX, eventY, event.getEventTime()); - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + context.cancelTouch(); + } else { + context.touchUpEvent((int) normalizedCoords[0], (int) normalizedCoords[1], event.getEventTime()); + } - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount() - 1); - } - if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { - // The original secondary touch now becomes primary - context.touchDownEvent( - (int)(event.getX(1) + xOffset), - (int)(event.getY(1) + yOffset), - event.getEventTime(), false); + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount() - 1); + } + if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { + // 对于多点触控的特殊情况,也需要转换第二个触摸点的坐标 + float[] normalizedSecondaryCoords = getNormalizedCoordinates(streamView, event.getX(1), event.getY(1)); + context.touchDownEvent( + (int) normalizedSecondaryCoords[0], + (int) normalizedSecondaryCoords[1], + event.getEventTime(), false); + } + break; } - break; case MotionEvent.ACTION_MOVE: - // ACTION_MOVE is special because it always has actionIndex == 0 - // We'll call the move handlers for all indexes manually - - // First process the historical events + // ACTION_MOVE 的处理需要更仔细,因为它有历史事件 + // 首先处理历史事件 for (int i = 0; i < event.getHistorySize(); i++) { for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getHistoricalX(aTouchContextMap.getActionIndex(), i) + xOffset), - (int)(event.getHistoricalY(aTouchContextMap.getActionIndex(), i) + yOffset), - event.getHistoricalEventTime(i)); + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) { + float[] histCoords = getNormalizedCoordinates(streamView, event.getHistoricalX(aTouchContextMap.getActionIndex(), i), event.getHistoricalY(aTouchContextMap.getActionIndex(), i)); + aTouchContextMap.touchMoveEvent((int) histCoords[0], (int) histCoords[1], event.getHistoricalEventTime(i)); } } } // Now process the current values for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getX(aTouchContextMap.getActionIndex()) + xOffset), - (int)(event.getY(aTouchContextMap.getActionIndex()) + yOffset), - event.getEventTime()); + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) { + float[] currentCoords = getNormalizedCoordinates(streamView, event.getX(aTouchContextMap.getActionIndex()), event.getY(aTouchContextMap.getActionIndex())); + aTouchContextMap.touchMoveEvent((int) currentCoords[0], (int) currentCoords[1], event.getEventTime()); } } break; @@ -2120,19 +3253,22 @@ public boolean onGenericMotionEvent(MotionEvent event) { } private void updateMousePosition(View touchedView, MotionEvent event) { + // 获取当前活动的StreamView + StreamView activeStreamView = getActiveStreamView(); + // X and Y are already relative to the provided view object float eventX, eventY; // For our StreamView itself, we can use the coordinates unmodified. - if (touchedView == streamView) { + if (touchedView == activeStreamView) { eventX = event.getX(0); eventY = event.getY(0); } else { // For the containing background view, we must subtract the origin // of the StreamView to get video-relative coordinates. - eventX = event.getX(0) - streamView.getX(); - eventY = event.getY(0) - streamView.getY(); + eventX = event.getX(0) - activeStreamView.getX(); + eventY = event.getY(0) - activeStreamView.getY(); } if (event.getPointerCount() == 1 && event.getActionIndex() == 0 && @@ -2162,14 +3298,41 @@ private void updateMousePosition(View touchedView, MotionEvent event) { } } - // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. - // Normalize these to the view size. We can't just drop them because we won't always get an event - // right at the boundary of the view, so dropping them would result in our cursor never really - // reaching the sides of the screen. - eventX = Math.min(Math.max(eventX, 0), streamView.getWidth()); - eventY = Math.min(Math.max(eventY, 0), streamView.getHeight()); + if (externalDisplayManager != null && externalDisplayManager.isUsingExternalDisplay()) { + int streamViewWidth = activeStreamView.getWidth(); + int streamViewHeight = activeStreamView.getHeight(); - conn.sendMousePosition((short)eventX, (short)eventY, (short)streamView.getWidth(), (short)streamView.getHeight()); + // 获取设备的分辨率 + Point size = new Point(); + Display display = getWindowManager().getDefaultDisplay(); + display.getRealSize(size); + int deviceWidth = size.x; + int deviceHeight = size.y; + + float scaleX = (float) streamViewWidth / deviceWidth; + float scaleY = (float) streamViewHeight / deviceHeight; + + float scaledX = eventX * scaleX; + float scaledY = eventY * scaleY; + + eventX = Math.max(0, Math.min(scaledX, streamViewWidth)); + eventY = Math.max(0, Math.min(scaledY, streamViewHeight)); + } else { + // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. + // Normalize these to the view size. We can't just drop them because we won't always get an event + // right at the boundary of the view, so dropping them would result in our cursor never really + // reaching the sides of the screen. + eventX = Math.min(Math.max(eventX, 0), activeStreamView.getWidth()); + eventY = Math.min(Math.max(eventY, 0), activeStreamView.getHeight()); + } + + conn.sendMousePosition((short)eventX, (short)eventY, (short)activeStreamView.getWidth(), (short)activeStreamView.getHeight()); + +// // 当鼠标移动时,同步更新本地光标的位置 +// CursorView cursorOverlay = findViewById(R.id.cursorOverlay); +// if (cursorOverlay != null && prefConfig.enableLocalCursorRendering) { +// cursorOverlay.updateCursorPosition(eventX, eventY); +// } } @Override @@ -2184,10 +3347,12 @@ public boolean onTouch(View view, MotionEvent event) { // Tell the OS not to buffer input events for us // // NB: This is still needed even when we call the newer requestUnbufferedDispatch()! - view.requestUnbufferedDispatch(event); + // Add a configuration to allow view.requestUnbufferedDispatch to be disabled. + if(!prefConfig.syncTouchEventWithDisplay) { + view.requestUnbufferedDispatch(event); + } } - - return handleMotionEvent(view, event); + return handleMotionEvent(view, event); //Y700平板上, onTouch的调用频率为120Hz } @Override @@ -2195,8 +3360,8 @@ public void stageStarting(final String stage) { runOnUiThread(new Runnable() { @Override public void run() { - if (spinner != null) { - spinner.setMessage(getResources().getString(R.string.conn_starting) + " " + stage); + if (progressOverlay != null) { + progressOverlay.setMessage(getResources().getString(R.string.conn_starting) + " " + stage); } } }); @@ -2207,15 +3372,35 @@ public void stageComplete(String stage) { } private void stopConnection() { + // 重置尝试连接标志。 + // 这确保了当 Activity 驻留在后台未销毁,再次回到前台触发 surfaceChanged 时, + // 代码会认为这是一个新的开始,从而再次执行 conn.start()。 + attemptedConnection = false; + if (connecting || connected) { connecting = connected = false; updatePipAutoEnter(); - controllerHandler.stop(); + if (controllerHandler != null) { + controllerHandler.stop(); + } + + // 停止并释放 USB 控制器接管 + stopAndUnbindUsbDriverService(); + + // 停止麦克风流 + if (microphoneManager != null) { + microphoneManager.stopMicrophoneStream(); + } // Update GameManager state to indicate we're no longer in game UiHelper.notifyStreamEnded(this); + // Save current settings for this app before stopping connection + if (appSettingsManager != null && computerUuid != null && app != null) { + appSettingsManager.saveAppLastSettings(computerUuid, app, prefConfig); + } + // Stop may take a few hundred ms to do some network I/O to tell // the server we're going away and clean up. Let it run in a separate // thread to keep things smooth for the UI. Inside moonlight-common, @@ -2226,6 +3411,8 @@ public void run() { conn.stop(); } }.start(); + + stopCursorService(); } } @@ -2238,9 +3425,9 @@ public void stageFailed(final String stage, final int portFlags, final int error runOnUiThread(new Runnable() { @Override public void run() { - if (spinner != null) { - spinner.dismiss(); - spinner = null; + if (progressOverlay != null) { + progressOverlay.dismiss(); + progressOverlay = null; } if (!displayedFailureDialog) { @@ -2285,6 +3472,8 @@ public void run() { // Stop processing controller input controllerHandler.stop(); + microphoneManager.stopMicrophoneStream(); + // Ungrab input setInputGrabState(false); @@ -2362,13 +3551,15 @@ public void run() { } if (connectionStatus == MoonBridge.CONN_STATUS_POOR) { + String message; if (prefConfig.bitrate > 5000) { - notificationOverlayView.setText(getResources().getString(R.string.slow_connection_msg)); + message = getResources().getString(R.string.slow_connection_msg); } else { - notificationOverlayView.setText(getResources().getString(R.string.poor_connection_msg)); + message = getResources().getString(R.string.poor_connection_msg); } - + + updateNotificationOverlay(connectionStatus, message); requestedNotificationOverlayVisibility = View.VISIBLE; } else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { @@ -2376,7 +3567,11 @@ else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { } if (!isHidingOverlays) { - notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + if (requestedNotificationOverlayVisibility == View.VISIBLE) { + notificationOverlayView.setVisibility(View.VISIBLE); + } else { + notificationOverlayView.setVisibility(View.GONE); + } } } }); @@ -2387,9 +3582,9 @@ public void connectionStarted() { runOnUiThread(new Runnable() { @Override public void run() { - if (spinner != null) { - spinner.dismiss(); - spinner = null; + if (progressOverlay != null) { + progressOverlay.dismiss(); + progressOverlay = null; } connected = true; @@ -2405,7 +3600,12 @@ public void run() { h.postDelayed(new Runnable() { @Override public void run() { - setInputGrabState(true); + // 根据配置决定是否启用原生鼠标指针 + if (prefConfig.enableNativeMousePointer) { + enableNativeMousePointer(true); + } else { + setInputGrabState(true); + } } }, 500); @@ -2429,6 +3629,94 @@ public void run() { // This may be null if launched from the "Resume Session" PC context menu item shortcutHelper.reportGameLaunched(computer, app); } + + // 检查是否启用了HDR并主动设置初始状态 + // 这解决了首次连接时setHdrMode没有被调用的问题 + boolean appSupportsHdr = Game.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); + if (appSupportsHdr && prefConfig.enableHdr) { + setHdrMode(true, null); + } + + // 初始化麦克风管理器 + microphoneManager = new MicrophoneManager(this, conn, prefConfig.enableMic); + microphoneManager.setStateListener(new MicrophoneManager.MicrophoneStateListener() { + @Override + public void onMicrophoneStateChanged(boolean isActive) { + // 麦克风状态改变时的回调 + LimeLog.info("麦克风状态改变: " + (isActive ? "激活" : "暂停")); + } + + @Override + public void onPermissionRequested() { + // 权限请求时的回调 + LimeLog.info("麦克风权限请求已发送"); + } + }); + + // 初始化麦克风流 + if (prefConfig.enableMic) { + runOnUiThread(() -> { + if (!microphoneManager.initializeMicrophoneStream()) { + LimeLog.warning("Failed to start microphone stream"); + } else { + LimeLog.info("Microphone stream initialized successfully"); + } + + // 更新麦克风按钮状态 + if (micButton != null) { + microphoneManager.setMicrophoneButton(micButton); + // 确保麦克风默认状态为关闭 + microphoneManager.setDefaultStateOff(); + } + }); + } + + // 记录游戏流媒体开始事件 + streamStartTime = System.currentTimeMillis(); + if (analyticsManager != null && pcName != null) { + analyticsManager.logGameStreamStart(pcName, appName); + } + + // 1. 获取并保存 IP (存到全局变量) + this.currentHostAddress = getIntent().getStringExtra(EXTRA_HOST); + + // 2. 调用统一的状态管理方法 + updateCursorServiceState(prefConfig.enableLocalCursorRendering && prefConfig.touchscreenTrackpad); + } + + @Override + protected void onStart() { + super.onStart(); + + if (shouldResumeSession) { + LimeLog.info("从后台恢复,正在快速重连..."); + + // 强制关闭所有残留的 Dialog + // 即使之前的 connectionTerminated 漏网弹出了对话框,现在也把它关掉 + Dialog.closeDialogs(); + + // 重置状态,准备迎接新的连接 + // 只有回到前台准备重连了,我们才再次关心连接失败的弹窗 + shouldResumeSession = false; + displayedFailureDialog = false; + + // 重新显示加载遮罩 + progressOverlay = new FullscreenProgressOverlay(this, app); + ComputerDetails computer = new ComputerDetails(); + computer.name = pcName; + computer.uuid = getIntent().getStringExtra(EXTRA_PC_UUID); + progressOverlay.setComputer(computer); + progressOverlay.show(getResources().getString(R.string.conn_establishing_title), + getResources().getString(R.string.conn_establishing_msg)); + + // 重新准备连接对象 + prepareConnection(); + + // 重置连接状态标志 + attemptedConnection = false; + connecting = false; + connected = false; + } } @Override @@ -2456,7 +3744,9 @@ public void run() { @Override public void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { LimeLog.info(String.format((Locale)null, "Rumble on gamepad %d: %04x %04x", controllerNumber, lowFreqMotor, highFreqMotor)); - + if (controllerManager != null){ + controllerManager.getElementController().gameVibrator(lowFreqMotor,highFreqMotor); + } controllerHandler.handleRumble(controllerNumber, lowFreqMotor, highFreqMotor); } @@ -2471,6 +3761,47 @@ public void rumbleTriggers(short controllerNumber, short leftTrigger, short righ public void setHdrMode(boolean enabled, byte[] hdrMetadata) { LimeLog.info("Display HDR mode: " + (enabled ? "enabled" : "disabled")); decoderRenderer.setHdrMode(enabled, hdrMetadata); + + // 通知系统 HDR 内容状态(在 Android Q+ 上切换 Window color mode) + // 这有助于部分 OEM(例如小米的 MIUI)在进入 HDR 时启用正确的色彩/亮度路径。 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + notifySystemHdrStatus(enabled); + } + } + + private void notifySystemHdrStatus(boolean hdrEnabled) { + runOnUiThread(() -> { + try { + // 通过 Window 设置色彩模式(该 API 在 Android Q/API29 引入) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (hdrEnabled) { + getWindow().setColorMode(ActivityInfo.COLOR_MODE_HDR); + } else { + getWindow().setColorMode(ActivityInfo.COLOR_MODE_DEFAULT); + } + } + + // 通过WindowManager.LayoutParams设置亮度 + WindowManager.LayoutParams params = getWindow().getAttributes(); + if (hdrEnabled) { + // 强制高亮度模式 + // params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL; + // 设置窗口标志以支持HDR + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + } + } else { + params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE; + } + getWindow().setAttributes(params); + + LimeLog.info("ColorOS HDR notification: Window color mode and brightness updated for HDR " + + (hdrEnabled ? "enabled" : "disabled")); + + } catch (Exception e) { + LimeLog.warning("Failed to notify ColorOS system HDR status: " + e.getMessage()); + } + }); } @Override @@ -2490,15 +3821,23 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig } if (!attemptedConnection) { - attemptedConnection = true; + attemptedConnection = true; // 标记已尝试连接 // Update GameManager state to indicate we're "loading" while connecting UiHelper.notifyStreamConnecting(Game.this); decoderRenderer.setRenderTarget(holder); - conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx), + + conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx, prefConfig.enableSpatializer), decoderRenderer, Game.this); + + if (streamView != null) { + streamView.post(this::syncCursorWithStream); + } } + + // 处理缩放手势 + panZoomHandler.handleSurfaceChange(); } @Override @@ -2543,6 +3882,9 @@ public void surfaceDestroyed(SurfaceHolder holder) { throw new IllegalStateException("Surface destroyed before creation!"); } + // 销毁本地光标渲染器 + destroyLocalCursorRenderers(); + if (attemptedConnection) { // Let the decoder know immediately that the surface is gone decoderRenderer.prepareForStop(); @@ -2553,6 +3895,316 @@ public void surfaceDestroyed(SurfaceHolder holder) { } } + /** + * 初始化本地光标渲染器 + * 通过 findViewById 找到 XML 中的 CursorView + */ + private void initializeLocalCursorRenderers(int width, int height) { + CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + + if (cursorOverlay == null) { + return; + } + + if (relativeTouchContextMap != null) { + for (TouchContext context : relativeTouchContextMap) { + if (context instanceof RelativeTouchContext) { + RelativeTouchContext relativeContext = (RelativeTouchContext) context; + relativeContext.initializeLocalCursorRenderer(cursorOverlay, width, height); + // 1. 开关必须开启 + // 2. 必须处于触控板模式 (touchscreenTrackpad == true) + // 3. 必须没开启原生鼠标 (防止冲突) + boolean shouldShow = prefConfig.enableLocalCursorRendering + && prefConfig.touchscreenTrackpad + && !prefConfig.enableNativeMousePointer; + + relativeContext.setEnableLocalCursorRendering(shouldShow); + } + } + } + } + + /** + * 销毁本地光标渲染器 + * 清理所有相对触摸上下文的光标渲染器 + */ + private void destroyLocalCursorRenderers() { + if (relativeTouchContextMap != null) { + for (TouchContext context : relativeTouchContextMap) { + if (context instanceof RelativeTouchContext) { + RelativeTouchContext relativeContext = (RelativeTouchContext) context; + relativeContext.destroyLocalCursorRenderer(); + } + } + } + } + + public void refreshLocalCursorState(boolean enabled) { + boolean shouldRender = enabled && !prefConfig.enableNativeMousePointer; + + if (relativeTouchContextMap != null) { + for (TouchContext context : relativeTouchContextMap) { + if (context instanceof RelativeTouchContext) { + ((RelativeTouchContext) context).setEnableLocalCursorRendering(shouldRender); + } + } + } + updateCursorServiceState(enabled); + } + + /** + * 强制将光标层与视频层 1:1 对齐 + */ + private void syncCursorWithStream() { + if (streamView == null) return; + CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay == null) return; + + // 获取 StreamView 当前的真实位置和大小 + float x = streamView.getX(); + float y = streamView.getY(); + int w = streamView.getWidth(); + int h = streamView.getHeight(); + + // 如果视频还没渲染出来(宽高为0),直接返回,等下次 + if (w == 0 || h == 0) return; + + ViewGroup.LayoutParams params = cursorOverlay.getLayoutParams(); + + // 1. 强制清除 Gravity + // 我们要用 setX/setY 绝对定位,所以必须把 Gravity 设为左上角,否则会发生双重偏移 + if (params instanceof android.widget.FrameLayout.LayoutParams) { + ((android.widget.FrameLayout.LayoutParams) params).gravity = android.view.Gravity.TOP | android.view.Gravity.LEFT; + } + + // 2. 同步大小 + boolean needLayout = false; + if (params.width != w || params.height != h) { + params.width = w; + params.height = h; + needLayout = true; + } + + if (needLayout) { + cursorOverlay.setLayoutParams(params); + } + + // 3. 同步位置 + cursorOverlay.setX(x); + cursorOverlay.setY(y); + + // 4. 同步渲染器边界 + if (relativeTouchContextMap != null) { + initializeLocalCursorRenderers(w, h); + } + + LimeLog.info("CursorFix:" + "Sync executed: W=" + w + " H=" + h + " X=" + x); + } + + // UDP 相关变量 + private Thread cursorNetworkThread; + private boolean isCursorNetworking = false; + private java.net.DatagramSocket cursorSocket; + private static final int CURSOR_PORT = 5005; + + private String computerIpAddress; + + + private android.util.LruCache cursorCache = new android.util.LruCache<>(100); + + private void startCursorService(String hostIp) { + if (isCursorNetworking) return; + this.computerIpAddress = hostIp; + this.isCursorNetworking = true; + + // 每次启动服务时清空缓存,防止上次残留的数据导致错乱 + if (cursorCache != null) { + cursorCache.evictAll(); + } + + cursorNetworkThread = new Thread(() -> { + try { + // 1. 初始化 Socket + cursorSocket = new java.net.DatagramSocket(); + cursorSocket.setSoTimeout(1000); // 1秒超时 + + java.net.InetAddress serverAddr = java.net.InetAddress.getByName(computerIpAddress); + byte[] helloData = "CURSOR_HELLO".getBytes("UTF-8"); + java.net.DatagramPacket helloPacket = new java.net.DatagramPacket( + helloData, helloData.length, serverAddr, CURSOR_PORT); + + // 增大缓冲区,防止 4K 屏大光标被截断 + byte[] receiveBuffer = new byte[64 * 1024]; + java.net.DatagramPacket receivePacket = new java.net.DatagramPacket(receiveBuffer, receiveBuffer.length); + + LimeLog.info("CursorNet:" + "握手开始于 " + computerIpAddress); + + long lastHelloTime = 0; + // 初始化为当前时间,避免刚启动就触发超时重置 + long lastReceiveTime = System.currentTimeMillis(); + + while (isCursorNetworking) { + // 发送握手包 (每2秒一次) + long now = System.currentTimeMillis(); + if (now - lastHelloTime > 2000) { + try { + cursorSocket.send(helloPacket); + LimeLog.info("CursorNet: 已向发送握手数据包 " + computerIpAddress); + lastHelloTime = now; + } catch (Exception e) { + LimeLog.warning("CursorNet: 发送握手数据包失败: " + e.getMessage()); + } + } + + // 接收数据 + try { + // 重置 packet 长度 + receivePacket.setLength(receiveBuffer.length); + + // 阻塞接收 + cursorSocket.receive(receivePacket); + + // 只有成功接收到数据后,才更新时间! + lastReceiveTime = System.currentTimeMillis(); + + byte[] data = receivePacket.getData(); + int length = receivePacket.getLength(); + + // 最小包长检测 + if (length >= 17) { + java.nio.ByteBuffer wrapped = java.nio.ByteBuffer.wrap(data); + wrapped.order(java.nio.ByteOrder.LITTLE_ENDIAN); + + byte type = wrapped.get(); // 0=全量, 1=缓存 + long hash = wrapped.getLong(); // CRC32 + int hotX = wrapped.getInt(); + int hotY = wrapped.getInt(); + + android.graphics.Bitmap targetBitmap = null; + + if (type == 1) { + // === 缓存命中 === + targetBitmap = cursorCache.get(hash); + LimeLog.info("CursorNet: 收到带有哈希的缓存游标 " + hash); + } else if (type == 0) { + // === 全量数据 === + int imageOffset = 17; + int imageLen = length - imageOffset; + if (imageLen > 0) { + targetBitmap = android.graphics.BitmapFactory.decodeByteArray(data, imageOffset, imageLen); + if (targetBitmap != null) { + cursorCache.put(hash, targetBitmap); // 存入缓存 + LimeLog.info("CursorNet: 收到带有哈希值的新游标 " + hash + ", size: " + imageLen + " bytes"); + } + } + } + + if (targetBitmap != null) { + final android.graphics.Bitmap finalBmp = targetBitmap; + runOnUiThread(() -> { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N && prefConfig.enableNativeMousePointer) { + // 方案B:当启用了原生指针且API版本符合时,使用 PointerIcon + PointerIcon pointerIcon = PointerIcon.create(finalBmp, hotX, hotY); + streamView.setPointerIcon(pointerIcon); + } else { + // 方案A:使用自定义View绘制 + com.limelight.ui.CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay != null) { + cursorOverlay.setCursorBitmap(finalBmp, hotX, hotY); + } + } + }); + } else { + LimeLog.warning("CursorNet: 无法解码光标位图, type: " + type + ", hash: " + hash); + } + } else { + LimeLog.warning("CursorNet: 收到的数据包太小: " + length + " bytes"); + } + }catch (java.net.SocketTimeoutException e) { + // 因为 Python 端现在每 1 秒会发一次心跳包。 + // 所以,如果我们超过 3 秒 (3000ms) 还没收到任何数据, + // 那肯定是因为服务器挂了,或者是网络断了。 + if (System.currentTimeMillis() - lastReceiveTime > 3000) { + LimeLog.warning("CursorNet: 与游标服务器的连接超时"); + + // 为了避免瞬间闪烁,再次确认时间差 + lastReceiveTime = System.currentTimeMillis(); // 重置计时,避免疯狂触发 + + runOnUiThread(() -> { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N && prefConfig.enableNativeMousePointer) { + // 恢复为默认箭头 + streamView.setPointerIcon(PointerIcon.getSystemIcon(Game.this, PointerIcon.TYPE_ARROW)); + } else { + com.limelight.ui.CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay != null) { + // 只有真的断连了,才会变回默认光标 + cursorOverlay.resetToDefault(); + LimeLog.warning("CursorNet:" + "服务器超时,正在重置光标。"); + } + } + }); + } + } catch (Exception e) { + LimeLog.warning("CursorNet: 接收数据包时出错: " + e.getMessage()); + } + } + } catch (Exception e) { + LimeLog.warning("CursorNet:" + "严重错误: " + e.getMessage()); + } finally { + if (cursorSocket != null) { + cursorSocket.close(); + cursorSocket = null; + LimeLog.info("CursorNet: 套接字已关闭"); + } + } + }); + cursorNetworkThread.start(); + } + + private void stopCursorService() { + isCursorNetworking = false; // 退出循环标志 + + // 关闭 Socket + if (cursorSocket != null) { + try { + cursorSocket.close(); + } catch (Exception e) {} + cursorSocket = null; + } + + // 清空画布 UI + runOnUiThread(() -> { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N && prefConfig.enableNativeMousePointer) { + streamView.setPointerIcon(PointerIcon.getSystemIcon(Game.this, PointerIcon.TYPE_ARROW)); + } else { + CursorView cursorOverlay = findViewById(R.id.cursorOverlay); + if (cursorOverlay != null) { + cursorOverlay.resetToDefault(); + } + } + }); + } + + /** + * 根据当前配置和运行状态,决定是启动还是停止光标服务 + */ + public void updateCursorServiceState(boolean shouldRun) { + + if (shouldRun) { + if (!isCursorNetworking && currentHostAddress != null) { + // 如果没在运行,且有IP,就开始运行 + LimeLog.info("CursorNet: Enabling cursor service during stream"); + startCursorService(currentHostAddress); + } + } else { + if (isCursorNetworking) { + // 如果正在运行,就停止它 + LimeLog.info("CursorNet: Disabling cursor service during stream"); + stopCursorService(); + } + } + } + @Override public void mouseMove(int deltaX, int deltaY) { conn.sendMouseMove((short) deltaX, (short) deltaY); @@ -2637,15 +4289,55 @@ else if ((visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { } @Override - public void onPerfUpdate(final String text) { - runOnUiThread(new Runnable() { - @Override - public void run() { - performanceOverlayView.setText(text); + public void onPerfUpdateV(final PerformanceInfo performanceInfo) { + if (performanceOverlayManager != null) { + performanceOverlayManager.updatePerformanceInfo(performanceInfo); + } + } + + @Override + public boolean isPerfOverlayVisible() { + return performanceOverlayManager != null && performanceOverlayManager.isPerfOverlayVisible(); + } + + @Override + public void onPerfUpdateWG(final PerformanceInfo performanceInfo) { + runOnUiThread(() -> { + long currentRxBytes = TrafficStats.getTotalRxBytes(); + long timeMillis = System.currentTimeMillis(); + long timeMillisInterval = timeMillis - previousTimeMillis; + + // 只在时间间隔合理时计算带宽,避免异常值 + if (timeMillisInterval > 0 && timeMillisInterval < 5000) { + performanceInfo.bandWidth = NetHelper.calculateBandwidth(currentRxBytes, previousRxBytes, timeMillisInterval); } + + previousTimeMillis = timeMillis; + previousRxBytes = currentRxBytes; + + if (controllerManager != null && !performanceInfoDisplays.isEmpty()) { + Map perfAttrs = new HashMap<>(); + perfAttrs.put("解码器", performanceInfo.decoder); + perfAttrs.put("分辨率", performanceInfo.initialWidth + "x" + performanceInfo.initialHeight); + perfAttrs.put("帧率", String.format("%.0f", performanceInfo.totalFps)); + perfAttrs.put("丢帧率", String.format("%.1f", performanceInfo.lostFrameRate)); + perfAttrs.put("网络延时", String.format("%d", (int) (performanceInfo.rttInfo >> 32))); + perfAttrs.put("主机延时", String.format("%.2f", performanceInfo.aveHostProcessingLatency)); + perfAttrs.put("解码时间", String.format("%.2f", performanceInfo.decodeTimeMs)); + perfAttrs.put("带宽", performanceInfo.bandWidth); + perfAttrs.put("渲染延迟", String.format("%.2f", performanceInfo.renderingLatencyMs)); + for (PerformanceInfoDisplay performanceInfoDisplay : performanceInfoDisplays) { + performanceInfoDisplay.display(perfAttrs); + } + } + }); } + public void removePerformanceInfoDisplay(PerformanceInfoDisplay display) { + performanceInfoDisplays.remove(display); + } + @Override public void onUsbPermissionPromptStarting() { // Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents @@ -2660,6 +4352,30 @@ public void onUsbPermissionPromptCompleted() { updatePipAutoEnter(); } + /** + * 根据当前设置的状态,显示不同的游戏菜单。 + * @param device 可能是触发菜单的输入设备,可以为 null + */ + public void showGameMenu(GameInputDevice device) { + switch (currentBackKeyMenu) { + case GAME_MENU: + new GameMenu(this, app, conn, device); + break; + case CROWN_MODE: + if (controllerManager != null && prefConfig.onscreenKeyboard) { + controllerManager.getSuperPagesController().returnOperation(); + } + break; + case NO_MENU: + // 无操作,直接返回 + break; + default: + new GameMenu(this, app, conn, device); + break; + } + } + + @Override public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { switch (keyEvent.getAction()) { @@ -2673,4 +4389,250 @@ public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { return false; } } + + public void disconnect() { + finish(); + } + + @Override + public void onBackPressed() { + // Instead of "closing" the game activity open the game menu. The user has to select + // "Disconnect" within the game menu to actually disconnect from the remote host. + // + // Use the onBackPressed instead of the onKey function, since the onKey function + // also captures events while having the on-screen keyboard open. Using onBackPressed + // ensures that Android properly handles the back key when needed and only open the game + // menu when the activity would be closed. + showGameMenu(null); + } + + private boolean isPhysicalKeyboardConnected() { + return getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY; + } + + /** + * 切换逻辑:关闭 -> 悬浮 -> 固定 -> 关闭 + */ + public void togglePerformanceOverlay() { + if (performanceOverlayManager == null) { + return; + } + + // 1. 当前是【关闭】状态 -> 切换到【悬浮】 + if (!prefConfig.enablePerfOverlay) { + prefConfig.enablePerfOverlay = true; + prefConfig.perfOverlayLocked = false; + performanceOverlayManager.applyOverlayState(); // 应用状态 + } + + // 2. 当前是【悬浮】状态 -> 切换到【固定】 + else if (!prefConfig.perfOverlayLocked) { + prefConfig.enablePerfOverlay = true; + prefConfig.perfOverlayLocked = true; + performanceOverlayManager.applyOverlayState(); // 应用状态 + } + + // 3. 当前是【固定】状态 -> 切换到【关闭】 + else { + prefConfig.enablePerfOverlay = false; + prefConfig.perfOverlayLocked = false; // 重置回默认 + performanceOverlayManager.applyOverlayState(); // 应用状态 + } + + prefConfig.writePreferences(this); + } + + /** + * 切换麦克风按钮的显示/隐藏状态 + */ + public void toggleMicrophoneButton() { + if (micButton != null) { + if (micButton.getVisibility() == View.VISIBLE) { + micButton.setVisibility(View.GONE); + Toast.makeText(this, "麦克风按钮已隐藏", Toast.LENGTH_SHORT).show(); + } else { + micButton.setVisibility(View.VISIBLE); + Toast.makeText(this, "麦克风按钮已显示", Toast.LENGTH_SHORT).show(); + } + } + } + + /** + * 切换虚拟手柄覆盖层的显示/隐藏状态 + */ + public void toggleVirtualController() { + if (virtualController != null && !virtualController.getElements().isEmpty()) { + // 检查第一个元素的可见性来判断当前状态 + boolean isVisible = virtualController.getElements().get(0).getVisibility() == View.VISIBLE; + + if (isVisible) { + virtualController.hide(); + Toast.makeText(this, "虚拟手柄已隐藏", Toast.LENGTH_SHORT).show(); + } else { + virtualController.show(); + Toast.makeText(this, "虚拟手柄已显示", Toast.LENGTH_SHORT).show(); + } + } else { + Toast.makeText(this, "虚拟手柄未启用", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 初始化控制器管理器(王冠功能) + */ + public void initializeControllerManager() { + if (controllerManager == null) { + controllerManager = new ControllerManager((FrameLayout)streamView.getParent(), this); + controllerManager.refreshLayout(); + } + } + + /** + * 设置王冠功能状态 + */ + public void setCrownFeatureEnabled(boolean enabled) { + prefConfig.onscreenKeyboard = enabled; + if (enabled) { + // 启用王冠模式 + if (controllerManager != null) { + controllerManager.show(); + } else { + initializeControllerManager(); + } + } else { + // 禁用王冠模式 + if (controllerManager != null) { + controllerManager.hide(); + } + } + } + + /** + * 获取王冠功能状态 + */ + public boolean isCrownFeatureEnabled() { + return prefConfig.onscreenKeyboard; + } + + /** + * 刷新性能覆盖层显示项配置(用户更改配置后调用) + */ + public void refreshPerformanceOverlayConfig() { + if (performanceOverlayManager != null) { + performanceOverlayManager.refreshPerformanceOverlayConfig(); + } + } + + private static byte getModifier(short key) { + switch (key) { + case KeyboardTranslator.VK_LSHIFT: + return KeyboardPacket.MODIFIER_SHIFT; + case KeyboardTranslator.VK_LCONTROL: + return KeyboardPacket.MODIFIER_CTRL; + case KeyboardTranslator.VK_LWIN: + return KeyboardPacket.MODIFIER_META; + case KeyboardTranslator.VK_MENU: + return KeyboardPacket.MODIFIER_ALT; + + default: + return 0; + } + } + + private void sendKeys(short[] keys) { + final byte[] modifier = {(byte) 0}; + + for (short key : keys) { + conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0); + + // Apply the modifier of the pressed key, e.g. CTRL first issues a CTRL event (without + // modifier) and then sends the following keys with the CTRL modifier applied + modifier[0] |= getModifier(key); + } + + new Handler().postDelayed((() -> { + + for (int pos = keys.length - 1; pos >= 0; pos--) { + short key = keys[pos]; + + // Remove the keys modifier before releasing the key + modifier[0] &= ~getModifier(key); + + conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0); + } + }), 25); + } + + public ControllerHandler getControllerHandler() { + return controllerHandler; + } + + public void addPerformanceInfoDisplay(PerformanceInfoDisplay performanceInfoDisplay){ + performanceInfoDisplays.add(performanceInfoDisplay); + } + + // 更新刷新显示位置方法 + public void refreshDisplayPosition() { + new DisplayPositionManager(this, prefConfig, streamView).refreshDisplayPosition(surfaceCreated); + } + + public StreamView getStreamView() { + return streamView; + } + + /** + * 获取当前活动的StreamView(优先使用外接显示器的StreamView) + */ + public StreamView getActiveStreamView() { + if (externalDisplayManager != null && externalDisplayManager.isUsingExternalDisplay() && externalStreamView != null) { + return externalStreamView; + } + return streamView; + } + + public boolean getHandleMotionEvent(StreamView streamView,MotionEvent event) { + return handleMotionEvent(streamView,event); + } + + /** + * 应用上一次设置到当前会话(不覆盖全局配置) + */ + private void applyLastSettingsToCurrentSession() { + if (appSettingsManager != null) { + // 使用AppSettingsManager统一处理上一次设置的应用 + boolean applied = appSettingsManager.applyLastSettingsFromIntent(getIntent(), prefConfig); + + if (applied) { + // 显示提示信息 + Toast.makeText(this, getString(R.string.app_last_settings_start_with_last), Toast.LENGTH_SHORT).show(); + } + } + } + + private void updateNotificationOverlay(int connectionStatus, String message) { + if (notificationOverlayView == null || notificationTextView == null) { + return; + } + + // Set the text + notificationTextView.setText(message); + + // Set different colors based on connection status with more transparency + int backgroundColor; + if (connectionStatus == MoonBridge.CONN_STATUS_POOR) { + if (prefConfig.bitrate > 5000) { + // Slow connection - orange warning + backgroundColor = 0x80FF9800; // Orange with more transparency + } else { + // Poor connection - red warning + backgroundColor = 0x80F44336; // Red with more transparency + } + } else { + // Default color + backgroundColor = 0x80FF5722; // Orange-red with more transparency + } + + // Apply background color without animation + notificationOverlayView.setCardBackgroundColor(backgroundColor); + } } diff --git a/app/src/main/java/com/limelight/GameMenu.java b/app/src/main/java/com/limelight/GameMenu.java new file mode 100644 index 0000000000..939fab332a --- /dev/null +++ b/app/src/main/java/com/limelight/GameMenu.java @@ -0,0 +1,1889 @@ +package com.limelight; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ContentValues; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; +import android.text.Html; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.limelight.binding.input.GameInputDevice; +import com.limelight.binding.input.KeyboardTranslator; +import com.limelight.binding.input.advance_setting.ControllerManager; +import com.limelight.binding.input.advance_setting.config.PageConfigController; +import com.limelight.binding.input.advance_setting.element.ElementController; +import com.limelight.binding.input.touch.RelativeTouchContext; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.input.KeyboardPacket; +import com.limelight.utils.KeyCodeMapper; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.widget.EditText; +import org.json.JSONArray; +import org.json.JSONObject; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; + +/** + * 提供游戏流媒体进行中的选项菜单 + * 在游戏活动中按返回键时显示 + */ +public class GameMenu { + + // 常量定义 + private static final long TEST_GAME_FOCUS_DELAY = 10L; + private static final long KEY_UP_DELAY = 25L; + private static final long SLEEP_DELAY = 200L; + private static final float DIALOG_ALPHA = 0.7f; + private static final float DIALOG_DIM_AMOUNT = 0.3f; + private static final String GAME_MENU_TITLE = "🍥🍬 V+ GAME MENU"; + + // 用于存储自定义按键的 SharedPreferences 文件名和键名 + private static final String PREF_NAME = "custom_special_keys"; + private static final String KEY_NAME = "data"; + + private static boolean mouse_enable_switch = false; + + // 图标映射缓存 + private static final Map ICON_MAP = new HashMap<>(); + + static { + ICON_MAP.put("game_menu_toggle_keyboard", R.drawable.ic_keyboard_cute); + ICON_MAP.put("game_menu_toggle_performance_overlay", R.drawable.ic_performance_cute); + ICON_MAP.put("game_menu_toggle_virtual_controller", R.drawable.ic_controller_cute); + ICON_MAP.put("game_menu_disconnect", R.drawable.ic_disconnect_cute); + ICON_MAP.put("game_menu_send_keys", R.drawable.ic_send_keys_cute); + ICON_MAP.put("game_menu_toggle_host_keyboard", R.drawable.ic_host_keyboard); + ICON_MAP.put("game_menu_disconnect_and_quit", R.drawable.ic_btn_quit); + ICON_MAP.put("game_menu_cancel", R.drawable.ic_cancel_cute); + ICON_MAP.put("mouse_mode", R.drawable.ic_mouse_cute); + ICON_MAP.put("game_menu_mouse_emulation", R.drawable.ic_mouse_emulation_cute); + ICON_MAP.put("crown_function_menu", R.drawable.ic_super_crown); + ICON_MAP.put("game_menu_test_local_rumble", R.drawable.ic_rumble_cute); + } + + /** + * 菜单选项类 + */ + public static class MenuOption { + private final String label; + private final boolean withGameFocus; + private final Runnable runnable; + private final String iconKey; // 用于图标映射的键 + private final boolean showIcon; // 是否显示图标 + private final boolean keepDialog; // 点击此项时是否保留对话框并替换左侧菜单(用于二级菜单) + + public MenuOption(String label, boolean withGameFocus, Runnable runnable) { + this(label, withGameFocus, runnable, null, true); + } + + public MenuOption(String label, Runnable runnable) { + this(label, false, runnable, null, true); + } + + public MenuOption(String label, boolean withGameFocus, Runnable runnable, String iconKey) { + this(label, withGameFocus, runnable, iconKey, true); + } + + public MenuOption(String label, boolean withGameFocus, Runnable runnable, String iconKey, boolean showIcon) { + this(label, withGameFocus, runnable, iconKey, showIcon, false); + } + + public MenuOption(String label, boolean withGameFocus, Runnable runnable, String iconKey, boolean showIcon, boolean keepDialog) { + this.label = label; + this.withGameFocus = withGameFocus; + this.runnable = runnable; + this.iconKey = iconKey; + this.showIcon = showIcon; + this.keepDialog = keepDialog; + } + + public String getLabel() { return label; } + public boolean isWithGameFocus() { return withGameFocus; } + public Runnable getRunnable() { return runnable; } + + public String getIconKey() { + return iconKey; + } + + public boolean isShowIcon() { + return showIcon; + } + + public boolean isKeepDialog() { + return keepDialog; + } + } + + // 实例变量 + private final Game game; + private final NvApp app; + private final NvConnection conn; + private final GameInputDevice device; + private final Handler handler; + // 当前激活的对话框(如果有) + private AlertDialog activeDialog; + // 当前激活对话框所用的自定义视图引用(便于内部替换) + private View activeCustomView; + // 标志:上一次运行的选项是否打开了子菜单(由 showSubMenu 设置) + private boolean lastActionOpenedSubmenu = false; + // 菜单历史栈,用于二级/多级菜单的回退 + private final java.util.Deque menuStack = new java.util.ArrayDeque<>(); + + public GameMenu(Game game, NvApp app, NvConnection conn, GameInputDevice device) { + this.game = game; + this.app = app; + this.conn = conn; + this.device = device; + this.handler = new Handler(); + + showMenu(); + } + + /** + * 菜单状态,用于回退 + */ + private static class MenuState { + final String title; + final MenuOption[] normalOptions; + + MenuState(String title, MenuOption[] normalOptions) { + this.title = title; + this.normalOptions = normalOptions; + } + } + + /** + * 获取字符串资源 + */ + private String getString(int id) { + return game.getResources().getString(id); + } + + /** + * 键盘修饰符枚举 + */ + private enum KeyModifier { + SHIFT((short) KeyboardTranslator.VK_LSHIFT, KeyboardPacket.MODIFIER_SHIFT), + CTRL((short) KeyboardTranslator.VK_LCONTROL, KeyboardPacket.MODIFIER_CTRL), + META((short) KeyboardTranslator.VK_LWIN, KeyboardPacket.MODIFIER_META), + ALT((short) KeyboardTranslator.VK_MENU, KeyboardPacket.MODIFIER_ALT); + + final short keyCode; + final byte modifier; + + KeyModifier(short keyCode, byte modifier) { + this.keyCode = keyCode; + this.modifier = modifier; + } + + public static byte getModifier(short key) { + for (KeyModifier km : values()) { + if (km.keyCode == key) { + return km.modifier; + } + } + return 0; + } + } + + /** + * 获取键盘修饰符 + */ + private static byte getModifier(short key) { + return KeyModifier.getModifier(key); + } + + /** + * 断开连接并退出 + */ + private void disconnectAndQuit() { + try { + game.disconnect(); + conn.doStopAndQuit(); + } catch (IOException | XmlPullParserException e) { + Toast.makeText(game, "断开连接时发生错误: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + /** + * 发送键盘按键序列 + */ + private void sendKeys(short[] keys) { + if (keys == null || keys.length == 0) { + return; + } + + final byte[] modifier = { (byte) 0 }; + + // 按下所有按键 + for (short key : keys) { + conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0); + modifier[0] |= getModifier(key); + } + + // 延迟后释放按键 + handler.postDelayed(() -> { + for (int pos = keys.length - 1; pos >= 0; pos--) { + short key = keys[pos]; + modifier[0] &= ~getModifier(key); + conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0); + } + }, KEY_UP_DELAY); + } + + /** + * 在游戏获得焦点时运行任务 + */ + private void runWithGameFocus(Runnable runnable) { + if (game.isFinishing()) { + return; + } + + if (!game.hasWindowFocus()) { + handler.postDelayed(() -> runWithGameFocus(runnable), TEST_GAME_FOCUS_DELAY); + return; + } + + runnable.run(); + } + + /** + * 执行菜单选项 + */ + private void run(MenuOption option) { + if (option == null || option.getRunnable() == null) { + return; + } + + if (option.isWithGameFocus()) { + runWithGameFocus(option.getRunnable()); + } else { + option.getRunnable().run(); + } + } + + /** + * 切换增强触摸模式 + */ + private void toggleEnhancedTouch() { + game.prefConfig.enableEnhancedTouch = !game.prefConfig.enableEnhancedTouch; + String message = game.prefConfig.enableEnhancedTouch ? "增强式多点触控已开启" : "经典鼠标模式已开启"; + Toast.makeText(game, message, Toast.LENGTH_SHORT).show(); + } + + /** + * 显示一个菜单列表,用于在"增强式多点触控","经典鼠标模式","触控板模式","本地鼠标指针"之间切换。 + */ + private void showTouchModeMenu() { + boolean isEnhancedTouch = game.prefConfig.enableEnhancedTouch; + boolean isTouchscreenTrackpad = game.prefConfig.touchscreenTrackpad; + boolean isNativeMousePointer = game.prefConfig.enableNativeMousePointer; + + // 创建一个列表来存储菜单选项 + List touchModeOptionsList = new ArrayList<>(); + + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_touch_mode_enhanced), + isEnhancedTouch && !isTouchscreenTrackpad && !isNativeMousePointer, + () -> { + game.prefConfig.enableEnhancedTouch = true; + game.prefConfig.enableNativeMousePointer = false; + game.enableNativeMousePointer(false); // 关闭本地鼠标模式 + game.setTouchMode(false); + updateEnhancedTouchSetting(true); + updateTouchModeSetting(false); + Toast.makeText(game, getString(R.string.toast_touch_mode_enhanced_on), Toast.LENGTH_SHORT).show(); + }, + null, + false + )); + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_touch_mode_classic), + !isEnhancedTouch && !isTouchscreenTrackpad && !isNativeMousePointer, + () -> { + game.prefConfig.enableEnhancedTouch = false; + game.prefConfig.enableNativeMousePointer = false; + game.enableNativeMousePointer(false); // 关闭本地鼠标模式 + game.setTouchMode(false); + updateEnhancedTouchSetting(false); + updateTouchModeSetting(false); + Toast.makeText(game, getString(R.string.toast_touch_mode_classic_on), Toast.LENGTH_SHORT).show(); + }, + null, + false + )); + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_touch_mode_trackpad), + isTouchscreenTrackpad && !isNativeMousePointer, + () -> { + game.prefConfig.enableNativeMousePointer = false; + game.enableNativeMousePointer(false); // 关闭本地鼠标模式 + game.setTouchMode(true); + updateTouchModeSetting(true); + Toast.makeText(game, getString(R.string.toast_touch_mode_trackpad_on), Toast.LENGTH_SHORT).show(); + }, + null, + false + )); + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_touch_mode_trackpad) + " - " + + (game.prefConfig.enableDoubleClickDrag ? "关闭双击按住" : "开启双击按住"), + false, + () -> { + game.prefConfig.enableDoubleClickDrag = !game.prefConfig.enableDoubleClickDrag; + // 不保存到持久化存储,只在当前会话中生效 + Toast.makeText(game, + game.prefConfig.enableDoubleClickDrag ? "已开启双击按住功能" : "已关闭双击按住功能", + Toast.LENGTH_SHORT).show(); + }, + null, + false + )); + + // 本地光标渲染选项(仅在触屏触控板模式下显示) + if (isTouchscreenTrackpad) { + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_local_cursor_rendering) + " - " + + (game.prefConfig.enableLocalCursorRendering ? "开启" : "关闭"), + false, + () -> { + game.prefConfig.enableLocalCursorRendering = !game.prefConfig.enableLocalCursorRendering; + game.refreshLocalCursorState(game.prefConfig.enableLocalCursorRendering); + String message = game.prefConfig.enableLocalCursorRendering ? + "本地光标渲染已开启" : "本地光标渲染已关闭"; + Toast.makeText(game, message, Toast.LENGTH_SHORT).show(); + }, + null, + false + ) + ); + } + + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_touch_mode_native_mouse), + isNativeMousePointer, + () -> { + game.prefConfig.enableNativeMousePointer = true; + game.prefConfig.enableEnhancedTouch = false; + game.setTouchMode(false); + game.enableNativeMousePointer(true); + updateTouchModeSetting(false); + Toast.makeText(game, getString(R.string.toast_touch_mode_native_mouse_on), Toast.LENGTH_SHORT).show(); + }, + null, + false + )); + + touchModeOptionsList.add( + new MenuOption( + getString(R.string.game_menu_toggle_remote_mouse), + false, + () -> { + sendKeys(new short[]{ + KeyboardTranslator.VK_LCONTROL, + KeyboardTranslator.VK_MENU, + KeyboardTranslator.VK_LSHIFT, + KeyboardTranslator.VK_N + }); + Toast.makeText(game, getString(R.string.toast_remote_mouse_toast), Toast.LENGTH_SHORT).show(); + }, + null, + false + ) + ); + + // 将列表转换为数组 + MenuOption[] touchModeOptions = touchModeOptionsList.toArray(new MenuOption[0]); + + // 3. 显示为子菜单(在活动对话框内替换普通菜单区域) + showSubMenu(getString(R.string.game_menu_switch_touch_mode), touchModeOptions); + } + + /** + * 将当前的触控模式(是否为触摸板模式)保存到数据库中。 + * @param isTrackpadMode true 表示保存为触摸板板模式,false 表示保存为其他模式。 + */ + private void updateTouchModeSetting(boolean isTrackpadMode) { + // 从 Game Activity 获取 ControllerManager 实例 + ControllerManager controllerManager = game.getControllerManager(); + + // 添加空值检查 + if (controllerManager == null) { + LimeLog.warning("ControllerManager is null, cannot update touch mode setting"); + return; + } + + // 创建一个 ContentValues 对象,用于存放要更新的数据 + ContentValues contentValues = new ContentValues(); + + // 1. 从 PageConfigController 获取当前正在使用的配置ID + Long currentConfigId = controllerManager.getPageConfigController().getCurrentConfigId(); + + // 2. 将传入的布尔值转换为字符串,并放入 ContentValues + // 键是数据库的列名,值是传入的 isTrackpadMode 的状态 + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(isTrackpadMode)); + + // 3. 调用数据库帮助类的方法,将数据更新到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + } + + private void updateEnhancedTouchSetting(boolean isEnabled) { + // 从 Game Activity 获取 ControllerManager 实例 + ControllerManager controllerManager = game.getControllerManager(); + + // 添加空值检查 + if (controllerManager == null) { + LimeLog.warning("ControllerManager is null, cannot update touch mode setting"); + return; + } + + ContentValues contentValues = new ContentValues(); + Long currentConfigId = controllerManager.getPageConfigController().getCurrentConfigId(); + + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(isEnabled)); + + // 更新到数据库 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + } + + /** + * 切换麦克风开关 + */ + private void toggleMicrophone() { + // 切换GameView中麦克风按钮的显示/隐藏状态 + game.toggleMicrophoneButton(); + } + + /** + * 切换王冠功能并即时刷新菜单内容 + */ + private void toggleCrownFeature() { + // 切换王冠功能状态 + game.setCrownFeatureEnabled(!game.isCrownFeatureEnabled()); + + // 显示状态变更提示 + Toast.makeText(game, game.isCrownFeatureEnabled() ? + getString(R.string.crown_switch_to_crown) : + getString(R.string.crown_switch_to_normal), + Toast.LENGTH_SHORT).show(); + + // 即时更新菜单内容,而不是重新创建整个对话框 + if (activeDialog != null && activeDialog.isShowing()) { + // 更新标题栏的王冠按钮文本 + updateCrownToggleButton(); + + // 重新构建并更新菜单列表 + rebuildAndReplaceMenu(); + } + } + + /** + * 更新标题栏王冠按钮文本 + */ + private void updateCrownToggleButton() { + if (activeCustomView != null) { + TextView crownToggleButton = activeCustomView.findViewById(R.id.btnCrownToggle); + if (crownToggleButton != null) { + String crownText = game.isCrownFeatureEnabled() ? + getString(R.string.crown_switch_to_normal) : + getString(R.string.crown_switch_to_crown); + crownToggleButton.setText(Html.fromHtml("" + crownText + "")); + } + } + } + + /** + * 重新构建并替换菜单内容 + */ + private void rebuildAndReplaceMenu() { + if (activeDialog == null || activeCustomView == null) return; + + // 重新构建普通菜单选项 + List normalOptions = new ArrayList<>(); + buildNormalMenuOptions(normalOptions); + + // 更新普通菜单列表 + ListView normalListView = activeCustomView.findViewById(R.id.gameMenuList); + if (normalListView != null) { + GameMenuAdapter adapter = new GameMenuAdapter(game, + normalOptions.toArray(new MenuOption[0])); + normalListView.setAdapter(adapter); + // 重新设置点击监听器 + setupMenu(normalListView, adapter, activeDialog); + } + } + + /** + * 显示“王冠功能”的二级菜单,包含显隐和配置选项。 + */ + private void showCrownFunctionMenu() { + // 从 Game Activity 获取 ControllerManager 实例 + ControllerManager controllerManager = game.getControllerManager(); + + // 检查 王冠功能是否开启,如果没有开启则不显示任何选项 + if (!game.isCrownFeatureEnabled()) { + Toast.makeText(game, "王冠功能未启用", Toast.LENGTH_SHORT).show(); + return; + } + MenuOption[] crownFunctionOptions = { + // --- 选项1: 显示/隐藏虚拟按键 --- + new MenuOption( + getString(R.string.game_menu_toggle_elements_visibility), + false, + game::toggleVirtualControllerVisibility, + "crown_function_menu", + true + ), + new MenuOption( + "开启/关闭触控", + false, + () -> { + controllerManager.getTouchController().enableTouch(mouse_enable_switch); + Toast.makeText(game, mouse_enable_switch ? "触控已开启" : "触控已关闭", Toast.LENGTH_SHORT).show(); + mouse_enable_switch = !mouse_enable_switch; + }, + "crown_function_menu", + true + ), + // --- 配置设置 --- + new MenuOption( + getString(R.string.game_menu_configure_settings), + false, + () -> { + if (controllerManager != null) { + game.toggleBackKeyMenuType(); + game.setcurrentBackKeyMenu(Game.BackKeyMenuMode.NO_MENU); + controllerManager.getPageConfigController().open(); + } + }, + "crown_function_menu", + true + ), + // --- 编辑模式 --- + new MenuOption( + getString(R.string.game_menu_edit_mode), + false, + () -> { + if (controllerManager != null) { + game.toggleBackKeyMenuType(); + controllerManager.getElementController().changeMode(ElementController.Mode.Edit); + controllerManager.getElementController().open(); + } + }, + "crown_function_menu", + true + ), + // --- 配置王冠功能 --- + new MenuOption( + getString(R.string.game_menu_configure_crown_function), + false, + game::toggleBackKeyMenuType, + "crown_function_menu", + true + ) + }; + + // 使用 showSubMenu 方法来显示这个二级菜单 + showSubMenu(getString(R.string.game_menu_crown_function), crownFunctionOptions); + } + + /** + * 本地测试震动:对控制器 0..3 发送 1 秒强震动 + */ + private void testLocalRumbleAll() { + try { + com.limelight.binding.input.ControllerHandler ch = game.getControllerHandler(); + if (ch == null) { + Toast.makeText(game, "无法访问控制器", Toast.LENGTH_SHORT).show(); + return; + } + + short on = (short) 0xFFFF; + short off = 0; + for (short n = 0; n < 4; n++) { + ch.handleRumble(n, on, on); + } + + handler.postDelayed(() -> { + try { + for (short n = 0; n < 4; n++) { + ch.handleRumble(n, off, off); + } + } catch (Exception ignored) {} + }, 1000); + + Toast.makeText(game, "已发送本地震动测试", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(game, "震动测试失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + + + /** + * 调整码率 + */ + private void adjustBitrate(int bitrateKbps) { + try { + // 显示正在调整的提示 + Toast.makeText(game, "正在调整码率...", Toast.LENGTH_SHORT).show(); + + // 调用码率调整,使用回调等待API真正返回结果 + conn.setBitrate(bitrateKbps, new NvConnection.BitrateAdjustmentCallback() { + @Override + public void onSuccess(int newBitrate) { + // API成功返回,在主线程显示成功消息 + game.runOnUiThread(() -> { + try { + String successMessage = String.format(getString(R.string.game_menu_bitrate_adjustment_success), newBitrate / 1000); + Toast.makeText(game, successMessage, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + LimeLog.warning("Failed to show success toast: " + e.getMessage()); + } + }); + } + + @Override + public void onFailure(String errorMessage) { + // API失败返回,在主线程显示错误消息 + game.runOnUiThread(() -> { + try { + String errorMsg = getString(R.string.game_menu_bitrate_adjustment_failed) + ": " + errorMessage; + Toast.makeText(game, errorMsg, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + LimeLog.warning("Failed to show error toast: " + e.getMessage()); + } + }); + } + }); + + } catch (Exception e) { + // 调用setBitrate时发生异常(如参数错误等) + game.runOnUiThread(() -> { + try { + Toast.makeText(game, getString(R.string.game_menu_bitrate_adjustment_failed) + ": " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Exception toastException) { + LimeLog.warning("Failed to show error toast: " + toastException.getMessage()); + } + }); + } + } + + /** + * 显示菜单对话框 + */ + private void showMenuDialog(String title, MenuOption[] normalOptions, MenuOption[] superOptions) { + AlertDialog.Builder builder = new AlertDialog.Builder(game, R.style.GameMenuDialogStyle); + + // 创建自定义视图 + View customView = createCustomView(builder); + AlertDialog dialog = builder.create(); + // 记录为当前活动对话框 + this.activeDialog = dialog; + this.activeCustomView = customView; + + // 设置自定义标题栏 + setupCustomTitleBar(customView, title); + + // 动态设置菜单列表区域高度 +// setupMenuListHeight(customView); + + // 设置App名字显示 + setupAppNameDisplay(customView); + + // 设置快捷按钮 + setupQuickButtons(customView, dialog); + + // 设置普通菜单 + setupNormalMenu(customView, normalOptions, dialog); + + // 设置超级菜单 + setupSuperMenu(customView, superOptions, dialog); + + // 设置码率调整区域 + if (game.prefConfig.showBitrateCard) { + new BitrateCardController(game, conn).setup(customView, dialog); + } else { + View bitrate = customView.findViewById(R.id.bitrateAdjustmentContainer); + if (bitrate != null) bitrate.setVisibility(View.GONE); + } + + // 设置陀螺仪控制卡片 + if (game.prefConfig.showGyroCard) { + new GyroCardController(game).setup(customView, dialog); + } else { + View gyro = customView.findViewById(R.id.gyroAdjustmentContainer); + if (gyro != null) gyro.setVisibility(View.GONE); + } + + // --- 设置快捷指令卡片 --- + setupCustomKeysCard(customView); + + // 卡片编辑入口 + View cardEditor = customView.findViewById(R.id.cardEditorButton); + if (cardEditor != null) { + cardEditor.setOnClickListener(v -> showCardEditorDialog()); + } + + // 设置对话框属性 + setupDialogProperties(dialog); + + // 设置返回键监听器,处理二级菜单返回 + dialog.setOnKeyListener((dialogInterface, keyCode, event) -> { + if (keyCode == android.view.KeyEvent.KEYCODE_BACK && event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + // 如果菜单栈不为空,说明在二级菜单中,需要返回一级菜单 + if (!menuStack.isEmpty()) { + MenuState previousState = menuStack.pop(); + replaceNormalMenuInDialog(dialog, previousState.title, previousState.normalOptions, false); + return true; // 消费返回键事件 + } + // 如果菜单栈为空,说明在一级菜单,正常关闭对话框 + return false; // 不消费返回键事件,让系统正常关闭对话框 + } + return false; + }); + + // 在对话框关闭时清理状态 + dialog.setOnDismissListener(d -> { + // 清理活动引用和菜单栈 + if (this.activeDialog == dialog) this.activeDialog = null; + if (this.activeCustomView != null) this.activeCustomView = null; + menuStack.clear(); + }); + + dialog.show(); + } + + private void showCardEditorDialog() { + final String[] items = new String[] { + "码率调整 Bitrate", + "体感助手 Gyro", + "特殊按键 Shortcuts" + }; + + // 获取当前状态 + final boolean[] checked = new boolean[] { + game.prefConfig.showBitrateCard, + game.prefConfig.showGyroCard, + game.prefConfig.showQuickKeyCard + }; + + new AlertDialog.Builder(game, R.style.AppDialogStyle) + .setTitle("卡片配置 Visible cards") + .setMultiChoiceItems(items, checked, (d, which, isChecked) -> { + checked[which] = isChecked; + }) + .setPositiveButton("OK", (d, w) -> { + game.prefConfig.showBitrateCard = checked[0]; + game.prefConfig.showGyroCard = checked[1]; + game.prefConfig.showQuickKeyCard = checked[2]; + + // Persist + game.prefConfig.writePreferences(game); + + // Update UI within current dialog (刷新界面) + View root = activeCustomView != null ? activeCustomView : + d instanceof AlertDialog ? ((AlertDialog) d).getOwnerActivity().findViewById(android.R.id.content) : null; + + if (root != null) { + View bitrate = root.findViewById(R.id.bitrateAdjustmentContainer); + if (bitrate != null) bitrate.setVisibility(game.prefConfig.showBitrateCard ? View.VISIBLE : View.GONE); + + View gyro = root.findViewById(R.id.gyroAdjustmentContainer); + if (gyro != null) gyro.setVisibility(game.prefConfig.showGyroCard ? View.VISIBLE : View.GONE); + + // 刷新快捷指令卡片可见性 + View keysCard = root.findViewById(R.id.customKeysCardContainer); + if (keysCard != null) { + // 如果刚才设置为显示,可能需要重新 build 一次按钮(如果之前是GONE的话) + if (game.prefConfig.showQuickKeyCard) { + setupCustomKeysCard(root); + } else { + keysCard.setVisibility(View.GONE); + } + } + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + + /** + * 创建自定义视图 + */ + private View createCustomView(AlertDialog.Builder builder) { + LayoutInflater inflater = game.getLayoutInflater(); + View customView = inflater.inflate(R.layout.custom_dialog, null); + builder.setView(customView); + return customView; + } + + // --- 简单的按键数据模型 --- + private static class CustomKeyData { + String name; + short[] keys; + + CustomKeyData(String name, short[] keys) { + this.name = name; + this.keys = keys; + } + } + + /** + * 从存储或默认资源中获取解析好的按键数据列表 + */ + private List getSavedCustomKeys() { + List resultList = new ArrayList<>(); + + SharedPreferences preferences = game.getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE); + String value = preferences.getString(KEY_NAME, ""); + + // 1. 如果 SharedPreferences 中没有数据(例如首次启动),则从 raw 资源文件加载默认按键 + if (TextUtils.isEmpty(value)) { + value = readRawResourceAsString(R.raw.default_special_keys); + if (!TextUtils.isEmpty(value)) { + // 将从文件读取的默认值保存到 SharedPreferences 中,以便后续可以对其进行修改 + preferences.edit().putString(KEY_NAME, value).apply(); + } + } + + // 2. 如果依然为空,直接返回空列表 + if (TextUtils.isEmpty(value)) { + return resultList; + } + + // 3. 解析 JSON 数据 + try { + JSONObject root = new JSONObject(value); + JSONArray dataArray = root.optJSONArray("data"); + + if (dataArray != null && dataArray.length() > 0) { + for (int i = 0; i < dataArray.length(); i++) { + JSONObject keyObject = dataArray.getJSONObject(i); + String name = keyObject.optString("name"); + JSONArray codesArray = keyObject.getJSONArray("data"); + + if (codesArray != null) { + short[] datas = new short[codesArray.length()]; + for (int j = 0; j < codesArray.length(); j++) { + String code = codesArray.getString(j); + // 解析 "0xXX" 格式的十六进制字符串 + datas[j] = (short) Integer.parseInt(code.substring(2), 16); + } + resultList.add(new CustomKeyData(name, datas)); + } + } + } + } catch (Exception e) { + LimeLog.warning("Exception while loading keys from SharedPreferences: " + e.getMessage()); + Toast.makeText(game, getString(R.string.toast_load_custom_keys_corrupted), Toast.LENGTH_SHORT).show(); + } + + return resultList; + } + + /** + * 设置自定义按键卡片 + */ + private void setupCustomKeysCard(View customView) { + View cardContainer = customView.findViewById(R.id.customKeysCardContainer); + LinearLayout listLayout = customView.findViewById(R.id.customKeysListLayout); + + if (cardContainer == null || listLayout == null) return; + + // 检查配置开关 + if (!game.prefConfig.showQuickKeyCard) { + cardContainer.setVisibility(View.GONE); + return; + } + + List keys = getSavedCustomKeys(); + if (keys.isEmpty()) { + cardContainer.setVisibility(View.GONE); + return; + } + + // 显示容器 + cardContainer.setVisibility(View.VISIBLE); + listLayout.removeAllViews(); + + // 3. 循环创建美化的列表项 + for (int i = 0; i < keys.size(); i++) { + CustomKeyData keyData = keys.get(i); + + // --- 创建文本项 --- + TextView itemView = new TextView(game); + itemView.setText(keyData.name); + + // 样式设置 + itemView.setTextColor(0xFF333333); // 灰色文字 + itemView.setTextSize(14); // 字体大小 + itemView.setGravity(android.view.Gravity.CENTER); // 文字居中 + + // 设置内边距 (Padding) + int paddingVertical = dpToPx(7); + int paddingHorizontal = dpToPx(10); + itemView.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical); + + // 布局参数 + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + itemView.setLayoutParams(params); + + // 添加点击效果 + itemView.setBackground(game.getDrawable(R.drawable.button_selector_background)); + + // 点击事件 + itemView.setOnClickListener(v -> { + sendKeys(keyData.keys); + // 震动反馈 + v.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY); + }); + + // 添加 Item + listLayout.addView(itemView); + + // --- 添加分割线 (除了最后一个) --- + if (i < keys.size() - 1) { + View divider = new View(game); + LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 1 // 高度 1px + ); + // 移除左右边距 + dividerParams.setMargins(0, 0, 0, 0); + + divider.setLayoutParams(dividerParams); + divider.setBackgroundColor(0x33000000); // 20% 透明度的黑色 + listLayout.addView(divider); + } + } + } + + // 辅助方法:dp转px + private int dpToPx(float dp) { + return (int) (dp * game.getResources().getDisplayMetrics().density + 0.5f); + } + + /** + * 设置自定义标题 + */ + private void setupCustomTitleBar(View customView, String title) { + TextView titleTextView = customView.findViewById(R.id.customTitleTextView); + if (titleTextView != null) { + titleTextView.setText(title); + } + + // 设置王冠按钮的下划线样式和动态文本 + TextView crownToggleButton = customView.findViewById(R.id.btnCrownToggle); + if (crownToggleButton != null) { + // 根据王冠功能状态设置文本 + String crownText = game.isCrownFeatureEnabled() ? getString(R.string.crown_switch_to_normal) : getString(R.string.crown_switch_to_crown); + crownToggleButton.setText(Html.fromHtml("" + crownText + "")); + crownToggleButton.setOnClickListener(v -> { + // 先切换状态 + boolean wasEnabled = game.isCrownFeatureEnabled(); + toggleCrownFeature(); + // 根据切换后的状态更新文本 + String newCrownText = !wasEnabled ? getString(R.string.crown_switch_to_normal) : getString(R.string.crown_switch_to_crown); + crownToggleButton.setText(Html.fromHtml("" + newCrownText + "")); + }); + } + } + + /** + * 设置当前串流应用信息 (名字、HDR支持) + */ + private void setupAppNameDisplay(View customView) { + try { + // 获取当前串流应用的名字 + String appName = app.getAppName(); + // 获取当前串流应用的HDR支持状态 + boolean hdrSupported = app.isHdrSupported(); + + // 找到App名字显示的TextView + TextView appNameTextView = customView.findViewById(R.id.appNameTextView); + appNameTextView.setText(appName + " (" + (hdrSupported ? "HDR: Supported" : "HDR: Unknown") + ")"); + } catch (Exception e) { + // 如果获取失败,使用默认名字 + TextView appNameTextView = customView.findViewById(R.id.appNameTextView); + if (appNameTextView != null) { + appNameTextView.setText("Moonlight V+"); + } + } + } + + /** + * 设置快捷按钮 + */ + private void setupQuickButtons(View customView, AlertDialog dialog) { + // 创建动画 + android.view.animation.Animation scaleDown = android.view.animation.AnimationUtils.loadAnimation(game, R.anim.button_scale_animation); + android.view.animation.Animation scaleUp = android.view.animation.AnimationUtils.loadAnimation(game, R.anim.button_scale_restore); + + // 设置按钮点击动画 + setupButtonWithAnimation(customView.findViewById(R.id.btnEsc), scaleDown, scaleUp, v -> + sendKeys(new short[]{KeyboardTranslator.VK_ESCAPE})); + + setupButtonWithAnimation(customView.findViewById(R.id.btnWin), scaleDown, scaleUp, v -> + sendKeys(new short[]{KeyboardTranslator.VK_LWIN})); + + setupButtonWithAnimation(customView.findViewById(R.id.btnHDR), scaleDown, scaleUp, v -> + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_MENU, KeyboardTranslator.VK_B})); + + // 设置麦克风按钮,根据设置决定是否启用 + View micButton = customView.findViewById(R.id.btnMic); + if (game.prefConfig != null && game.prefConfig.enableMic) { + // 麦克风重定向已开启,启用按钮 + setupButtonWithAnimation(micButton, scaleDown, scaleUp, v -> toggleMicrophone()); + } else { + // 麦克风重定向未开启,禁用按钮 + micButton.setEnabled(false); + micButton.setAlpha(0.5f); + // 设置禁用图标 + if (micButton instanceof android.widget.Button) { + android.widget.Button button = (android.widget.Button) micButton; + button.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_mic_gm_disabled, 0, 0, 0); + } + micButton.setOnClickListener(v -> { + // 显示提示信息 + Toast.makeText(game, "请在设置中开启麦克风重定向", Toast.LENGTH_SHORT).show(); + }); + } + + setupButtonWithAnimation(customView.findViewById(R.id.btnSleep), scaleDown, scaleUp, v -> { + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, 88}); + handler.postDelayed(() -> sendKeys(new short[]{85, 83}), SLEEP_DELAY); + }); + + setupButtonWithAnimation(customView.findViewById(R.id.btnQuit), scaleDown, scaleUp, v -> { + if (game.prefConfig.swapQuitAndDisconnect) { + game.disconnect(); + } + else { + disconnectAndQuit(); + } + }); + } + + /** + * 为按钮设置动画效果 + */ + @SuppressLint("ClickableViewAccessibility") + private void setupButtonWithAnimation(View button, android.view.animation.Animation scaleDown, + android.view.animation.Animation scaleUp, View.OnClickListener listener) { + // 设置按钮样式 + if (button instanceof android.widget.Button) { + android.widget.Button btn = (android.widget.Button) button; + btn.setTextAppearance(game, R.style.GameMenuButtonStyle); + } + + // 设置按钮支持焦点 + button.setFocusable(true); + button.setClickable(true); + button.setFocusableInTouchMode(true); + + // 设置触摸事件 + button.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + v.startAnimation(scaleDown); + // 添加按下状态的视觉反馈 + v.setAlpha(0.8f); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + v.startAnimation(scaleUp); + // 恢复透明度 + v.setAlpha(1.0f); + if (event.getAction() == MotionEvent.ACTION_UP) { + // 添加点击反馈 + v.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY); + listener.onClick(v); + } + break; + } + return true; + }); + + // 设置键盘事件支持(手柄和遥控器) + setupButtonKeyListener(button, scaleDown, scaleUp, listener); + } + + /** + * 通用按钮键盘事件处理方法 + */ + private void setupButtonKeyListener(View button, android.view.animation.Animation scaleDown, + android.view.animation.Animation scaleUp, View.OnClickListener listener) { + button.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == android.view.KeyEvent.KEYCODE_ENTER) { + // 添加点击反馈 + v.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY); + // 播放动画 + v.startAnimation(scaleDown); + v.postDelayed(() -> { + v.startAnimation(scaleUp); + listener.onClick(v); + }, 100); + return true; + } + } + return false; + }); + } + + /** + * 通用菜单设置方法 + */ + private void setupMenu(ListView listView, ArrayAdapter adapter, AlertDialog dialog) { + // 设置ListView支持手柄和遥控导航 + listView.setItemsCanFocus(true); + + listView.setOnItemClickListener((parent, view, pos, id) -> { + MenuOption option = adapter.getItem(pos); + // 在执行前清除子菜单打开标志 + lastActionOpenedSubmenu = false; + if (option != null) { + run(option); + } + + // 根据选项或运行结果决定是否关闭 dialog:如果该选项需要保留 dialog(如打开二级菜单),或最近操作已打开子菜单,则不关闭 + boolean shouldKeep = (option != null && option.isKeepDialog()) || lastActionOpenedSubmenu; + if (!shouldKeep) { + dialog.dismiss(); + } + // 重置标志 + lastActionOpenedSubmenu = false; + }); + } + + /** + * 设置普通菜单 + */ + private void setupNormalMenu(View customView, MenuOption[] normalOptions, AlertDialog dialog) { + GameMenuAdapter normalAdapter = new GameMenuAdapter(game, normalOptions); + ListView normalListView = customView.findViewById(R.id.gameMenuList); + normalListView.setAdapter(normalAdapter); + setupMenu(normalListView, normalAdapter, dialog); + } + + /** + * 在现有对话框中替换普通菜单区域为新的选项(用于二级菜单),并将当前状态推入栈以便回退 + */ + private void replaceNormalMenuInDialog(AlertDialog dialog, String title, MenuOption[] newNormalOptions, boolean pushToStack) { + if (dialog == null || dialog.getWindow() == null) return; + // 首先尝试使用保存的 custom view 引用 + View customView = this.activeCustomView; + if (customView == null) { + customView = dialog.findViewById(android.R.id.content); + } + // dialog 的自定义视图可能不在 android.R.id.content,尝试通过 getLayoutInflater 找到 + if (customView == null) { + // 通过反向查找原始创建时的视图引用 + customView = dialog.getWindow().getDecorView().findViewById(android.R.id.content); + } + + if (customView == null) return; + + // 更新标题 + TextView titleTextView = customView.findViewById(R.id.customTitleTextView); + if (titleTextView != null && title != null) titleTextView.setText(title); + + // 更新普通菜单列表 + ListView normalListView = customView.findViewById(R.id.gameMenuList); + if (normalListView != null) { + GameMenuAdapter adapter = new GameMenuAdapter(game, newNormalOptions); + normalListView.setAdapter(adapter); + setupMenu(normalListView, adapter, dialog); + } + + // 可选地推入栈 + if (pushToStack) { + // 因为调用者可能已经将当前状态入栈,只有当需要时再入栈 + menuStack.push(new MenuState(title, newNormalOptions)); + } + } + + /** + * 在当前打开的 dialog 中显示一个子菜单(保持超级菜单不变) + */ + private void showSubMenu(String title, MenuOption[] subOptions) { + if (activeDialog != null && activeDialog.isShowing()) { + // 表示接下来要打开子菜单,避免点击后被自动 dismiss + lastActionOpenedSubmenu = true; + // 尝试读取当前普通菜单并保存到栈,以便回退 + if (this.activeCustomView != null) { + // 获取当前标题 + TextView titleTextView = this.activeCustomView.findViewById(R.id.customTitleTextView); + String currentTitle = titleTextView != null ? titleTextView.getText().toString() : null; + + ListView normalListView = this.activeCustomView.findViewById(R.id.gameMenuList); + if (normalListView != null && normalListView.getAdapter() != null) { + int count = normalListView.getAdapter().getCount(); + MenuOption[] currentOptions = new MenuOption[count]; + for (int i = 0; i < count; i++) { + currentOptions[i] = (MenuOption) normalListView.getAdapter().getItem(i); + } + menuStack.push(new MenuState(currentTitle, currentOptions)); + } + } + + // 替换为子菜单(不再自动将子菜单入栈,因为已经保存了当前状态) + replaceNormalMenuInDialog(activeDialog, title, subOptions, false); + } else { + // 没有活动 dialog,则创建新的 + showMenuDialog(title, subOptions, new MenuOption[0]); + } + } + + /** + * 设置超级菜单 + */ + private void setupSuperMenu(View customView, MenuOption[] superOptions, AlertDialog dialog) { + ListView superListView = customView.findViewById(R.id.superMenuList); + + if (superOptions.length > 0) { + SuperMenuAdapter superAdapter = new SuperMenuAdapter(game, superOptions); + superListView.setAdapter(superAdapter); + setupMenu(superListView, superAdapter, dialog); + } else { + setupEmptySuperMenu(superListView); + } + } + + /** + * 设置空的超级菜单 + */ + private void setupEmptySuperMenu(ListView superListView) { + // 计算当前显示的卡片数量 + int visibleCardCount = 0; + if (game.prefConfig.showBitrateCard) visibleCardCount++; + if (game.prefConfig.showGyroCard) visibleCardCount++; + if (game.prefConfig.showQuickKeyCard) visibleCardCount++; + + // 根据卡片数量决定使用哪个布局 + int layoutRes = (visibleCardCount >= 2 | game.prefConfig.showQuickKeyCard) ? + R.layout.game_menu_super_empty_text_only : + R.layout.game_menu_super_empty; + + View emptyView = LayoutInflater.from(game).inflate(layoutRes, superListView, false); + ViewGroup parent = (ViewGroup) superListView.getParent(); + parent.addView(emptyView); + superListView.setEmptyView(emptyView); + SuperMenuAdapter emptyAdapter = new SuperMenuAdapter(game, new MenuOption[0]); + superListView.setAdapter(emptyAdapter); + } + + /** + * 设置对话框属性 + */ + private void setupDialogProperties(AlertDialog dialog) { + if (dialog.getWindow() != null) { + WindowManager.LayoutParams layoutParams = dialog.getWindow().getAttributes(); + layoutParams.alpha = DIALOG_ALPHA; + layoutParams.dimAmount = DIALOG_DIM_AMOUNT; + layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT; + + dialog.getWindow().setAttributes(layoutParams); + dialog.getWindow().setBackgroundDrawableResource(R.drawable.game_menu_dialog_bg); + } + } + + /** + * 显示特殊按键菜单(从rew加载默认配置,可自定义和添加选项) + */ + private void showSpecialKeysMenu() { + List options = new ArrayList<>(); + + // 从 SharedPreferences 加载所有按键。 + // 如果是首次运行,则从 res/raw/default_special_keys.json 加载默认值。 + boolean hasKeys = loadAndAddAllKeys(options); + + // 添加 "添加自定义按键" 选项 + options.add(new MenuOption(getString(R.string.game_menu_add_custom_key), false, this::showAddCustomKeyDialog, null, false)); + + // 如果存在任何按键 (默认或自定义),则添加 "删除" 选项 + if (hasKeys) { + options.add(new MenuOption(getString(R.string.game_menu_delete_custom_key), false, this::showDeleteKeysDialog, null, false)); + } + + // 添加 "取消" 选项 + options.add(new MenuOption(getString(R.string.game_menu_cancel), false, null, null, false)); + + // 显示为子菜单 + showSubMenu(getString(R.string.game_menu_send_keys), options.toArray(new MenuOption[0])); + } + + + /** + * 加载所有按键并添加到菜单选项列表中 + * @param options 用于填充菜单选项的列表 + * @return 如果成功加载了至少一个按键,则返回 true + */ + private boolean loadAndAddAllKeys(List options) { + List loadedKeys = getSavedCustomKeys(); + + if (loadedKeys.isEmpty()) { + return false; + } + + // 将数据转换为菜单选项 + for (CustomKeyData keyData : loadedKeys) { + MenuOption option = new MenuOption( + keyData.name, + false, + () -> sendKeys(keyData.keys), + null, + false + ); + options.add(option); + } + + return true; + } + + /** + * 从 res/raw 目录读取资源文件内容并返回字符串。 + * @param resourceId 资源文件的 ID (例如 R.raw.default_special_keys) + * @return 文件内容的字符串,如果失败则返回空字符串 + */ + private String readRawResourceAsString(int resourceId) { + try (InputStream inputStream = game.getResources().openRawResource(resourceId); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + return builder.toString(); + } catch (IOException e) { + LimeLog.warning("Failed to read raw resource file: " + resourceId + ": " + e); + return ""; + } + } + + /** + * 将新的自定义按键保存到 SharedPreferences + * @param name 按键的显示名称 + * @param keysString 逗号分隔的十六进制按键码字符串 + */ + private void saveCustomKey(String name, String keysString) { + SharedPreferences preferences = game.getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE); + String value = preferences.getString(KEY_NAME, "{\"data\":[]}"); // 如果为空,提供默认JSON结构 + + try { + // 解析按键码 + String[] keyParts = keysString.split(","); + JSONArray keyCodesArray = new JSONArray(); + for (String part : keyParts) { + String trimmedPart = part.trim(); + // 简单验证是否是 "0x" 开头的十六进制 + if (!trimmedPart.startsWith("0x")) { + Toast.makeText(game, R.string.toast_key_code_format_error, Toast.LENGTH_LONG).show(); + return; + } + keyCodesArray.put(trimmedPart); + } + + // 读取现有的JSON数据 + JSONObject root = new JSONObject(value); + JSONArray dataArray = root.getJSONArray("data"); + + // 创建新的JSON对象并添加 + JSONObject newKeyEntry = new JSONObject(); + newKeyEntry.put("name", name); + newKeyEntry.put("data", keyCodesArray); + dataArray.put(newKeyEntry); + + // 写回SharedPreferences + SharedPreferences.Editor editor = preferences.edit(); + editor.putString(KEY_NAME, root.toString()); + editor.apply(); + + Toast.makeText(game, game.getString(R.string.toast_custom_key_saved, name), Toast.LENGTH_SHORT).show(); + + } catch (Exception e) { + LimeLog.warning("Exception while saving custom key" + e.getMessage()); + Toast.makeText(game, R.string.toast_save_failed, Toast.LENGTH_SHORT).show(); + } + } + + private void showAddCustomKeyDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(game, R.style.VirtualKeyboardDialogStyle); + + View dialogView = LayoutInflater.from(game).inflate(R.layout.dialog_add_custom_key, null); + builder.setView(dialogView); + + final LinearLayout dialogContent = dialogView.findViewById(R.id.dialog_content); + final EditText nameInput = dialogView.findViewById(R.id.edit_text_key_name); + final TextView keysDisplay = dialogView.findViewById(R.id.text_view_key_codes); + final Button clearButton = dialogView.findViewById(R.id.button_clear_keys); + final Button closeButton = dialogView.findViewById(R.id.button_close_dialog); + final Button saveButton = dialogView.findViewById(R.id.button_save_key); + + AlertDialog dialog = builder.create(); + + // 设置返回键监听器,返回到二级菜单 + dialog.setOnKeyListener((dialogInterface, keyCode, event) -> { + if (keyCode == android.view.KeyEvent.KEYCODE_BACK && event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + dialog.dismiss(); + return true; // 消费返回键事件 + } + return false; + }); + + // 关闭按钮事件 + if (closeButton != null) { + closeButton.setOnClickListener(v -> dialog.dismiss()); + } + + // 点击背景关闭对话框 + if (dialogView instanceof FrameLayout) { + FrameLayout rootLayout = (FrameLayout) dialogView; + rootLayout.setOnClickListener(v -> { + // 只有点击背景区域才关闭对话框 + dialog.dismiss(); + }); + + // 防止内容区域的点击事件传播到背景 + View contentArea = rootLayout.getChildAt(0); // ScrollView + if (contentArea != null) { + contentArea.setOnClickListener(v -> { + // 阻止事件传播,不关闭对话框 + }); + } + } + + // 初始化/重置 TextView 的数据存储 (tag) 和显示 (text) + keysDisplay.setTag(""); + keysDisplay.setText(""); + keysDisplay.setHint(R.string.dialog_hint_key_codes); + + // 清空按钮: 同时清空数据(tag)和显示(text) + clearButton.setOnClickListener(v -> { + keysDisplay.setTag(""); + keysDisplay.setText(""); + }); + + // 递归设置键盘监听器 + setupCompactKeyboardListeners((ViewGroup) dialogView.findViewById(R.id.keyboard_drawing), keysDisplay); + + // 保存按钮事件 + if (saveButton != null) { + saveButton.setOnClickListener(v -> { + String name = nameInput.getText().toString().trim(); + String androidKeyCodesStr = keysDisplay.getTag().toString(); // 从 tag 获取原始数据 + + if (TextUtils.isEmpty(name) || TextUtils.isEmpty(androidKeyCodesStr)) { + Toast.makeText(game, R.string.toast_name_and_codes_cannot_be_empty, Toast.LENGTH_SHORT).show(); + return; + } + + // 将 Android KeyCodes 字符串转换为 Windows KeyCodes 字符串 + String[] androidCodes = androidKeyCodesStr.split(","); + StringBuilder windowsCodesBuilder = new StringBuilder(); + for (int i = 0; i < androidCodes.length; i++) { + try { + int code = Integer.parseInt(androidCodes[i]); + String windowsCode = KeyCodeMapper.getWindowsKeyCode(code); + if (windowsCode == null) throw new NullPointerException(); // 如果找不到映射,则抛出异常 + + windowsCodesBuilder.append(windowsCode).append(i < androidCodes.length - 1 ? "," : ""); + } catch (Exception e) { + Toast.makeText(game, "error: invalid key code", Toast.LENGTH_LONG).show(); + return; + } + } + saveCustomKey(name, windowsCodesBuilder.toString()); + dialog.dismiss(); + }); + } + + // 显示对话框 + dialog.show(); + dialogContent.setMinimumHeight(game.getResources().getDisplayMetrics().heightPixels); + } + + /** + * 键盘监听器设置方法 + * @param parent 键盘布局的根视图 + * @param keysDisplay 用于存储和显示按键的 TextView + */ + private void setupCompactKeyboardListeners(ViewGroup parent, final TextView keysDisplay) { + if (parent == null) return; + for (int i = 0; i < parent.getChildCount(); i++) { + View child = parent.getChildAt(i); + if (child instanceof ViewGroup) { + setupCompactKeyboardListeners((ViewGroup) child, keysDisplay); // 递归 + } else if (child instanceof TextView && child.getTag() != null) { + child.setOnClickListener(v -> { + String androidKeyCode = v.getTag().toString(); + String currentTag = keysDisplay.getTag().toString(); + + // 1. 更新数据 (Tag) + String newTag = currentTag.isEmpty() ? androidKeyCode : currentTag + "," + androidKeyCode; + keysDisplay.setTag(newTag); + + // 2. 更新显示 (Text) + String currentText = keysDisplay.getText().toString(); + String displayName = KeyCodeMapper.getDisplayName(Integer.parseInt(androidKeyCode)); + String newText = currentText.isEmpty() ? displayName : currentText + " + " + displayName; + keysDisplay.setText(newText); + }); + } + } + } + + /** + * 显示一个对话框,列出所有自定义按键,允许用户选择并删除。 + */ + private void showDeleteKeysDialog() { + SharedPreferences preferences = game.getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE); + String value = preferences.getString(KEY_NAME, ""); + + if (TextUtils.isEmpty(value)) { + Toast.makeText(game, R.string.toast_no_custom_keys_to_delete, Toast.LENGTH_SHORT).show(); + return; + } + + try { + JSONObject root = new JSONObject(value); + JSONArray dataArray = root.optJSONArray("data"); + + if (dataArray == null || dataArray.length() == 0) { + Toast.makeText(game, R.string.toast_no_custom_keys_to_delete, Toast.LENGTH_SHORT).show(); + return; + } + + // 准备列表和选中状态 + final List keyNames = new ArrayList<>(); + for (int i = 0; i < dataArray.length(); i++) { + keyNames.add(dataArray.getJSONObject(i).optString("name")); + } + final boolean[] checkedItems = new boolean[keyNames.size()]; + + // 创建并显示对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(game, R.style.AppDialogStyle); + builder.setTitle(R.string.dialog_title_select_keys_to_delete) + .setMultiChoiceItems(keyNames.toArray(new CharSequence[0]), checkedItems, (dialog, which, isChecked) -> + checkedItems[which] = isChecked) + .setPositiveButton(R.string.dialog_button_delete, (dialog, which) -> { + try { + // 从后往前删除选中项 + for (int i = checkedItems.length - 1; i >= 0; i--) { + if (checkedItems[i]) { + dataArray.remove(i); + } + } + // 保存更改 + root.put("data", dataArray); + preferences.edit().putString(KEY_NAME, root.toString()).apply(); + Toast.makeText(game, R.string.toast_selected_keys_deleted, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + LimeLog.warning("Exception while deleting keys" + e.getMessage()); + Toast.makeText(game, R.string.toast_delete_failed, Toast.LENGTH_SHORT).show(); + } + }) + .setNegativeButton(R.string.dialog_button_cancel, null) + .create() + .show(); + + } catch (Exception e) { + LimeLog.warning("Exception while loading key list" + e.getMessage()); + Toast.makeText(game, R.string.toast_load_key_list_failed, Toast.LENGTH_SHORT).show(); + } + } + + /** + * 显示主菜单 + */ + private void showMenu() { + List normalOptions = new ArrayList<>(); + List superOptions = new ArrayList<>(); + + // 构建普通菜单项 + buildNormalMenuOptions(normalOptions); + + // 构建超级菜单项 + buildSuperMenuOptions(superOptions); + + showMenuDialog(GAME_MENU_TITLE, + normalOptions.toArray(new MenuOption[0]), + superOptions.toArray(new MenuOption[0])); + } + + /** + * 构建普通菜单选项 + */ + private void buildNormalMenuOptions(List normalOptions) { + normalOptions.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard), true, + game::toggleKeyboard, "game_menu_toggle_keyboard", true)); + + normalOptions.add(new MenuOption(getString(R.string.game_menu_toggle_host_keyboard), true, + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_LCONTROL, KeyboardTranslator.VK_O}), + "game_menu_toggle_host_keyboard", true)); + + // 此菜单是 UI 操作,不应该依赖游戏窗口焦点 + normalOptions.add(new MenuOption( + getTouchModeDescription(), + false, + this::showTouchModeMenu, + "mouse_mode", + true, true)); + + normalOptions.add(new MenuOption( + game.getisTouchOverrideEnabled()?"关闭平移/缩放":"开启平移/缩放", + false, + () -> { + Toast.makeText(game, game.getisTouchOverrideEnabled()?"已关闭平移/缩放":"已开启平移/缩放", Toast.LENGTH_SHORT).show(); + game.setisTouchOverrideEnabled(!game.getisTouchOverrideEnabled()); + }, + "game_menu_mouse_emulation", + true + )); + + // 王冠功能 - 只在开启王冠功能时显示 + if (game.isCrownFeatureEnabled()) { + normalOptions.add(new MenuOption( + getString(R.string.game_menu_crown_function), + false, + this::showCrownFunctionMenu, + "crown_function_menu", + true, + true + )); + } + + if (device != null) { + normalOptions.addAll(device.getGameMenuOptions()); + } + + // 性能显示 + normalOptions.add(new MenuOption( + getPerfOverlayMenuLabel(), + false, + ()->{ + game.togglePerformanceOverlay(); + rebuildAndReplaceMenu(); + }, + "game_menu_toggle_performance_overlay", + true, + true + )); + + // 只有在启用了虚拟手柄时才显示虚拟手柄切换选项 + if (game.prefConfig.onscreenController) { + normalOptions.add(new MenuOption(getString(R.string.game_menu_toggle_virtual_controller), + false, game::toggleVirtualController, "game_menu_toggle_virtual_controller", true)); + } + + normalOptions.add(new MenuOption(getString(R.string.game_menu_send_keys), + false, this::showSpecialKeysMenu, "game_menu_send_keys", true, true)); + + // 本地测试震动 + // normalOptions.add(new MenuOption("震动测试", false, this::testLocalRumbleAll, "game_menu_test_local_rumble", true)); + + normalOptions.add(new MenuOption(getString(R.string.game_menu_disconnect), true, + game::disconnect, "game_menu_disconnect", true)); + + normalOptions.add(new MenuOption(getString(R.string.game_menu_disconnect_and_quit), true, + () -> { + if (game.prefConfig.lockScreenAfterDisconnect) { + lockAndDisconnectWithDelay(); + } + else disconnectAndQuit(); + }, "game_menu_disconnect_and_quit", true)); + + // normalOptions.add(new MenuOption(getString(R.string.game_menu_cancel), false, null, null, true)); + } + + private String getTouchModeDescription() { + String touchModeText = getString(R.string.game_menu_switch_touch_mode) + ": "; + + if (game.prefConfig.enableNativeMousePointer) { + touchModeText += getString(R.string.game_menu_touch_mode_native_mouse); + } else if (game.prefConfig.touchscreenTrackpad) { + touchModeText += getString(R.string.game_menu_touch_mode_trackpad); + } else if (game.prefConfig.enableEnhancedTouch) { + touchModeText += getString(R.string.game_menu_touch_mode_enhanced); + } else { + touchModeText += getString(R.string.game_menu_touch_mode_classic); + } + return touchModeText; + } + + private String getPerfOverlayMenuLabel() { + String status; + + // 1. 如果未开启 -> 显示 "关闭" + if (!game.prefConfig.enablePerfOverlay) { + status = getString(R.string.perf_overlay_hidden); + } + // 2. 如果开启且锁定 -> 显示 "固定" + else if (game.prefConfig.perfOverlayLocked) { + status = getString(R.string.perf_overlay_locked); + } + // 3. 如果开启且未锁定 -> 显示 "悬浮" + else { + status = getString(R.string.perf_overlay_floating); + } + + // 拼接结果,例如 "性能监控:悬浮" + return getString(R.string.game_menu_toggle_performance_overlay) + ": " + status; + } + + // 由于不能直接发送win+L来锁定屏幕,可以先打开Windows的屏幕键盘,再发送win+L + public void lockAndDisconnectWithDelay() { + //需要用户先自行打开屏幕键盘 + // 发送 Win+L 锁定屏幕 + sendKeys(new short[]{ + KeyboardTranslator.VK_LWIN, + KeyboardTranslator.VK_L + }); + // 断开并退出串流 + disconnectAndQuit(); + } + + /** + * 构建超级菜单选项 + */ + private void buildSuperMenuOptions(List superOptions) { + JsonArray cmdList = app.getCmdList(); + if (cmdList != null) { + for (int i = 0; i < cmdList.size(); i++) { + JsonObject cmd = cmdList.get(i).getAsJsonObject(); + superOptions.add(new MenuOption(cmd.get("name").getAsString(), true, () -> { + try { + conn.sendSuperCmd(cmd.get("id").getAsString()); + } catch (IOException | XmlPullParserException e) { + Toast.makeText(game, "发送超级命令时发生错误: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + }, null, false)); // 超级指令菜单不显示图标 + } + } + } + + /** + * 获取菜单项图标 + */ + private static int getIconForMenuOption(String iconKey) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return ICON_MAP.getOrDefault(iconKey, R.drawable.ic_menu_item_default); + } + return -1; + } + + /** + * 自定义适配器用于显示美化的菜单项 + */ + private static class GameMenuAdapter extends ArrayAdapter { + private final Context context; + + public GameMenuAdapter(Context context, MenuOption[] options) { + super(context, 0, options); + this.context = context; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = LayoutInflater.from(context).inflate(R.layout.game_menu_list_item, parent, false); + } + + MenuOption option = getItem(position); + if (option != null) { + TextView textView = convertView.findViewById(R.id.menu_item_text); + ImageView iconView = convertView.findViewById(R.id.menu_item_icon); + + textView.setText(option.getLabel()); + + if (option.isShowIcon()) { + iconView.setImageResource(getIconForMenuOption(option.getIconKey())); + iconView.setVisibility(View.VISIBLE); + } else { + iconView.setVisibility(View.GONE); + } + } + + return convertView; + } + } + + /** + * 超级菜单适配器 + */ + private static class SuperMenuAdapter extends ArrayAdapter { + private final Context context; + + public SuperMenuAdapter(Context context, MenuOption[] options) { + super(context, 0, options); + this.context = context; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = LayoutInflater.from(context).inflate(R.layout.game_menu_list_item, parent, false); + } + + MenuOption option = getItem(position); + if (option != null) { + TextView textView = convertView.findViewById(R.id.menu_item_text); + ImageView iconView = convertView.findViewById(R.id.menu_item_icon); + + textView.setText(option.getLabel()); + + if (option.isShowIcon()) { + iconView.setImageResource(R.drawable.ic_cmd_cute); + iconView.setVisibility(View.VISIBLE); + } else { + iconView.setVisibility(View.GONE); + } + } + + return convertView; + } + } +} diff --git a/app/src/main/java/com/limelight/GyroCardController.java b/app/src/main/java/com/limelight/GyroCardController.java new file mode 100644 index 0000000000..4d2222a2e5 --- /dev/null +++ b/app/src/main/java/com/limelight/GyroCardController.java @@ -0,0 +1,150 @@ +package com.limelight; + +import android.app.AlertDialog; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import android.widget.CompoundButton; + +import com.limelight.binding.input.ControllerHandler; + +/** + * Encapsulates the gyro-to-right-stick control card logic. + */ +public class GyroCardController { + private final Game game; + + public GyroCardController(Game game) { + this.game = game; + } + + public void setup(View customView, AlertDialog dialog) { + View container = customView.findViewById(R.id.gyroAdjustmentContainer); + if (container == null) return; + + TextView statusText = customView.findViewById(R.id.gyroStatusText); + CompoundButton toggleSwitch = customView.findViewById(R.id.gyroToggleSwitch); + View activationKeyContainer = customView.findViewById(R.id.gyroActivationKeyContainer); + TextView activationKeyText = customView.findViewById(R.id.gyroActivationKeyText); + SeekBar sensSeek = customView.findViewById(R.id.gyroSensitivitySeekBar); + TextView sensVal = customView.findViewById(R.id.gyroSensitivityValueText); + CompoundButton invertXSwitch = customView.findViewById(R.id.gyroInvertXSwitch); + CompoundButton invertYSwitch = customView.findViewById(R.id.gyroInvertYSwitch); + + if (statusText != null) { + statusText.setText(game.prefConfig.gyroToRightStick ? "ON" : "OFF"); + } + + if (toggleSwitch != null) { + toggleSwitch.setChecked(game.prefConfig.gyroToRightStick); + toggleSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + ControllerHandler ch = game.getControllerHandler(); + if (ch == null) { + Toast.makeText(game, "Failed to access controller", Toast.LENGTH_SHORT).show(); + buttonView.setChecked(!isChecked); + return; + } + ch.setGyroToRightStickEnabled(isChecked); + if (statusText != null) statusText.setText(isChecked ? "ON" : "OFF"); + }); + } + + // 更新显示 + if (activationKeyText != null) { + updateActivationKeyText(activationKeyText); + } + if (activationKeyContainer != null) { + activationKeyContainer.setOnClickListener(v -> showActivationKeyDialog(activationKeyText)); + } + + if (sensSeek != null && sensVal != null) { + // 改为“灵敏度倍数”,越高越快。映射范围:0.5x .. 3.0x(步进 0.1) + int max = 25; // 0..25 -> +0..2.5 => 0.5..3.0 + sensSeek.setMax(max); + // 反推当前 multiplier 到 progress + float mult = Math.max(0.5f, Math.min(3.0f, game.prefConfig.gyroSensitivityMultiplier > 0 ? game.prefConfig.gyroSensitivityMultiplier : 1.0f)); + int progress = Math.round((mult - 0.5f) / 0.1f); + sensSeek.setProgress(progress); + sensVal.setText(String.format("%.1fx", mult)); + + sensSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int p, boolean fromUser) { + float m = 0.5f + p * 0.1f; + game.prefConfig.gyroSensitivityMultiplier = m; + sensVal.setText(String.format("%.1fx", m)); + } + @Override public void onStartTrackingTouch(SeekBar seekBar) {} + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + game.prefConfig.writePreferences(game); + } + }); + } + + if (invertXSwitch != null) { + invertXSwitch.setChecked(game.prefConfig.gyroInvertXAxis); + invertXSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + game.prefConfig.gyroInvertXAxis = isChecked; + game.prefConfig.writePreferences(game); + }); + } + + if (invertYSwitch != null) { + invertYSwitch.setChecked(game.prefConfig.gyroInvertYAxis); + invertYSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + game.prefConfig.gyroInvertYAxis = isChecked; + game.prefConfig.writePreferences(game); + }); + } + } + + private void showActivationKeyDialog(TextView activationKeyText) { + final CharSequence[] items = new CharSequence[]{ + game.getString(R.string.gyro_activation_always), + "LT (L2)", + "RT (R2)" + }; + int checked; + if (game.prefConfig.gyroActivationKeyCode == ControllerHandler.GYRO_ACTIVATION_ALWAYS) { + checked = 0; + } else if (game.prefConfig.gyroActivationKeyCode == android.view.KeyEvent.KEYCODE_BUTTON_R2) { + checked = 2; + } else { + checked = 1; + } + new AlertDialog.Builder(game, R.style.AppDialogStyle) + .setTitle(R.string.gyro_activation_method) + .setSingleChoiceItems(items, checked, (d, which) -> { + if (which == 0) { + game.prefConfig.gyroActivationKeyCode = ControllerHandler.GYRO_ACTIVATION_ALWAYS; + } else if (which == 1) { + game.prefConfig.gyroActivationKeyCode = android.view.KeyEvent.KEYCODE_BUTTON_L2; + } else { + game.prefConfig.gyroActivationKeyCode = android.view.KeyEvent.KEYCODE_BUTTON_R2; + } + game.prefConfig.writePreferences(game); + if (activationKeyText != null) { + activationKeyText.setText(items[which]); + } + d.dismiss(); + }) + .setNegativeButton(R.string.dialog_button_cancel, null) + .show(); + } + + private void updateActivationKeyText(TextView activationKeyText) { + String label; + if (game.prefConfig.gyroActivationKeyCode == ControllerHandler.GYRO_ACTIVATION_ALWAYS) { + label = game.getString(R.string.gyro_activation_always); + } else if (game.prefConfig.gyroActivationKeyCode == android.view.KeyEvent.KEYCODE_BUTTON_R2) { + label = "RT (R2)"; + } else { + label = "LT (L2)"; + } + activationKeyText.setText(label); + } +} diff --git a/app/src/main/java/com/limelight/PcView.java b/app/src/main/java/com/limelight/PcView.java index 4ec6094f6e..cdf9e63b44 100644 --- a/app/src/main/java/com/limelight/PcView.java +++ b/app/src/main/java/com/limelight/PcView.java @@ -2,11 +2,14 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.io.File; +import java.io.FileOutputStream; import java.net.UnknownHostException; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.RequestOptions; import com.limelight.binding.PlatformBinding; import com.limelight.binding.crypto.AndroidCryptoProvider; -import com.limelight.computers.ComputerManagerListener; import com.limelight.computers.ComputerManagerService; import com.limelight.grid.PcGridAdapter; import com.limelight.grid.assets.DiskAssetLoader; @@ -14,6 +17,7 @@ import com.limelight.nvstream.http.NvApp; import com.limelight.nvstream.http.NvHTTP; import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.http.PairingManager.PairResult; import com.limelight.nvstream.http.PairingManager.PairState; import com.limelight.nvstream.wol.WakeOnLanSender; import com.limelight.preferences.AddComputerManually; @@ -23,48 +27,101 @@ import com.limelight.ui.AdapterFragment; import com.limelight.ui.AdapterFragmentCallbacks; import com.limelight.utils.Dialog; +import com.limelight.utils.EasyTierController; import com.limelight.utils.HelpLauncher; +import com.limelight.utils.Iperf3Tester; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; import com.limelight.utils.UiHelper; +import com.limelight.utils.AnalyticsManager; +import com.limelight.utils.UpdateManager; +import com.limelight.utils.AppCacheManager; +import com.limelight.utils.CacheHelper; +import com.limelight.dialogs.AddressSelectionDialog; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.StringReader; +import java.util.List; + +import com.bumptech.glide.Glide; + +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; import android.app.Service; +import android.content.BroadcastReceiver; import android.content.ComponentName; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.net.Uri; +import android.net.VpnService; import android.opengl.GLSurfaceView; import android.os.Build; import android.os.Bundle; +import android.os.Environment; import android.os.IBinder; import android.preference.PreferenceManager; +import android.provider.Settings; +import android.util.LruCache; import android.view.ContextMenu; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ContextMenu.ContextMenuInfo; -import android.view.View.OnClickListener; import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; import android.widget.ImageButton; +import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.Toast; import android.widget.AdapterView.AdapterContextMenuInfo; -import org.xmlpull.v1.XmlPullParserException; +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.opengles.GL10; -public class PcView extends Activity implements AdapterFragmentCallbacks { +import jp.wasabeef.glide.transformations.BlurTransformation; +import jp.wasabeef.glide.transformations.ColorFilterTransformation; + +import android.app.AlertDialog; +import android.content.SharedPreferences; +import android.hardware.SensorManager; + +import com.squareup.seismic.ShakeDetector; +import com.easytier.jni.EasyTierManager; + +public class PcView extends Activity implements AdapterFragmentCallbacks, ShakeDetector.Listener, EasyTierController.VpnPermissionCallback { private RelativeLayout noPcFoundLayout; private PcGridAdapter pcGridAdapter; private ShortcutHelper shortcutHelper; + private int selectedPosition = -1; private ComputerManagerService.ComputerManagerBinder managerBinder; private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled; + + private EasyTierController easyTierController; + + private AddressSelectionDialog currentAddressDialog; + + private ShakeDetector shakeDetector; + private long lastShakeTime = 0; + private static final long SHAKE_DEBOUNCE_INTERVAL = 3000; // 3 seconds debounce + private static final int MAX_DAILY_REFRESH = 7; // Maximum 7 refreshes per day + private static final String REFRESH_PREF_NAME = "RefreshLimit"; + private static final String REFRESH_COUNT_KEY = "refresh_count"; + private static final String REFRESH_DATE_KEY = "refresh_date"; + + // 背景图片刷新广播接收器 + private BroadcastReceiver backgroundImageRefreshReceiver; + private final ServiceConnection serviceConnection = new ServiceConnection() { public void onServiceConnected(ComponentName className, IBinder binder) { final ComputerManagerService.ComputerManagerBinder localBinder = @@ -119,6 +176,20 @@ public void onConfigurationChanged(Configuration newConfig) { private final static int FULL_APP_LIST_ID = 9; private final static int TEST_NETWORK_ID = 10; private final static int GAMESTREAM_EOL_ID = 11; + private final static int SLEEP_ID = 12; + private final static int IPERF3_TEST_ID = 13; + + public String clientName; + private LruCache bitmapLruCache; + private AnalyticsManager analyticsManager; + private EasyTierManager easyTierManager; + private static final int VPN_PERMISSION_REQUEST_CODE = 101; + private static final String EASYTIER_PREFS = "easytier_preferences"; + private static final String KEY_TOML_CONFIG = "toml_config_string"; + + // 添加场景配置相关常量 + private static final String SCENE_PREF_NAME = "SceneConfigs"; + private static final String SCENE_KEY_PREFIX = "scene_"; private void initializeViews() { setContentView(R.layout.activity_pc_view); @@ -130,8 +201,74 @@ private void initializeViews() { setShouldDockBigOverlays(false); } - // Set default preferences if we've never been run - PreferenceManager.setDefaultValues(this, R.xml.preferences, false); + clientName = Settings.Global.getString(this.getContentResolver(), "device_name"); + + ImageView imageView = findViewById(R.id.pcBackgroundImage); + String imageUrl = getBackgroundImageUrl(); + + // set background image + new Thread(() -> { + try { + // 将 imageUrl 转换为可被 Glide 正确识别的对象 + Object glideLoadTarget; + if (imageUrl.startsWith("http")) { + // URL 直接使用 + glideLoadTarget = imageUrl; + } else if (new File(imageUrl).exists()) { + // 本地文件路径,转换为 File 对象 + glideLoadTarget = new File(imageUrl); + } else { + // 文件不存在或无效,使用默认 URL + glideLoadTarget = "https://img-api.pipw.top"; + } + + final Bitmap bitmap = Glide.with(PcView.this) + .asBitmap() + .load(glideLoadTarget) + .skipMemoryCache(true).diskCacheStrategy(DiskCacheStrategy.NONE) + .submit() + .get(); + if (bitmap != null) { + bitmapLruCache.put(imageUrl, bitmap); + runOnUiThread(() -> Glide.with(PcView.this) + .load(bitmap) + .apply(RequestOptions.bitmapTransform(new BlurTransformation(2, 3))) + .transform(new ColorFilterTransformation(Color.argb(120, 0, 0, 0))) + .into(imageView)); + } + } catch (Exception e) { + e.printStackTrace(); + } + }).start(); + + // 设置长按监听 + imageView.setOnLongClickListener(v -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11及以上需要检查MANAGE_EXTERNAL_STORAGE权限 + if (Environment.isExternalStorageManager()) { + saveImage(); + } else { + // 请求权限 + Toast.makeText(this, getResources().getString(R.string.storage_permission_required), Toast.LENGTH_LONG).show(); + try { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.setData(Uri.parse("package:" + getPackageName())); + startActivity(intent); + } catch (Exception e) { + Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + startActivity(intent); + } + } + } else { + // Android 10及以下直接保存 + saveImage(); + } + return true; + }); + + if (getWindow().getDecorView().getRootView() != null) { + initSceneButtons(); + } // Set the correct layout for the PC grid pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); @@ -140,26 +277,23 @@ private void initializeViews() { ImageButton settingsButton = findViewById(R.id.settingsButton); ImageButton addComputerButton = findViewById(R.id.manuallyAddPc); ImageButton helpButton = findViewById(R.id.helpButton); + ImageButton restoreSessionButton = findViewById(R.id.restoreSessionButton); - settingsButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - startActivity(new Intent(PcView.this, StreamSettings.class)); - } - }); - addComputerButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - Intent i = new Intent(PcView.this, AddComputerManually.class); - startActivity(i); - } + ImageButton easyTierButton = findViewById(R.id.easyTierControlButton); + if (easyTierButton != null) { + easyTierButton.setOnClickListener(v -> showEasyTierControlDialog()); + } + + settingsButton.setOnClickListener(v -> startActivity(new Intent(PcView.this, StreamSettings.class))); + addComputerButton.setOnClickListener(v -> { + Intent i = new Intent(PcView.this, AddComputerManually.class); + startActivity(i); }); - helpButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - HelpLauncher.launchSetupGuide(PcView.this); - } + helpButton.setOnClickListener(v -> { +// HelpLauncher.launchSetupGuide(PcView.this); + joinQQGroup("LlbLDIF_YolaM4HZyLx0xAXXo04ZmoBM"); }); + restoreSessionButton.setOnClickListener(v -> restoreLastSession()); // Amazon review didn't like the help button because the wiki was not entirely // navigable via the Fire TV remote (though the relevant parts were). Let's hide @@ -182,14 +316,140 @@ public void onClick(View v) { pcGridAdapter.notifyDataSetChanged(); } + private @NonNull String getBackgroundImageUrl() { + // 获取用户自定义的图片API地址 + String customUrl = PreferenceManager.getDefaultSharedPreferences(this) + .getString("background_image_url", null); + + // 如果没有自定义地址,使用默认地址 + if (customUrl == null || customUrl.isEmpty()) { + int deviceRotation = this.getWindowManager().getDefaultDisplay().getRotation(); + return deviceRotation == Configuration.ORIENTATION_PORTRAIT ? + "https://img-api.pipw.top" : + "https://img-api.pipw.top/?phone=true"; + } + + // 使用自定义地址 + return customUrl; + } + + private void saveImage() { + // 先尝试从缓存获取 + Bitmap bitmap = bitmapLruCache.get(getBackgroundImageUrl()); + + if (bitmap == null) { + // 如果缓存中没有,尝试从ImageView获取 + ImageView imageView = findViewById(R.id.pcBackgroundImage); + if (imageView != null && imageView.getDrawable() != null) { + Toast.makeText(this, getResources().getString(R.string.downloading_image_please_wait), Toast.LENGTH_SHORT).show(); + + // 在后台线程重新下载原图 + new Thread(() -> { + try { + String imageUrl = getBackgroundImageUrl(); + + // 将 imageUrl 转换为可被 Glide 正确识别的对象 + Object glideLoadTarget; + if (imageUrl.startsWith("http")) { + // URL 直接使用 + glideLoadTarget = imageUrl; + } else if (new File(imageUrl).exists()) { + // 本地文件路径,转换为 File 对象 + glideLoadTarget = new File(imageUrl); + } else { + // 文件不存在或无效,使用默认 URL + glideLoadTarget = "https://img-api.pipw.top"; + } + + Bitmap downloadedBitmap = Glide.with(PcView.this) + .asBitmap() + .load(glideLoadTarget) + .submit() + .get(); + + if (downloadedBitmap != null) { + // 重新放入缓存 + bitmapLruCache.put(imageUrl, downloadedBitmap); + // 保存图片 + runOnUiThread(() -> saveBitmapToFile(downloadedBitmap)); + } else { + runOnUiThread(() -> Toast.makeText(PcView.this, getResources().getString(R.string.image_download_failed_retry), Toast.LENGTH_SHORT).show()); + } + } catch (Exception e) { + e.printStackTrace(); + runOnUiThread(() -> Toast.makeText(PcView.this, getResources().getString(R.string.image_download_failed_with_error, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }).start(); + return; + } else { + Toast.makeText(this, getResources().getString(R.string.image_not_loaded_please_retry), Toast.LENGTH_SHORT).show(); + return; + } + } + + // 如果缓存中有图片,直接保存 + saveBitmapToFile(bitmap); + } + + private void saveBitmapToFile(Bitmap bitmap) { + if (bitmap == null) { + Toast.makeText(this, getResources().getString(R.string.image_invalid), Toast.LENGTH_SHORT).show(); + return; + } + + // 图片保存路径,这里保存到外部存储的Pictures目录下,可根据需求调整 + String root = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toString(); + File myDir = new File(root + "/setu"); + myDir.mkdirs(); + + // 文件名设置 + String fileName = "pipw-" + System.currentTimeMillis() + ".png"; + File file = new File(myDir, fileName); + + try { + FileOutputStream outputStream = new FileOutputStream(file); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); + outputStream.flush(); + outputStream.close(); + refreshSystemPic(PcView.this, file); + Toast.makeText(this, getResources().getString(R.string.image_saved_successfully), Toast.LENGTH_SHORT).show(); + } catch (IOException e) { + e.printStackTrace(); + Toast.makeText(this, getResources().getString(R.string.image_save_failed_with_error, e.getMessage()), Toast.LENGTH_SHORT).show(); + } + // 不再清空所有缓存,只移除当前图片(可选) + // bitmapLruCache.remove(getBackgroundImageUrl()); + } + + // 刷新图库的方法 + private void refreshSystemPic(Context context, File file) { + Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + Uri contentUri = Uri.fromFile(file); + intent.setData(contentUri); + context.sendBroadcast(intent); + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + easyTierController = new EasyTierController(this, this); + // Assume we're in the foreground when created to avoid a race // between binding to CMS and onResume() inForeground = true; + // Create cache for images + int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + int cacheSize = maxMemory / 8; + bitmapLruCache = new LruCache<>(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap value) { + // 计算每个Bitmap占用的内存大小(以KB为单位) + return value.getByteCount() / 1024; + } + }; + // Create a GLSurfaceView to fetch GLRenderer unless we have // a cached result already. final GlPreferences glPrefs = GlPreferences.readPreferences(this); @@ -205,12 +465,7 @@ public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) { LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer); - runOnUiThread(new Runnable() { - @Override - public void run() { - completeOnCreate(); - } - }); + runOnUiThread(() -> completeOnCreate()); } @Override @@ -229,6 +484,114 @@ public void onDrawFrame(GL10 gl10) { } } + private void initSceneButtons() { + try { + int[] sceneButtonIds = { + R.id.scene1Btn, R.id.scene2Btn, + R.id.scene3Btn, R.id.scene4Btn, R.id.scene5Btn + }; + + for (int i = 0; i < sceneButtonIds.length; i++) { + final int sceneNumber = i + 1; + ImageButton btn = findViewById(sceneButtonIds[i]); + + if (btn == null) { + LimeLog.warning("Scene button "+ sceneNumber +" (ID: "+getResources().getResourceName(sceneButtonIds[i])+") not found!"); + continue; + } + + btn.setOnClickListener(v -> applySceneConfiguration(sceneNumber)); + btn.setOnLongClickListener(v -> { + showSaveConfirmationDialog(sceneNumber); + return true; + }); + } + } catch (Exception e) { + LimeLog.warning("Scene init failed: "+ e); + e.printStackTrace(); + } + } + + @SuppressLint({"DefaultLocale", "StringFormatMatches"}) + private void applySceneConfiguration(int sceneNumber) { + try { + SharedPreferences prefs = getSharedPreferences(SCENE_PREF_NAME, MODE_PRIVATE); + String configJson = prefs.getString(SCENE_KEY_PREFIX + sceneNumber, null); + + if (configJson != null) { + JSONObject config = new JSONObject(configJson); + // 解析配置参数 + int width = config.optInt("width", 1920); + int height = config.optInt("height", 1080); + int fps = config.optInt("fps", 60); + int bitrate = config.optInt("bitrate", 10000); + String videoFormat = config.optString("videoFormat", "auto"); + boolean enableHdr = config.optBoolean("enableHdr", false); + boolean enablePerfOverlay = config.optBoolean("enablePerfOverlay", false); + + // 使用副本配置进行操作 + PreferenceConfiguration configPrefs = PreferenceConfiguration.readPreferences(this).copy(); + configPrefs.width = width; + configPrefs.height = height; + configPrefs.fps = fps; + configPrefs.bitrate = bitrate; + configPrefs.videoFormat = PreferenceConfiguration.FormatOption.valueOf(videoFormat); + configPrefs.enableHdr = enableHdr; + configPrefs.enablePerfOverlay = enablePerfOverlay; + + // 保存并检查结果 + if (!configPrefs.writePreferences(this)) { + Toast.makeText(this, getResources().getString(R.string.config_save_failed), Toast.LENGTH_SHORT).show(); + return; + } + + pcGridAdapter.updateLayoutWithPreferences(this, configPrefs); + + Toast.makeText(this, getResources().getString(R.string.scene_config_applied, + sceneNumber, width, height, fps, bitrate / 1000.0, videoFormat, enableHdr ? "On" : "Off"), Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, getResources().getString(R.string.scene_not_configured, sceneNumber), Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + LimeLog.warning("Scene apply failed: "+ e); + runOnUiThread(() -> Toast.makeText(PcView.this, getResources().getString(R.string.config_apply_failed), Toast.LENGTH_SHORT).show()); + } + } + + private void showSaveConfirmationDialog(int sceneNumber) { + new AlertDialog.Builder(this, R.style.AppDialogStyle) + .setTitle(getResources().getString(R.string.save_to_scene, sceneNumber)) + .setMessage(getResources().getString(R.string.overwrite_current_config)) + .setPositiveButton(getResources().getString(R.string.dialog_button_save), (dialog, which) -> saveCurrentConfiguration(sceneNumber)) + .setNegativeButton(getResources().getString(R.string.dialog_button_cancel), null) + .show(); + } + + private void saveCurrentConfiguration(int sceneNumber) { + try { + PreferenceConfiguration configPrefs = PreferenceConfiguration.readPreferences(this); + JSONObject config = new JSONObject(); + config.put("width", configPrefs.width); + config.put("height", configPrefs.height); + config.put("fps", configPrefs.fps); + config.put("bitrate", configPrefs.bitrate); + config.put("videoFormat", configPrefs.videoFormat.toString()); + config.put("enableHdr", configPrefs.enableHdr); + config.put("enablePerfOverlay", configPrefs.enablePerfOverlay); + + // 保存到SharedPreferences + getSharedPreferences(SCENE_PREF_NAME, MODE_PRIVATE) + .edit() + .putString(SCENE_KEY_PREFIX + sceneNumber, config.toString()) + .apply(); + + Toast.makeText(this, getResources().getString(R.string.scene_saved_successfully, sceneNumber), Toast.LENGTH_SHORT).show(); + } catch (JSONException e) { + Toast.makeText(this, getResources().getString(R.string.config_save_failed), Toast.LENGTH_SHORT).show(); + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") private void completeOnCreate() { completeOnCreateCalled = true; @@ -236,11 +599,39 @@ private void completeOnCreate() { UiHelper.setLocale(this); + // 初始化统计分析管理器 + analyticsManager = AnalyticsManager.getInstance(this); + analyticsManager.logAppLaunch(); + + // 检查应用更新 + UpdateManager.checkForUpdatesOnStartup(this); + // Bind to the computer manager service bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE); pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this)); + + SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); + shakeDetector = new ShakeDetector(this); + shakeDetector.setSensitivity(ShakeDetector.SENSITIVITY_MEDIUM); // 设置中等灵敏度 + + // 注册背景图片刷新广播接收器 + backgroundImageRefreshReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if ("com.limelight.REFRESH_BACKGROUND_IMAGE".equals(intent.getAction())) { + // 传入 false,表示这不是通过摇一摇触发的,不需要显示每日限制提示 + refreshBackgroundImage(false); + } + } + }; + IntentFilter filter = new IntentFilter("com.limelight.REFRESH_BACKGROUND_IMAGE"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(backgroundImageRefreshReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(backgroundImageRefreshReceiver, filter); + } initializeViews(); } @@ -250,21 +641,13 @@ private void startComputerUpdates() { // and our activity is in the foreground. if (managerBinder != null && !runningPolling && inForeground) { freezeUpdates = false; - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - if (!freezeUpdates) { - PcView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - updateComputer(details); - } - }); - - // Add a launcher shortcut for this PC (off the main thread to prevent ANRs) - if (details.pairState == PairState.PAIRED) { - shortcutHelper.createAppViewShortcutForOnlineHost(details); - } + managerBinder.startPolling(details -> { + if (!freezeUpdates) { + PcView.this.runOnUiThread(() -> updateComputer(details)); + + // Add a launcher shortcut for this PC (off the main thread to prevent ANRs) + if (details.pairState == PairState.PAIRED) { + shortcutHelper.createAppViewShortcutForOnlineHost(details); } } }); @@ -294,9 +677,33 @@ private void stopComputerUpdates(boolean wait) { public void onDestroy() { super.onDestroy(); + if (easyTierController != null) { + easyTierController.onDestroy(); + } + if (managerBinder != null) { unbindService(serviceConnection); } + + // 关闭地址选择对话框 + if (currentAddressDialog != null) { + currentAddressDialog.dismiss(); + currentAddressDialog = null; + } + + // 注销背景图片刷新广播接收器 + if (backgroundImageRefreshReceiver != null) { + try { + unregisterReceiver(backgroundImageRefreshReceiver); + } catch (IllegalArgumentException e) { + LimeLog.warning("Failed to unregister background image refresh receiver: " + e.getMessage()); + } + } + + // 清理统计分析资源 + if (analyticsManager != null) { + analyticsManager.cleanup(); + } } @Override @@ -308,6 +715,23 @@ protected void onResume() { inForeground = true; startComputerUpdates(); + + // 开始记录使用时长 + if (analyticsManager != null) { + analyticsManager.startUsageTracking(); + } + + if (shakeDetector != null) { + try { + shakeDetector.start((SensorManager) getSystemService(SENSOR_SERVICE)); + } catch (SecurityException e) { + // Android 12+ 需要 HIGH_SAMPLING_RATE_SENSORS 权限 + LimeLog.warning("shakeDetector start failed: " + e.getMessage()); + // 不显示错误,静默失败即可 + } catch (Exception e) { + LimeLog.warning("shakeDetector start failed: " + e.getMessage()); + } + } } @Override @@ -316,6 +740,19 @@ protected void onPause() { inForeground = false; stopComputerUpdates(false); + + // 停止记录使用时长 + if (analyticsManager != null) { + analyticsManager.stopUsageTracking(); + } + + if (shakeDetector != null) { + try { + shakeDetector.stop(); + } catch (Exception e) { + LimeLog.warning("shakeDetector stop failed: " + e.getMessage()); + } + } } @Override @@ -331,9 +768,19 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn // Call superclass super.onCreateContextMenu(menu, v, menuInfo); - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); + + int position = -1; + if (menuInfo instanceof AdapterContextMenuInfo) { + position = ((AdapterContextMenuInfo) menuInfo).position; + } else if (v != null && v.getTag() instanceof Integer) { + position = (Integer) v.getTag(); + } else if (selectedPosition >= 0) { + position = selectedPosition; + } + + if (position < 0) return; + + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(position); // Add a header with PC status details menu.clearHeader(); @@ -358,7 +805,6 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn if (computer.details.state == ComputerDetails.State.OFFLINE || computer.details.state == ComputerDetails.State.UNKNOWN) { menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol)); - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); } else if (computer.details.pairState != PairState.PAIRED) { menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc)); @@ -377,9 +823,11 @@ else if (computer.details.pairState != PairState.PAIRED) { } menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list)); + menu.add(Menu.NONE, SLEEP_ID, 8, getResources().getString(R.string.send_sleep_command)); } menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network)); + menu.add(Menu.NONE, IPERF3_TEST_ID, 6, getResources().getString(R.string.network_bandwidth_test)); menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc)); menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details)); } @@ -403,97 +851,96 @@ private void doPair(final ComputerDetails computer) { } Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - boolean success = false; - try { - // Stop updates and wait while pairing - stopComputerUpdates(true); - - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), - computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, - PlatformBinding.getCryptoProvider(PcView.this)); - if (httpConn.getPairState() == PairState.PAIRED) { - // Don't display any toast, but open the app list - message = null; - success = true; - } - else { - final String pinStr = PairingManager.generatePinString(); - - // Spin the dialog off in a thread because it blocks - Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title), - getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+ - getResources().getString(R.string.pair_pairing_help), false); - - PairingManager pm = httpConn.getPairingManager(); - - PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr); - if (pairState == PairState.PIN_WRONG) { + new Thread(() -> { + String message = null; + boolean success = false; + + try { + // Stop updates and wait while pairing + stopComputerUpdates(true); + + NvHTTP httpConn = new NvHTTP( + ServerHelper.getCurrentAddressFromComputer(computer), + computer.httpsPort, + managerBinder.getUniqueId(), + clientName, + computer.serverCert, + PlatformBinding.getCryptoProvider(PcView.this) + ); + + if (httpConn.getPairState() == PairState.PAIRED) { + // Already paired, open the app list directly + success = true; + } else { + // Generate PIN and show pairing dialog + final String pinStr = PairingManager.generatePinString(); + Dialog.displayDialog( + PcView.this, + getResources().getString(R.string.pair_pairing_title), + getResources().getString(R.string.pair_pairing_msg) + " " + pinStr + "\n\n" + + getResources().getString(R.string.pair_pairing_help), + false + ); + + PairingManager pm = httpConn.getPairingManager(); + PairResult pairResult = pm.pair(httpConn.getServerInfo(true), pinStr); + PairState pairState = pairResult.state; + + switch (pairState) { + case PIN_WRONG: message = getResources().getString(R.string.pair_incorrect_pin); - } - else if (pairState == PairState.FAILED) { - if (computer.runningGameId != 0) { - message = getResources().getString(R.string.pair_pc_ingame); - } - else { - message = getResources().getString(R.string.pair_fail); - } - } - else if (pairState == PairState.ALREADY_IN_PROGRESS) { + break; + case FAILED: + message = computer.runningGameId != 0 + ? getResources().getString(R.string.pair_pc_ingame) + : getResources().getString(R.string.pair_fail); + break; + case ALREADY_IN_PROGRESS: message = getResources().getString(R.string.pair_already_in_progress); - } - else if (pairState == PairState.PAIRED) { - // Just navigate to the app view without displaying a toast - message = null; + break; + case PAIRED: success = true; - // Pin this certificate for later HTTPS use managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert(); - - // Invalidate reachability information after pairing to force - // a refresh before reading pair state again + + // Save pair name using SharedPreferences + SharedPreferences sharedPreferences = getSharedPreferences("pair_name_map", MODE_PRIVATE); + sharedPreferences.edit().putString(computer.uuid, pairResult.pairName).apply(); + + // Invalidate reachability information after pairing managerBinder.invalidateStateForComputer(computer.uuid); - } - else { - // Should be no other values - message = null; - } + break; } - } catch (UnknownHostException e) { - message = getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = getResources().getString(R.string.error_404); - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - message = e.getMessage(); } - + } catch (UnknownHostException e) { + message = getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = getResources().getString(R.string.error_404); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore interrupt status + message = getResources().getString(R.string.pair_fail); + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + message = e.getMessage(); + } finally { Dialog.closeDialogs(); + } - final String toastMessage = message; - final boolean toastSuccess = success; - runOnUiThread(new Runnable() { - @Override - public void run() { - if (toastMessage != null) { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } + final String toastMessage = message; + final boolean toastSuccess = success; + runOnUiThread(() -> { + if (toastMessage != null) { + Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); + } - if (toastSuccess) { - // Open the app list after a successful pairing attempt - doAppList(computer, true, false); - } - else { - // Start polling again if we're still in the foreground - startComputerUpdates(); - } - } - }); - } + if (toastSuccess) { + // Open the app list after a successful pairing attempt + doAppList(computer, true, false); + } else { + // Start polling again if we're still in the foreground + startComputerUpdates(); + } + }); }).start(); } @@ -508,25 +955,17 @@ private void doWakeOnLan(final ComputerDetails computer) { return; } - new Thread(new Runnable() { - @Override - public void run() { - String message; - try { - WakeOnLanSender.sendWolPacket(computer); - message = getResources().getString(R.string.wol_waking_msg); - } catch (IOException e) { - message = getResources().getString(R.string.wol_fail); - } - - final String toastMessage = message; - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } - }); + new Thread(() -> { + String message; + try { + WakeOnLanSender.sendWolPacket(computer); + message = getResources().getString(R.string.wol_waking_msg); + } catch (IOException e) { + message = getResources().getString(R.string.wol_fail); } + + final String toastMessage = message; + runOnUiThread(() -> Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show()); }).start(); } @@ -541,44 +980,36 @@ private void doUnpair(final ComputerDetails computer) { } Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - try { - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), - computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, - PlatformBinding.getCryptoProvider(PcView.this)); - if (httpConn.getPairState() == PairingManager.PairState.PAIRED) { - httpConn.unpair(); - if (httpConn.getPairState() == PairingManager.PairState.NOT_PAIRED) { - message = getResources().getString(R.string.unpair_success); - } - else { - message = getResources().getString(R.string.unpair_fail); - } - } - else { - message = getResources().getString(R.string.unpair_error); - } - } catch (UnknownHostException e) { - message = getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = getResources().getString(R.string.error_404); - } catch (XmlPullParserException | IOException e) { - message = e.getMessage(); - e.printStackTrace(); + new Thread(() -> { + String message; + try { + NvHTTP httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), + computer.httpsPort, managerBinder.getUniqueId(), clientName, computer.serverCert, + PlatformBinding.getCryptoProvider(PcView.this)); + + PairState pairState = httpConn.getPairState(); + if (pairState == PairState.PAIRED) { + httpConn.unpair(); + message = httpConn.getPairState() == PairState.NOT_PAIRED + ? getResources().getString(R.string.unpair_success) + : getResources().getString(R.string.unpair_fail); + } else { + message = getResources().getString(R.string.unpair_error); } - - final String toastMessage = message; - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } - }); + } catch (UnknownHostException e) { + message = getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = getResources().getString(R.string.error_404); + } catch (XmlPullParserException | IOException e) { + message = e.getMessage(); + e.printStackTrace(); + } catch (InterruptedException e) { + // Thread was interrupted during unpair + message = getResources().getString(R.string.error_interrupted); } + + final String toastMessage = message; + runOnUiThread(() -> Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show()); }).start(); } @@ -597,13 +1028,47 @@ private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean sh i.putExtra(AppView.UUID_EXTRA, computer.uuid); i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired); i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames); + + // 如果activeAddress与默认地址不同,说明用户选择了特定地址,需要传递这个信息 + if (computer.activeAddress != null) { + i.putExtra(AppView.SELECTED_ADDRESS_EXTRA, computer.activeAddress.address); + i.putExtra(AppView.SELECTED_PORT_EXTRA, computer.activeAddress.port); + } + startActivity(i); } + /** + * 显示地址选择对话框 + */ + private void showAddressSelectionDialog(ComputerDetails computer) { + AddressSelectionDialog dialog = new AddressSelectionDialog(this, computer, address -> { + // 使用选中的地址创建临时ComputerDetails对象 + ComputerDetails tempComputer = new ComputerDetails(computer); + tempComputer.activeAddress = address; + + // 使用选中的地址进入应用列表 + doAppList(tempComputer, false, false); + }); + + dialog.show(); + } + @Override public boolean onContextItemSelected(MenuItem item) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); + int position = -1; + ContextMenuInfo menuInfo = item.getMenuInfo(); + if (menuInfo instanceof AdapterContextMenuInfo) { + position = ((AdapterContextMenuInfo) menuInfo).position; + } + + if (position < 0) { + position = this.selectedPosition; + } + + if (position < 0) return super.onContextItemSelected(item); + + final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(position); switch (item.getItemId()) { case PAIR_ID: doPair(computer.details); @@ -622,15 +1087,12 @@ public boolean onContextItemSelected(MenuItem item) { LimeLog.info("Ignoring delete PC request from monkey"); return true; } - UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() { - @Override - public void run() { - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return; - } - removeComputer(computer.details); + UiHelper.displayDeletePcConfirmationDialog(this, computer.details, () -> { + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; } + removeComputer(computer.details); }, null); return true; @@ -644,7 +1106,14 @@ public void run() { return true; } - ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder); + // 尝试获取完整的NvApp对象(包括cmdList) + NvApp actualApp = getNvAppById(computer.details.runningGameId, computer.details.uuid); + if (actualApp != null) { + ServerHelper.doStart(this, actualApp, computer.details, managerBinder); + } else { + // 如果找不到完整的应用信息,使用基本的NvApp对象作为备用 + ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder); + } return true; case QUIT_ID: @@ -654,23 +1123,45 @@ public void run() { } // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - ServerHelper.doQuit(PcView.this, computer.details, - new NvApp("app", 0, false), managerBinder, null); - } - }, null); + UiHelper.displayQuitConfirmationDialog(this, () -> ServerHelper.doQuit(PcView.this, computer.details, + new NvApp("app", 0, false), managerBinder, null), null); return true; + + case SLEEP_ID: + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return true; + } + ServerHelper.pcSleep(PcView.this, computer.details, managerBinder, null); + return true; + case VIEW_DETAILS_ID: - Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false); + Dialog.displayDetailsDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false); return true; case TEST_NETWORK_ID: ServerHelper.doNetworkTest(PcView.this); return true; + case IPERF3_TEST_ID: + try { + // 1. 直接在UI线程获取地址对象 (因为此操作不耗时) + ComputerDetails.AddressTuple addressTuple = ServerHelper.getCurrentAddressFromComputer(computer.details); + + // 2. 从对象中提取IP地址字符串 + String currentIp = addressTuple.address; + + // 3. 直接创建并显示对话框 + new Iperf3Tester(PcView.this, currentIp).show(); + + } catch (IOException e) { + // 捕获因 activeAddress 为 null 导致的异常 + e.printStackTrace(); + Toast.makeText(this, getResources().getString(R.string.unable_to_get_pc_address, e.getMessage()), Toast.LENGTH_LONG).show(); + } + return true; + case GAMESTREAM_EOL_ID: HelpLauncher.launchGameStreamEolFaq(PcView.this); return true; @@ -680,6 +1171,78 @@ public void run() { } } + /** + * 一键恢复上一次会话 + * 持续查找主机直到找到有运行游戏的主机为止 + */ + private void restoreLastSession() { + if (managerBinder == null) { + Toast.makeText(this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; + } + + // 持续查找有运行游戏的在线主机 + ComputerDetails targetComputer = null; + for (int i = 0; i < pcGridAdapter.getCount(); i++) { + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i); + if (computer.details.state == ComputerDetails.State.ONLINE && + computer.details.pairState == PairState.PAIRED && + computer.details.runningGameId != 0) { + targetComputer = computer.details; + break; // 找到有运行游戏的主机就停止查找 + } + } + + if (targetComputer == null) { + Toast.makeText(this, getResources().getString(R.string.no_online_computer_with_running_game), Toast.LENGTH_SHORT).show(); + return; + } + + // 恢复会话 + NvApp actualApp = getNvAppById(targetComputer.runningGameId, targetComputer.uuid); + if (actualApp != null) { + Toast.makeText(this, getResources().getString(R.string.restoring_session, targetComputer.name), Toast.LENGTH_SHORT).show(); + ServerHelper.doStart(this, actualApp, targetComputer, managerBinder); + } else { + // 使用基本的NvApp对象作为备用 + Toast.makeText(this, getResources().getString(R.string.restoring_session, targetComputer.name), Toast.LENGTH_SHORT).show(); + ServerHelper.doStart(this, new NvApp("app", targetComputer.runningGameId, false), targetComputer, managerBinder); + } + } + + /** + * 根据应用ID获取完整的NvApp对象(包括cmdList) + * @param appId 应用ID + * @param uuidString PC的UUID + * @return 完整的NvApp对象,如果找不到则返回null + */ + private NvApp getNvAppById(int appId, String uuidString) { + try { + // 首先尝试从缓存的应用列表中获取 + String rawAppList = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); + if (!rawAppList.isEmpty()) { + List applist = NvHTTP.getAppListByReader(new StringReader(rawAppList)); + for (NvApp app : applist) { + if (app.getAppId() == appId) { + // 保存这个应用信息到SharedPreferences,供下次使用 + AppCacheManager cacheManager = new AppCacheManager(this); + cacheManager.saveAppInfo(uuidString, app); + return app; + } + } + } + + // 如果在应用列表中找不到,尝试从SharedPreferences获取 + AppCacheManager cacheManager = new AppCacheManager(this); + return cacheManager.getAppInfo(uuidString, appId); + } catch (IOException | XmlPullParserException e) { + // 如果读取缓存失败,尝试从SharedPreferences获取 + e.printStackTrace(); + AppCacheManager cacheManager = new AppCacheManager(this); + return cacheManager.getAppInfo(uuidString, appId); + } + } + private void removeComputer(ComputerDetails details) { managerBinder.removeComputer(details); @@ -747,12 +1310,19 @@ public int getAdapterFragmentLayoutId() { } @Override - public void receiveAbsListView(AbsListView listView) { - listView.setAdapter(pcGridAdapter); - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView arg0, View arg1, int pos, - long id) { + public void receiveAbsListView(View view) { + // Generalized interface implementation + receiveAdapterView(view); + } + + public void receiveAdapterView(View view) { + if (view instanceof androidx.recyclerview.widget.RecyclerView) { + // Update selectionAnimator's RecyclerView and Adapter references + } + else if (view instanceof AbsListView) { + AbsListView listView = (AbsListView) view; + listView.setAdapter(pcGridAdapter); + listView.setOnItemClickListener((arg0, arg1, pos, id) -> { ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos); if (computer.details.state == ComputerDetails.State.UNKNOWN || computer.details.state == ComputerDetails.State.OFFLINE) { @@ -762,12 +1332,17 @@ public void onItemClick(AdapterView arg0, View arg1, int pos, // Pair an unpaired machine by default doPair(computer.details); } else { - doAppList(computer.details, false, false); + // 检查是否有多个可用地址 + if (computer.details.hasMultipleAddresses()) { + showAddressSelectionDialog(computer.details); + } else { + doAppList(computer.details, false, false); + } } - } - }); - UiHelper.applyStatusBarPadding(listView); - registerForContextMenu(listView); + }); + UiHelper.applyStatusBarPadding(listView); + registerForContextMenu(listView); + } } public static class ComputerObject { @@ -785,4 +1360,220 @@ public String toString() { return details.name; } } + + @Override + public void hearShake() { + long currentTime = System.currentTimeMillis(); + + // Debounce: Check if enough time has passed since last shake + if (currentTime - lastShakeTime < SHAKE_DEBOUNCE_INTERVAL) { + long remainingSeconds = (SHAKE_DEBOUNCE_INTERVAL - (currentTime - lastShakeTime)) / 1000; + runOnUiThread(() -> + Toast.makeText(PcView.this, getResources().getString(R.string.please_wait_seconds, remainingSeconds), Toast.LENGTH_SHORT).show() + ); + return; + } + + // Check daily limit + if (!canRefreshToday()) { + runOnUiThread(() -> + Toast.makeText(PcView.this, getResources().getString(R.string.daily_limit_reached), Toast.LENGTH_LONG).show() + ); + return; + } + + lastShakeTime = currentTime; + + // Increment counter and get remaining + incrementRefreshCount(); + int remaining = getRemainingRefreshCount(); + + runOnUiThread(() -> { + String message = getResources().getString(R.string.refreshing_with_remaining, remaining); + Toast.makeText(PcView.this, message, Toast.LENGTH_SHORT).show(); + refreshBackgroundImage(true); + }); + } + + /** + * Get today's date string (YYYY-MM-DD) + */ + private String getTodayDateString() { + return new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US) + .format(new java.util.Date()); + } + + /** + * Check if user can refresh (within daily limit) + * @return true if can refresh, false if limit reached + */ + private boolean canRefreshToday() { + SharedPreferences prefs = getSharedPreferences(REFRESH_PREF_NAME, MODE_PRIVATE); + String today = getTodayDateString(); + String savedDate = prefs.getString(REFRESH_DATE_KEY, ""); + int count = prefs.getInt(REFRESH_COUNT_KEY, 0); + + // New day, reset counter + if (!today.equals(savedDate)) { + prefs.edit() + .putString(REFRESH_DATE_KEY, today) + .putInt(REFRESH_COUNT_KEY, 0) + .apply(); + return true; + } + + // Check if within limit + return count < MAX_DAILY_REFRESH; + } + + /** + * Get remaining refresh count for today + */ + private int getRemainingRefreshCount() { + SharedPreferences prefs = getSharedPreferences(REFRESH_PREF_NAME, MODE_PRIVATE); + String today = getTodayDateString(); + String savedDate = prefs.getString(REFRESH_DATE_KEY, ""); + int count = prefs.getInt(REFRESH_COUNT_KEY, 0); + + // New day + if (!today.equals(savedDate)) { + return MAX_DAILY_REFRESH; + } + + return Math.max(0, MAX_DAILY_REFRESH - count); + } + + /** + * Increment refresh count + */ + private void incrementRefreshCount() { + SharedPreferences prefs = getSharedPreferences(REFRESH_PREF_NAME, MODE_PRIVATE); + String today = getTodayDateString(); + String savedDate = prefs.getString(REFRESH_DATE_KEY, ""); + int count = prefs.getInt(REFRESH_COUNT_KEY, 0); + + // Ensure date is today + if (!today.equals(savedDate)) { + count = 0; + } + + prefs.edit() + .putString(REFRESH_DATE_KEY, today) + .putInt(REFRESH_COUNT_KEY, count + 1) + .apply(); + } + + /** + * Refresh background image + */ + private void refreshBackgroundImage(boolean isFromShake) { + ImageView imageView = findViewById(R.id.pcBackgroundImage); + if (imageView == null) return; + + String imageUrl = getBackgroundImageUrl(); + + bitmapLruCache.remove(imageUrl); + + // Reload the image in a background thread + new Thread(() -> { + try { + // 将 imageUrl 转换为可被 Glide 正确识别的对象 + Object glideLoadTarget; + if (imageUrl.startsWith("http")) { + // URL 直接使用 + glideLoadTarget = imageUrl; + } else if (new File(imageUrl).exists()) { + // 本地文件路径,转换为 File 对象 + glideLoadTarget = new File(imageUrl); + } else { + // 文件不存在或无效,使用默认 URL + glideLoadTarget = "https://img-api.pipw.top"; + } + + final Bitmap bitmap = Glide.with(PcView.this) + .asBitmap() + .load(glideLoadTarget) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit() + .get(); + + if (bitmap != null) { + bitmapLruCache.put(imageUrl, bitmap); + runOnUiThread(() -> { + Glide.with(PcView.this) + .load(bitmap) + .apply(RequestOptions.bitmapTransform(new BlurTransformation(2, 3))) + .transform(new ColorFilterTransformation(Color.argb(120, 0, 0, 0))) + .into(imageView); + if (isFromShake) { + int remaining = getRemainingRefreshCount(); + String message = getResources().getString(R.string.background_refreshed_with_remaining, remaining); + Toast.makeText(PcView.this, message, Toast.LENGTH_SHORT).show(); + } + }); + } else { + runOnUiThread(() -> Toast.makeText(PcView.this, getResources().getString(R.string.refresh_failed_please_retry), Toast.LENGTH_SHORT).show()); + } + } catch (Exception e) { + e.printStackTrace(); + runOnUiThread(() -> Toast.makeText(PcView.this, getResources().getString(R.string.refresh_failed_with_error, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }).start(); + } + + /**************** + * + * 发起添加群流程。群号:第四串流基地(460965258) 的 key 为: JfhuyTDZFsHrOXaWEEX6YGH9FHh3xGzR + * 调用 joinQQGroup(JfhuyTDZFsHrOXaWEEX6YGH9FHh3xGzR) 即可发起手Q客户端申请加群 第四串流基地(460965258) + * + * @param key 由官网生成的key + * @return 返回true表示呼起手Q成功,返回false表示呼起失败 + ******************/ + public boolean joinQQGroup(String key) { + Intent intent = new Intent(); + intent.setData(Uri.parse("mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26jump_from%3Dwebapi%26k%3D" + key)); + // 此Flag可根据具体产品需要自定义,如设置,则在加群界面按返回,返回手Q主界面,不设置,按返回会返回到呼起产品界面 //intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + startActivity(intent); + return true; + } catch (Exception e) { + // 未安装手Q或安装的版本不支持 + return false; + } + } + + /** + * 显示集成了状态显示和配置编辑的 EasyTier 控制面板。 + */ + private void showEasyTierControlDialog() { + if (easyTierController != null) { + easyTierController.showControlDialog(); + } + } + + + // VPN 权限请求和结果处理逻辑 + /** + * 检查并请求 VPN 权限。 + */ + @Override + public void requestVpnPermission() { + Intent intent = VpnService.prepare(this); + if (intent != null) { + startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE); + } else { + onActivityResult(VPN_PERMISSION_REQUEST_CODE, RESULT_OK, null); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == VPN_PERMISSION_REQUEST_CODE) { + if (easyTierController != null) { + easyTierController.handleVpnPermissionResult(resultCode); + } + } + } } diff --git a/app/src/main/java/com/limelight/PerformanceOverlayManager.java b/app/src/main/java/com/limelight/PerformanceOverlayManager.java new file mode 100644 index 0000000000..fe09f34381 --- /dev/null +++ b/app/src/main/java/com/limelight/PerformanceOverlayManager.java @@ -0,0 +1,1468 @@ +package com.limelight; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.net.TrafficStats; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import com.limelight.binding.video.PerformanceInfo; +import com.limelight.preferences.PerfOverlayDisplayItemsPreference; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.StreamView; +import com.limelight.utils.NetHelper; +import com.limelight.utils.MoonPhaseUtils; +import com.limelight.utils.UiHelper; + +public class PerformanceOverlayManager { + + private final Activity activity; + private final PreferenceConfiguration prefConfig; + + private LinearLayout performanceOverlayView; + private StreamView streamView; + + private TextView perfResView; + private TextView perfDecoderView; + private TextView perfRenderFpsView; + private TextView networkLatencyView; + private TextView decodeLatencyView; + private TextView hostLatencyView; + private TextView packetLossView; + + private int requestedPerformanceOverlayVisibility = View.GONE; + private boolean hasShownPerfOverlay = false; // 跟踪性能覆盖层是否已经显示过 + + // 性能覆盖层拖动相关 + private boolean isDraggingPerfOverlay = false; + private float perfOverlayStartX, perfOverlayStartY; + private float perfOverlayDeltaX, perfOverlayDeltaY; + private static final int SNAP_THRESHOLD = 100; // 吸附阈值(像素) + + // 点击检测相关 + private static final int CLICK_THRESHOLD = 10; // 点击阈值(像素) + private static final int DOUBLE_CLICK_TIMEOUT = 300; // 双击超时时间(毫秒) + private long clickStartTime = 0; + private float clickStartX, clickStartY; // 记录点击开始位置 + private long lastClickTime = 0; // 上次点击时间 + private boolean isDoubleClickHandled = false; // 标记双击是否已被处理 + + // 8个吸附位置的枚举 + private enum SnapPosition { + TOP_LEFT, TOP_CENTER, TOP_RIGHT, + CENTER_LEFT, CENTER_RIGHT, + BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT + } + + // 计算带宽用 + private long previousTimeMillis = 0; + private long previousRxBytes = 0; + private String lastValidBandwidth = "N/A"; + + // 月相缓存 + private String currentMoonPhaseIcon = "🌙"; + private int lastCalculatedDay = -1; + + // 当前性能信息缓存 + private PerformanceInfo currentPerformanceInfo; + + /** + * 性能项目枚举 - 统一管理所有性能指标 + */ + private enum PerformanceItem { + RESOLUTION(R.id.perfRes, "resolution", "perfResView"), + DECODER(R.id.perfDecoder, "decoder", "perfDecoderView"), + RENDER_FPS(R.id.perfRenderFps, "render_fps", "perfRenderFpsView"), + PACKET_LOSS(R.id.perfPacketLoss, "packet_loss", "packetLossView"), + NETWORK_LATENCY(R.id.perfNetworkLatency, "network_latency", "networkLatencyView"), + DECODE_LATENCY(R.id.perfDecodeLatency, "decode_latency", "decodeLatencyView"), + HOST_LATENCY(R.id.perfHostLatency, "host_latency", "hostLatencyView"), + BATTERY(R.id.perfBattery, "battery", "perfBatteryView"); + + final int viewId; + final String preferenceKey; + final String fieldName; + + PerformanceItem(int viewId, String preferenceKey, String fieldName) { + this.viewId = viewId; + this.preferenceKey = preferenceKey; + this.fieldName = fieldName; + } + } + + /** + * 性能项目信息类 - 包含View引用和相关信息 + */ + private static class PerformanceItemInfo { + final PerformanceItem item; + final TextView view; + final Runnable infoMethod; + + PerformanceItemInfo(PerformanceItem item, TextView view, Runnable infoMethod) { + this.item = item; + this.view = view; + this.infoMethod = infoMethod; + } + + boolean isVisible() { + return view != null && view.getVisibility() == View.VISIBLE; + } + } + + // 性能项目信息数组 + private PerformanceItemInfo[] performanceItems; + + // 解码器类型映射表 + private static final Map DECODER_TYPE_MAP = new HashMap<>(); + + static { + // 初始化解码器类型映射 + DECODER_TYPE_MAP.put("avc", new DecoderTypeInfo("H.264/AVC", "AVC")); + DECODER_TYPE_MAP.put("h264", new DecoderTypeInfo("H.264/AVC", "AVC")); + DECODER_TYPE_MAP.put("hevc", new DecoderTypeInfo("H.265/HEVC", "HEVC")); + DECODER_TYPE_MAP.put("h265", new DecoderTypeInfo("H.265/HEVC", "HEVC")); + DECODER_TYPE_MAP.put("av1", new DecoderTypeInfo("AV1", "AV1")); + DECODER_TYPE_MAP.put("vp9", new DecoderTypeInfo("VP9", "VP9")); + DECODER_TYPE_MAP.put("vp8", new DecoderTypeInfo("VP8", "VP8")); + } + + // 解码器类型信息类 + private static class DecoderTypeInfo { + final String fullName; + final String shortName; + + DecoderTypeInfo(String fullName, String shortName) { + this.fullName = fullName; + this.shortName = shortName; + } + } + + public PerformanceOverlayManager(Activity activity, PreferenceConfiguration prefConfig) { + this.activity = activity; + this.prefConfig = prefConfig; + } + + /** + * 初始化性能覆盖层 + */ + public void initialize() { + performanceOverlayView = activity.findViewById(R.id.performanceOverlay); + streamView = activity.findViewById(R.id.surfaceView); + + // 初始化性能项目信息 + initializePerformanceItems(); + + // 加载保存的布局方向设置 + loadLayoutOrientation(); + + // Check if the user has enabled performance stats overlay + if (prefConfig.enablePerfOverlay) { + requestedPerformanceOverlayVisibility = View.VISIBLE; + // 初始状态下设置为不可见,等待性能数据更新时再显示 + if (performanceOverlayView != null) { + performanceOverlayView.setVisibility(View.GONE); + performanceOverlayView.setAlpha(0.0f); + } + } + // 配置性能覆盖层的方向和位置 + configurePerformanceOverlay(); + + // 强制应用一次当前的配置(为了处理初始就是 锁定 的情况) + setupPerformanceOverlayDragging(); + } + + /** + * 初始化性能项目信息数组 + */ + private void initializePerformanceItems() { + performanceItems = new PerformanceItemInfo[PerformanceItem.values().length]; + + for (int i = 0; i < PerformanceItem.values().length; i++) { + PerformanceItem item = PerformanceItem.values()[i]; + TextView view = activity.findViewById(item.viewId); + Runnable infoMethod = getInfoMethodForItem(item); + + performanceItems[i] = new PerformanceItemInfo(item, view, infoMethod); + } + } + + /** + * 根据性能项目获取对应的信息显示方法 + */ + private Runnable getInfoMethodForItem(PerformanceItem item) { + switch (item) { + case RESOLUTION: return this::showResolutionInfo; + case DECODER: return this::showDecoderInfo; + case RENDER_FPS: return this::showFpsInfo; + case PACKET_LOSS: return this::showPacketLossInfo; + case NETWORK_LATENCY: return this::showNetworkLatencyInfo; + case DECODE_LATENCY: return this::showDecodeLatencyInfo; + case HOST_LATENCY: return this::showHostLatencyInfo; + case BATTERY: return this::showBatteryInfo; + default: return this::showMoonPhaseInfo; + } + } + + /** 隐藏覆盖层(立即) */ + public void hideOverlayImmediate() { + if (performanceOverlayView != null) { + performanceOverlayView.setVisibility(View.GONE); + } + } + + /** 应用当前请求的可见性到视图 */ + public void applyRequestedVisibility() { + if (performanceOverlayView != null) { + performanceOverlayView.setVisibility(requestedPerformanceOverlayVisibility); + } + } + + /** 覆盖层是否可见 */ + public boolean isPerfOverlayVisible() { + return requestedPerformanceOverlayVisibility == View.VISIBLE; + } + + /** 切换覆盖层显示/隐藏 */ + public void togglePerformanceOverlay() { + if (performanceOverlayView == null) { + return; + } + + if (requestedPerformanceOverlayVisibility == View.VISIBLE) { + // 隐藏性能覆盖层 - 使用淡出动画 + requestedPerformanceOverlayVisibility = View.GONE; + hasShownPerfOverlay = false; // 重置显示状态 + Animation fadeOutAnimation = AnimationUtils.loadAnimation(activity, R.anim.perf_overlay_fadeout); + performanceOverlayView.startAnimation(fadeOutAnimation); + fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + performanceOverlayView.setVisibility(View.GONE); + performanceOverlayView.setAlpha(0.0f); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + } else { + requestedPerformanceOverlayVisibility = View.VISIBLE; + hasShownPerfOverlay = true; // 标记为已显示,避免重复动画 + performanceOverlayView.setVisibility(View.VISIBLE); + performanceOverlayView.setAlpha(1.0f); + } + } + + public void applyOverlayState() { + if (performanceOverlayView == null) return; + + // ------------------------------------------------ + // 情况 A: 需要隐藏 (enablePerfOverlay == false) + // ------------------------------------------------ + if (!prefConfig.enablePerfOverlay) { + requestedPerformanceOverlayVisibility = View.GONE; + hasShownPerfOverlay = false; + + // 执行淡出动画 + Animation fadeOutAnimation = AnimationUtils.loadAnimation(activity, R.anim.perf_overlay_fadeout); + performanceOverlayView.startAnimation(fadeOutAnimation); + fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + @Override + public void onAnimationEnd(Animation animation) { + performanceOverlayView.setVisibility(View.GONE); + performanceOverlayView.setAlpha(0.0f); + } + @Override + public void onAnimationRepeat(Animation animation) {} + }); + return; + } + + // ------------------------------------------------ + // 情况 B: 需要显示 (enablePerfOverlay == true) + // ------------------------------------------------ + requestedPerformanceOverlayVisibility = View.VISIBLE; + + // 如果之前是隐藏的,现在显示出来 + if (performanceOverlayView.getVisibility() != View.VISIBLE || performanceOverlayView.getAlpha() < 1.0f) { + performanceOverlayView.setVisibility(View.VISIBLE); + performanceOverlayView.setAlpha(1.0f); + hasShownPerfOverlay = true; + } + + setupPerformanceOverlayDragging(); + } + + /** 刷新覆盖层配置(显示项与对齐) */ + public void refreshPerformanceOverlayConfig() { + if (performanceOverlayView != null && requestedPerformanceOverlayVisibility == View.VISIBLE) { + configureDisplayItems(); + configureTextAlignment(); + } + } + + /** + * 更新性能信息(带宽、丢包、延迟等)并刷新文案 + */ + public void updatePerformanceInfo(final PerformanceInfo performanceInfo) { + // 保存当前性能信息,用于弹窗显示 + currentPerformanceInfo = performanceInfo; + + // 计算带宽信息 + updateBandwidthInfo(performanceInfo); + + // 在UI线程中更新显示 + activity.runOnUiThread(() -> { + showOverlayIfNeeded(); + updatePerformanceViewsWithStyledText(performanceInfo); + // 单独更新电量信息(不需要performanceInfo参数) + updateBatteryDisplay(); + }); + } + + /** + * 更新电量显示(定时调用) + */ + private void updateBatteryDisplay() { + TextView batteryView = getPerformanceItemView(PerformanceItem.BATTERY); + if (batteryView != null && batteryView.getVisibility() == View.VISIBLE) { + updateBatteryText(batteryView); + } + } + + /** + * 更新带宽信息 + */ + private void updateBandwidthInfo(PerformanceInfo performanceInfo) { + long currentRxBytes = TrafficStats.getTotalRxBytes(); + long timeMillis = System.currentTimeMillis(); + long timeMillisInterval = timeMillis - previousTimeMillis; + + String calculatedBandwidth = NetHelper.calculateBandwidth(currentRxBytes, previousRxBytes, timeMillisInterval); + + // 如果时间间隔过长,使用上次有效带宽 + if (timeMillisInterval > 5000) { + performanceInfo.bandWidth = lastValidBandwidth != null ? lastValidBandwidth : "N/A"; + previousTimeMillis = timeMillis; + previousRxBytes = currentRxBytes; + return; + } + + // 检查计算出的带宽是否可靠 + if (!calculatedBandwidth.equals("0 K/s")) { + performanceInfo.bandWidth = calculatedBandwidth; + lastValidBandwidth = calculatedBandwidth; + // 只有带宽数据可靠时才更新时间戳 + previousTimeMillis = timeMillis; + } else { + // 带宽数据不可靠,使用上次有效值 + performanceInfo.bandWidth = lastValidBandwidth != null ? lastValidBandwidth : "N/A"; + } + + // 无论带宽数据是否可靠,都要更新 previousRxBytes 用于下次计算 + previousRxBytes = currentRxBytes; + } + + /** + * 构建解码器信息字符串 + */ + private String buildDecoderInfo(PerformanceInfo performanceInfo) { + DecoderTypeInfo decoderTypeInfo = getDecoderTypeInfo(performanceInfo.decoder); + String decoderInfo = decoderTypeInfo.shortName; + + // 基于实际HDR激活状态而不是配置 + if (performanceInfo.isHdrActive) { + decoderInfo += " HDR"; + } + return decoderInfo; + } + + /** + * 获取当前月相图标 + * 基于真实的天文月相计算,带缓存优化 + */ + private String getCurrentMoonPhaseIcon() { + Calendar now = Calendar.getInstance(TimeZone.getDefault()); + int currentDay = now.get(Calendar.DAY_OF_YEAR); + + // 如果是同一天,使用缓存的图标 + if (currentDay == lastCalculatedDay) { + return currentMoonPhaseIcon; + } + + // 计算月相 + currentMoonPhaseIcon = MoonPhaseUtils.getMoonPhaseIcon(MoonPhaseUtils.getCurrentMoonPhase()); + lastCalculatedDay = currentDay; + + return currentMoonPhaseIcon; + } + + + /** + * 如果需要则显示覆盖层 + */ + private void showOverlayIfNeeded() { + // 只有当 enabled 为 true 且还没显示过时才执行 + if (prefConfig.enablePerfOverlay && !hasShownPerfOverlay && performanceOverlayView != null) { + performanceOverlayView.setVisibility(View.VISIBLE); + performanceOverlayView.setAlpha(1.0f); + hasShownPerfOverlay = true; + // 确保由于自动显示时,触摸状态是正确的 + setupPerformanceOverlayDragging(); + } + } + + private SpannableString createStyledText(String icon, String value, String unit, Integer valueColor) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + + // 添加图标(使用标题样式) + if (icon != null && !icon.isEmpty()) { + int iconStart = builder.length(); + builder.append(icon); + builder.setSpan(new StyleSpan(Typeface.BOLD), iconStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new RelativeSizeSpan(1.1f), iconStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append(" "); + } + + // 添加数值(使用中等粗细样式) + if (value != null && !value.isEmpty()) { + int valueStart = builder.length(); + builder.append(value); + builder.setSpan(new TypefaceSpan("sans-serif-medium"), valueStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new RelativeSizeSpan(1.0f), valueStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (valueColor != null) { + builder.setSpan(new ForegroundColorSpan(valueColor), valueStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + // 添加单位(使用细体样式) + if (unit != null && !unit.isEmpty()) { + builder.append(" "); + int unitStart = builder.length(); + builder.append(unit); + builder.setSpan(new TypefaceSpan("sans-serif-light"), unitStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new RelativeSizeSpan(0.9f), unitStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new ForegroundColorSpan(0xCCFFFFFF), unitStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + return new SpannableString(builder); + } + + private void updatePerformanceViewsWithStyledText(PerformanceInfo performanceInfo) { + // 更新所有可见的性能项目 + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.isVisible()) { + updatePerformanceItemText(itemInfo, performanceInfo); + } + } + + configureTextAlignment(); + } + + /** + * 更新单个性能项目的文本 + */ + private void updatePerformanceItemText(PerformanceItemInfo itemInfo, PerformanceInfo performanceInfo) { + switch (itemInfo.item) { + case RESOLUTION: + updateResolutionText(itemInfo.view, performanceInfo); + break; + case DECODER: + updateDecoderText(itemInfo.view, performanceInfo); + break; + case RENDER_FPS: + updateRenderFpsText(itemInfo.view, performanceInfo); + break; + case PACKET_LOSS: + updatePacketLossText(itemInfo.view, performanceInfo); + break; + case NETWORK_LATENCY: + updateNetworkLatencyText(itemInfo.view, performanceInfo); + break; + case DECODE_LATENCY: + updateDecodeLatencyText(itemInfo.view, performanceInfo); + break; + case HOST_LATENCY: + updateHostLatencyText(itemInfo.view, performanceInfo); + break; + case BATTERY: + updateBatteryText(itemInfo.view); + break; + } + } + + private void updateResolutionText(TextView view, PerformanceInfo performanceInfo) { + @SuppressLint("DefaultLocale") String resValue = String.format("%dx%d@%.0f", + performanceInfo.initialWidth, performanceInfo.initialHeight, performanceInfo.totalFps); + String moonIcon = getCurrentMoonPhaseIcon(); + view.setText(createStyledText(moonIcon, resValue, "", null)); + } + + private void updateDecoderText(TextView view, PerformanceInfo performanceInfo) { + String decoderInfo = buildDecoderInfo(performanceInfo); + view.setText(createStyledText("", decoderInfo, "", null)); + view.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD)); + } + + private void updateRenderFpsText(TextView view, PerformanceInfo performanceInfo) { + @SuppressLint("DefaultLocale") String fpsValue = String.format("Rx %.0f / Rd %.0f", + performanceInfo.receivedFps, performanceInfo.renderedFps); + view.setText(createStyledText("", fpsValue, "FPS", 0xFF0DDAF4)); + } + + private void updatePacketLossText(TextView view, PerformanceInfo performanceInfo) { + @SuppressLint("DefaultLocale") String lossValue = String.format("%.2f", performanceInfo.lostFrameRate); + int lossColor = performanceInfo.lostFrameRate < 5.0f ? 0xFF7D9D7D : 0xFFB57D7D; + view.setText(createStyledText("📶", lossValue, "%", lossColor)); + } + + private void updateNetworkLatencyText(TextView view, PerformanceInfo performanceInfo) { + boolean showPacketLoss = getPerformanceItemView(PerformanceItem.PACKET_LOSS) != null && + getPerformanceItemView(PerformanceItem.PACKET_LOSS).getVisibility() == View.VISIBLE; + String icon = showPacketLoss ? "" : "🌐"; + @SuppressLint("DefaultLocale") String bandwidthAndLatency = String.format("%s %d ± %d", + performanceInfo.bandWidth, + (int) (performanceInfo.rttInfo >> 32), + (int) performanceInfo.rttInfo); + view.setText(createStyledText(icon, bandwidthAndLatency, "ms", 0xFFBCEDD3)); + } + + private void updateDecodeLatencyText(TextView view, PerformanceInfo performanceInfo) { + String icon = performanceInfo.decodeTimeMs < 15 ? "⏱️" : "🥵"; + @SuppressLint("DefaultLocale") String latencyValue = String.format("%.2f", performanceInfo.decodeTimeMs); + view.setText(createStyledText(icon, latencyValue, "ms", 0xFFD597E3)); + } + + private void updateHostLatencyText(TextView view, PerformanceInfo performanceInfo) { + if (performanceInfo.framesWithHostProcessingLatency > 0) { + @SuppressLint("DefaultLocale") String latencyValue = String.format("%.1f", performanceInfo.aveHostProcessingLatency); + view.setText(createStyledText("🖥", latencyValue, "ms", 0xFF009688)); + } else { + view.setText(createStyledText("🧋", "Ver.V+", "", 0xFF009688)); + } + } + + private void updateBatteryText(TextView view) { + int batteryLevel = UiHelper.getBatteryLevel(activity); + String batteryText; + int batteryColor; + + if (batteryLevel > 50) { + batteryText = String.valueOf(batteryLevel); + batteryColor = 0xFF90EE90; // 浅绿色 - 电量充足 + } else if (batteryLevel > 20) { + batteryText = String.valueOf(batteryLevel); + batteryColor = 0xFFFFA500; // 橙色 - 电量偏低 + } else { + batteryText = String.valueOf(batteryLevel); + batteryColor = 0xFFFF6B6B; // 红色 - 电量严重不足 + } + + view.setText(createStyledText("🔋", batteryText, "%", batteryColor)); + } + + /** + * 显示电池信息对话框 + */ + private void showBatteryInfo() { + int batteryLevel = UiHelper.getBatteryLevel(activity); + String batteryStatus; + int statusResId; + + if (batteryLevel > 50) { + statusResId = R.string.perf_battery_status_sufficient; + } else if (batteryLevel > 20) { + statusResId = R.string.perf_battery_status_low; + } else { + statusResId = R.string.perf_battery_status_critical; + } + + batteryStatus = activity.getString(statusResId); + + String batteryInfo = activity.getString(R.string.perf_battery_info_content, batteryLevel, batteryStatus); + + showInfoDialog(activity.getString(R.string.perf_battery_info_title), batteryInfo); + } + + /** + * 获取指定性能项目的View + */ + private TextView getPerformanceItemView(PerformanceItem item) { + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.item == item) { + return itemInfo.view; + } + } + return null; + } + + private void configurePerformanceOverlay() { + if (performanceOverlayView == null) { + return; + } + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) performanceOverlayView.getLayoutParams(); + + // 设置方向 + if (prefConfig.perfOverlayOrientation == PreferenceConfiguration.PerfOverlayOrientation.VERTICAL) { + performanceOverlayView.setOrientation(LinearLayout.VERTICAL); + performanceOverlayView.setBackgroundColor(activity.getResources().getColor(R.color.overlay_background_vertical)); + } else { + performanceOverlayView.setOrientation(LinearLayout.HORIZONTAL); + performanceOverlayView.setBackgroundColor(activity.getResources().getColor(R.color.overlay_background_horizontal)); + } + + // 根据用户配置显示/隐藏特定的性能指标 + configureDisplayItems(); + + // 从SharedPreferences读取保存的位置 + SharedPreferences prefs = activity.getSharedPreferences("performance_overlay", Activity.MODE_PRIVATE); + boolean hasCustomPosition = prefs.getBoolean("has_custom_position", false); + + if (hasCustomPosition) { + // 使用自定义位置 + layoutParams.gravity = Gravity.NO_GRAVITY; + layoutParams.leftMargin = prefs.getInt("left_margin", 0); + layoutParams.topMargin = prefs.getInt("top_margin", 0); + } else { + // 使用预设位置 + switch (prefConfig.perfOverlayPosition) { + case TOP: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; + break; + case BOTTOM: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM; + break; + case TOP_LEFT: + layoutParams.gravity = Gravity.LEFT | Gravity.TOP; + break; + case TOP_RIGHT: + layoutParams.gravity = Gravity.RIGHT | Gravity.TOP; + break; + case BOTTOM_LEFT: + layoutParams.gravity = Gravity.LEFT | Gravity.BOTTOM; + break; + case BOTTOM_RIGHT: + layoutParams.gravity = Gravity.RIGHT | Gravity.BOTTOM; + break; + default: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; + break; + } + // 清除自定义边距 + layoutParams.leftMargin = 0; + layoutParams.topMargin = 0; + } + layoutParams.rightMargin = 0; + layoutParams.bottomMargin = 0; + + performanceOverlayView.setLayoutParams(layoutParams); + + // 根据位置和方向调整文字对齐(延迟执行确保View已测量) + performanceOverlayView.post(this::configureTextAlignment); + + // 设置拖动监听器 + setupPerformanceOverlayDragging(); + } + + private void configureDisplayItems() { + // 根据用户配置显示/隐藏特定的性能指标 + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.view != null) { + boolean isEnabled = PerfOverlayDisplayItemsPreference.isItemEnabled(activity, itemInfo.item.preferenceKey); + itemInfo.view.setVisibility(isEnabled ? View.VISIBLE : View.GONE); + } + } + } + + private void configureTextAlignment() { + if (performanceOverlayView == null) { + return; + } + + boolean isVertical = prefConfig.perfOverlayOrientation == PreferenceConfiguration.PerfOverlayOrientation.VERTICAL; + boolean isRightSide = determineRightSidePosition(isVertical); + + // 只在垂直布局且位置在右侧时,将文字设置为右对齐 + // 注意:需要保持 center_vertical 以确保文字垂直居中 + int gravity = (isVertical && isRightSide) ? + (android.view.Gravity.CENTER_VERTICAL | android.view.Gravity.END) : + (android.view.Gravity.CENTER_VERTICAL | android.view.Gravity.START); + + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.isVisible()) { + configureTextViewStyle(itemInfo.view, gravity, isVertical); + } + } + } + + /** + * 判断性能覆盖层是否位于右侧 + */ + private boolean determineRightSidePosition(boolean isVertical) { + SharedPreferences prefs = activity.getSharedPreferences("performance_overlay", Activity.MODE_PRIVATE); + boolean hasCustomPosition = prefs.getBoolean("has_custom_position", false); + + if (hasCustomPosition) { + // 自定义位置:检查是否接近右侧 + int[] viewDimensions = getViewDimensions(performanceOverlayView); + int viewWidth = viewDimensions[0]; + int leftMargin = prefs.getInt("left_margin", 0); + + // 如果距离右边缘小于屏幕宽度的1/3,认为是右侧 + return (leftMargin + viewWidth) > (streamView.getWidth() * 2 / 3); + } else { + // 预设位置:检查是否为右侧位置 + return prefConfig.perfOverlayPosition == PreferenceConfiguration.PerfOverlayPosition.TOP_RIGHT || + prefConfig.perfOverlayPosition == PreferenceConfiguration.PerfOverlayPosition.BOTTOM_RIGHT; + } + } + + private void configureTextViewStyle(TextView textView, int gravity, boolean isVertical) { + // 设置文字对齐方式 + textView.setGravity(gravity); + + // 设置基础字体属性 + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setLetterSpacing(0.02f); + textView.setIncludeFontPadding(false); + + // 根据布局方向设置阴影效果 + if (isVertical) { + // 竖屏时添加字体阴影,提高可读性 + textView.setShadowLayer(2.5f, 1.0f, 1.0f, 0x80000000); + } else { + // 横屏时使用较轻的阴影 + textView.setShadowLayer(1.5f, 0.5f, 0.5f, 0x60000000); + } + + // 根据TextView的ID设置特定的字体样式 + int viewId = textView.getId(); + if (viewId == R.id.perfRes) { + textView.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD)); + textView.setTextSize(11); + } else if (viewId == R.id.perfDecoder) { + textView.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD)); + textView.setTextSize(10); + } else if (viewId == R.id.perfRenderFps) { + textView.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + textView.setTextSize(10); + } else if (viewId == R.id.perfPacketLoss) { + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setTextSize(10); + } else if (viewId == R.id.perfNetworkLatency) { + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setTextSize(10); + } else if (viewId == R.id.perfDecodeLatency) { + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setTextSize(10); + } else if (viewId == R.id.perfHostLatency) { + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setTextSize(10); + } else if (viewId == R.id.perfBattery) { + textView.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + textView.setTextSize(10); + } + } + + @SuppressLint("ClickableViewAccessibility") + private void setupPerformanceOverlayDragging() { + if (performanceOverlayView == null) { + return; + } + + // 检查新的参数:是否锁定? + if (prefConfig.perfOverlayLocked) { + // ========================= + // 模式:固定 (Locked) + // ========================= + // 1. 移除监听器 + performanceOverlayView.setOnTouchListener(null); + + // 2. 设置不可点击,让事件穿透到底层游戏画面 + performanceOverlayView.setClickable(false); + performanceOverlayView.setFocusable(false); + performanceOverlayView.setLongClickable(false); + + } else { + // ========================= + // 模式:悬浮 (Floating) + // ========================= + // 1. 恢复可点击 + performanceOverlayView.setClickable(true); + performanceOverlayView.setFocusable(true); + performanceOverlayView.setLongClickable(true); + + // 2. 绑定正常的拖动/点击逻辑 + performanceOverlayView.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + return handleActionDown(v, event); + case MotionEvent.ACTION_MOVE: + return handleActionMove(v, event); + case MotionEvent.ACTION_UP: + return handleActionUp(v, event); + } + return false; + }); + } + } + /** + * 处理触摸按下事件 + */ + private boolean handleActionDown(View v, MotionEvent event) { + isDraggingPerfOverlay = true; + perfOverlayStartX = event.getRawX(); + perfOverlayStartY = event.getRawY(); + clickStartTime = System.currentTimeMillis(); + clickStartX = event.getX(); + clickStartY = event.getY(); + isDoubleClickHandled = false; + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) v.getLayoutParams(); + + // 如果使用预设位置(gravity不为NO_GRAVITY),需要转换为实际坐标 + if (layoutParams.gravity != Gravity.NO_GRAVITY) { + convertGravityToMargins(v, layoutParams); + } + + perfOverlayDeltaX = perfOverlayStartX - layoutParams.leftMargin; + perfOverlayDeltaY = perfOverlayStartY - layoutParams.topMargin; + + // 添加视觉反馈:降低透明度表示正在拖动 + applyDraggingVisualFeedback(v, true); + return true; + } + + /** + * 处理触摸移动事件 + */ + private boolean handleActionMove(View v, MotionEvent event) { + if (!isDraggingPerfOverlay) { + return false; + } + + // 获取父容器和View的尺寸 + int[] parentDimensions = getParentDimensions(v); + int[] viewDimensions = getViewDimensions(v); + int parentWidth = parentDimensions[0]; + int parentHeight = parentDimensions[1]; + int viewWidth = viewDimensions[0]; + int viewHeight = viewDimensions[1]; + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) v.getLayoutParams(); + int newLeftMargin = (int) (event.getRawX() - perfOverlayDeltaX); + int newTopMargin = (int) (event.getRawY() - perfOverlayDeltaY); + + // 边界检查,防止移出屏幕 + newLeftMargin = Math.max(0, Math.min(newLeftMargin, parentWidth - viewWidth)); + newTopMargin = Math.max(0, Math.min(newTopMargin, parentHeight - viewHeight)); + + layoutParams.leftMargin = newLeftMargin; + layoutParams.topMargin = newTopMargin; + layoutParams.gravity = Gravity.NO_GRAVITY; + v.setLayoutParams(layoutParams); + + // 拖动过程中实时更新文字对齐 + configureTextAlignment(); + return true; + } + + /** + * 处理触摸抬起事件 + */ + private boolean handleActionUp(View v, MotionEvent event) { + if (!isDraggingPerfOverlay) { + return false; + } + + isDraggingPerfOverlay = false; + applyDraggingVisualFeedback(v, false); + + // 检测是否为点击事件 + if (isClick(event)) { + handleClickEvent(); + } else { + snapToNearestPosition(v); + } + + return true; + } + + /** + * 将预设位置转换为实际边距 + */ + private void convertGravityToMargins(View v, FrameLayout.LayoutParams layoutParams) { + int[] viewLocation = new int[2]; + int[] parentLocation = new int[2]; + v.getLocationInWindow(viewLocation); + ((View) v.getParent()).getLocationInWindow(parentLocation); + + layoutParams.leftMargin = viewLocation[0] - parentLocation[0]; + layoutParams.topMargin = viewLocation[1] - parentLocation[1]; + layoutParams.gravity = Gravity.NO_GRAVITY; + v.setLayoutParams(layoutParams); + } + + /** + * 应用拖动视觉反馈效果 + */ + private void applyDraggingVisualFeedback(View v, boolean isDragging) { + if (isDragging) { + v.setAlpha(0.7f); + v.setScaleX(1.05f); + v.setScaleY(1.05f); + } else { + v.setAlpha(1.0f); + v.setScaleX(1.0f); + v.setScaleY(1.0f); + } + } + + /** + * 处理点击事件(单击和双击) + */ + private void handleClickEvent() { + long currentTime = System.currentTimeMillis(); + long timeSinceLastClick = currentTime - lastClickTime; + + if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT && lastClickTime > 0) { + // 双击:切换布局 + toggleLayoutOrientation(); + lastClickTime = 0; + isDoubleClickHandled = true; + } else { + // 单击:延迟显示项目信息,等待确认不是双击 + lastClickTime = currentTime; + isDoubleClickHandled = false; + performanceOverlayView.postDelayed(() -> { + if (!isDoubleClickHandled && lastClickTime > 0) { + showClickedItemInfo(); + } + }, DOUBLE_CLICK_TIMEOUT); + } + } + + /** + * 根据点击位置显示对应项目的信息 + */ + private void showClickedItemInfo() { + if (prefConfig.perfOverlayOrientation == PreferenceConfiguration.PerfOverlayOrientation.VERTICAL) { + showClickedItemInfoVertical(); + } else { + showClickedItemInfoHorizontal(); + } + } + + /** + * 垂直布局的点击检测 + */ + private void showClickedItemInfoVertical() { + // 获取覆盖层高度和可见项目数量 + int overlayHeight = performanceOverlayView.getHeight(); + if (overlayHeight == 0) return; + + // 计算每个项目的平均高度 + int visibleItemCount = getVisibleItemCount(); + if (visibleItemCount == 0) { + showMoonPhaseInfo(); // 默认显示月相信息 + return; + } + + int itemHeight = overlayHeight / visibleItemCount; + int clickedItemIndex = (int) (clickStartY / itemHeight); + + // 根据索引显示对应信息 + showInfoByIndex(clickedItemIndex); + } + + /** + * 水平布局的点击检测 + */ + private void showClickedItemInfoHorizontal() { + // 获取覆盖层宽度 + int overlayWidth = performanceOverlayView.getWidth(); + if (overlayWidth == 0) return; + + // 获取可见项目数量 + int visibleItemCount = getVisibleItemCount(); + if (visibleItemCount == 0) { + showMoonPhaseInfo(); // 默认显示月相信息 + return; + } + + // 使用实际View边界进行点击检测 + int clickedItemIndex = findClickedItemByBoundaries(); + + // 根据索引显示对应信息 + showInfoByIndex(clickedItemIndex); + } + + /** + * 基于实际View边界查找被点击的项目 + */ + private int findClickedItemByBoundaries() { + int currentIndex = 0; + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.isVisible()) { + // 获取View在父容器中的位置 + int[] viewLocation = new int[2]; + itemInfo.view.getLocationInWindow(viewLocation); + + // 获取覆盖层在父容器中的位置 + int[] overlayLocation = new int[2]; + performanceOverlayView.getLocationInWindow(overlayLocation); + + // 计算View相对于覆盖层的边界 + int viewLeft = viewLocation[0] - overlayLocation[0]; + int viewRight = viewLeft + itemInfo.view.getWidth(); + + // 检查点击位置是否在此View的边界内 + if (clickStartX >= viewLeft && clickStartX <= viewRight) { + return currentIndex; + } + + currentIndex++; + } + } + + return -1; + } + + /** + * 获取可见项目的数量 + */ + private int getVisibleItemCount() { + int count = 0; + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.isVisible()) { + count++; + } + } + return count; + } + + /** + * 根据项目索引显示对应信息 + */ + private void showInfoByIndex(int index) { + int currentIndex = 0; + for (PerformanceItemInfo itemInfo : performanceItems) { + if (itemInfo.isVisible()) { + if (currentIndex == index) { + itemInfo.infoMethod.run(); + return; + } + currentIndex++; + } + } + + showMoonPhaseInfo(); + } + + /** + * 显示月相信息对话框 + */ + private void showMoonPhaseInfo() { + MoonPhaseUtils.MoonPhaseInfo moonPhaseInfo = MoonPhaseUtils.getCurrentMoonPhaseInfo(); + double moonPhase = MoonPhaseUtils.getCurrentMoonPhase(); + double phasePercentage = MoonPhaseUtils.getMoonPhasePercentage(moonPhase); + int daysInCycle = MoonPhaseUtils.getDaysInMoonCycle(moonPhase); + + // 格式化日期 + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy年MM月dd日 EEEE", Locale.getDefault()); + String currentDate = dateFormat.format(Calendar.getInstance(TimeZone.getDefault()).getTime()); + + // 创建月相信息文本 + String moonInfo = String.format( + activity.getString(R.string.perf_moon_phase_info), + moonPhaseInfo.icon, moonPhaseInfo.name, phasePercentage, daysInCycle, currentDate, moonPhaseInfo.description + ); + + showMoonPhaseDialog(moonPhaseInfo.poeticTitle, moonInfo); + } + + /** + * 显示月相信息对话框 + */ + private void showMoonPhaseDialog(String title, String message) { + new AlertDialog.Builder(activity, R.style.AppDialogStyle) + .setTitle(title) + .setMessage(message) + .setPositiveButton("Ok", null) + .setCancelable(true) + .show(); + } + + + /** + * 显示分辨率信息 + */ + private void showResolutionInfo() { + if (currentPerformanceInfo == null) { + showMoonPhaseInfo(); // 如果没有性能信息,显示月相信息 + return; + } + + // 计算主机端分辨率(客户端分辨率 * 缩放比例) + // 从设置中获取缩放比例,默认为100(即1.0) + int scalePercent = prefConfig.resolutionScale; + float scaleFactor = scalePercent / 100.0f; + int hostWidth = (int) (currentPerformanceInfo.initialWidth * scaleFactor); + int hostHeight = (int) (currentPerformanceInfo.initialHeight * scaleFactor); + + // 创建分辨率信息文本 + StringBuilder resolutionInfo = new StringBuilder(); + resolutionInfo.append("Client Resolution: ").append(currentPerformanceInfo.initialWidth) + .append(" × ").append(currentPerformanceInfo.initialHeight).append("\n"); + resolutionInfo.append("Host Resolution: ").append(hostWidth) + .append(" × ").append(hostHeight).append("\n"); + resolutionInfo.append("Scale Factor: ").append(String.format("%.2f", scaleFactor)).append(" (").append(scalePercent).append("%)\n"); + // 获取设备支持的刷新率 + float deviceRefreshRate = UiHelper.getDeviceRefreshRate(activity); + + resolutionInfo.append("Target FPS: ").append(prefConfig.fps).append(" FPS\n"); + resolutionInfo.append("Current FPS: ").append(String.format("%.0f", currentPerformanceInfo.totalFps)).append(" FPS\n"); + resolutionInfo.append("Device Refresh Rate: ").append(String.format("%.0f", deviceRefreshRate)).append(" Hz\n"); + + showInfoDialog( + "📱 Resolution Information", + resolutionInfo.toString() + ); + } + + /** + * 显示解码器信息 + */ + private void showDecoderInfo() { + // 获取当前性能信息中的完整解码器信息 + String fullDecoderInfo = getCurrentDecoderInfo(); + + showInfoDialog( + activity.getString(R.string.perf_decoder_title), + fullDecoderInfo + ); + } + + /** + * 获取当前完整的解码器信息 + */ + private String getCurrentDecoderInfo() { + StringBuilder decoderInfo = new StringBuilder(); + // 这里需要获取当前的PerformanceInfo对象 + // 由于PerformanceInfo是在updatePerformanceInfo方法中传入的, + // 我们需要保存最新的PerformanceInfo对象 + if (currentPerformanceInfo != null) { + // 添加完整解码器名称 + decoderInfo.append("Codec: ").append(currentPerformanceInfo.decoder).append("\n\n"); + + // 添加解码器类型 + DecoderTypeInfo decoderTypeInfo = getDecoderTypeInfo(currentPerformanceInfo.decoder); + decoderInfo.append("Type: ").append(decoderTypeInfo.fullName).append("\n"); + + // 添加HDR状态 + if (currentPerformanceInfo.isHdrActive) { + decoderInfo.append("HDR: Enabled\n"); + } else { + decoderInfo.append("HDR: Disabled\n"); + } + } + + decoderInfo.append(activity.getString(R.string.perf_decoder_info)); + return decoderInfo.toString(); + } + + /** + * 统一的解码器类型识别方法 + * 返回包含完整名称和简短名称的DecoderTypeInfo对象 + */ + private DecoderTypeInfo getDecoderTypeInfo(String fullDecoderName) { + if (fullDecoderName == null) { + return new DecoderTypeInfo("Unknown", "Unknown"); + } + + String lowerName = fullDecoderName.toLowerCase(); + + // 在映射表中查找匹配的解码器类型 + for (Map.Entry entry : DECODER_TYPE_MAP.entrySet()) { + if (lowerName.contains(entry.getKey())) { + return entry.getValue(); + } + } + + // 如果没有找到匹配的类型,尝试提取最后一个点后面的部分 + String[] parts = fullDecoderName.split("\\."); + if (parts.length > 0) { + String extractedName = parts[parts.length - 1]; + return new DecoderTypeInfo(fullDecoderName, extractedName.toUpperCase()); + } + + return new DecoderTypeInfo(fullDecoderName, fullDecoderName); + } + + private void showPerformanceInfo(int titleResId, int infoResId) { + showInfoDialog( + activity.getString(titleResId), + activity.getString(infoResId) + ); + } + + + private void showFpsInfo() { + showPerformanceInfo(R.string.perf_fps_title, R.string.perf_fps_info); + } + + private void showPacketLossInfo() { + showPerformanceInfo(R.string.perf_packet_loss_title, R.string.perf_packet_loss_info); + } + + private void showNetworkLatencyInfo() { + showPerformanceInfo(R.string.perf_network_latency_title, R.string.perf_network_latency_info); + } + + private void showDecodeLatencyInfo() { + showPerformanceInfo(R.string.perf_decode_latency_title, R.string.perf_decode_latency_info); + } + + private void showHostLatencyInfo() { + showPerformanceInfo(R.string.perf_host_latency_title, R.string.perf_host_latency_info); + } + + private void showInfoDialog(String title, String message) { + new AlertDialog.Builder(activity, R.style.AppDialogStyle) + .setTitle(title) + .setMessage(message) + .setPositiveButton(activity.getString(R.string.yes), null) + .setCancelable(true) + .show(); + } + + private void toggleLayoutOrientation() { + // 切换布局方向 + if (prefConfig.perfOverlayOrientation == PreferenceConfiguration.PerfOverlayOrientation.VERTICAL) { + prefConfig.perfOverlayOrientation = PreferenceConfiguration.PerfOverlayOrientation.HORIZONTAL; + } else { + prefConfig.perfOverlayOrientation = PreferenceConfiguration.PerfOverlayOrientation.VERTICAL; + } + + // 保存设置到SharedPreferences + saveLayoutOrientation(); + + // 重新配置性能覆盖层 + configurePerformanceOverlay(); + } + + private void saveLayoutOrientation() { + SharedPreferences prefs = activity.getSharedPreferences("performance_overlay", Activity.MODE_PRIVATE); + prefs.edit() + .putString("layout_orientation", prefConfig.perfOverlayOrientation.name()) + .apply(); + } + + private void loadLayoutOrientation() { + SharedPreferences prefs = activity.getSharedPreferences("performance_overlay", Activity.MODE_PRIVATE); + String savedOrientation = prefs.getString("layout_orientation", null); + + if (savedOrientation != null) { + try { + prefConfig.perfOverlayOrientation = PreferenceConfiguration.PerfOverlayOrientation.valueOf(savedOrientation); + } catch (IllegalArgumentException e) { + // 如果保存的值无效,使用默认值 + prefConfig.perfOverlayOrientation = PreferenceConfiguration.PerfOverlayOrientation.VERTICAL; + } + } + } + + /** + * 检测是否为点击事件(而非拖动) + */ + private boolean isClick(MotionEvent event) { + float deltaX = Math.abs(event.getRawX() - perfOverlayStartX); + float deltaY = Math.abs(event.getRawY() - perfOverlayStartY); + long deltaTime = System.currentTimeMillis() - clickStartTime; + + // 点击条件:移动距离小且时间短 + return deltaX < CLICK_THRESHOLD && deltaY < CLICK_THRESHOLD && deltaTime < 500; + } + + private void snapToNearestPosition(View view) { + // 获取父容器和View的尺寸 + int[] parentDimensions = getParentDimensions(view); + int[] viewDimensions = getViewDimensions(view); + int screenWidth = parentDimensions[0]; + int screenHeight = parentDimensions[1]; + int viewWidth = viewDimensions[0]; + int viewHeight = viewDimensions[1]; + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + int currentX = layoutParams.leftMargin + viewWidth / 2; + int currentY = layoutParams.topMargin + viewHeight / 2; + + // 计算到各个吸附位置的距离 + SnapPosition nearestPosition = SnapPosition.TOP_CENTER; + double minDistance = Double.MAX_VALUE; + + // 定义8个吸附位置 + int[][] snapPositions = { + {viewWidth / 2, viewHeight / 2}, // TOP_LEFT + {screenWidth / 2, viewHeight / 2}, // TOP_CENTER + {screenWidth - viewWidth / 2, viewHeight / 2}, // TOP_RIGHT + {viewWidth / 2, screenHeight / 2}, // CENTER_LEFT + {screenWidth - viewWidth / 2, screenHeight / 2}, // CENTER_RIGHT + {viewWidth / 2, screenHeight - viewHeight / 2}, // BOTTOM_LEFT + {screenWidth / 2, screenHeight - viewHeight / 2}, // BOTTOM_CENTER + {screenWidth - viewWidth / 2, screenHeight - viewHeight / 2} // BOTTOM_RIGHT + }; + + SnapPosition[] positions = SnapPosition.values(); + + // 找到最近的吸附位置 + for (int i = 0; i < snapPositions.length; i++) { + double distance = Math.sqrt( + Math.pow(currentX - snapPositions[i][0], 2) + + Math.pow(currentY - snapPositions[i][1], 2) + ); + + if (distance < minDistance) { + minDistance = distance; + nearestPosition = positions[i]; + } + } + + // 吸过来 + animateToSnapPosition(view, nearestPosition, screenWidth, screenHeight); + } + + private void animateToSnapPosition(View view, SnapPosition position, int screenWidth, int screenHeight) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + int[] viewDimensions = getViewDimensions(view); + int viewWidth = viewDimensions[0]; + int viewHeight = viewDimensions[1]; + + int targetX, targetY; + + switch (position) { + case TOP_LEFT: + targetX = 0; + targetY = 0; + break; + case TOP_CENTER: + targetX = (screenWidth - viewWidth) / 2; + targetY = 0; + break; + case TOP_RIGHT: + targetX = screenWidth - viewWidth; + targetY = 0; + break; + case CENTER_LEFT: + targetX = 0; + targetY = (screenHeight - viewHeight) / 2; + break; + case CENTER_RIGHT: + targetX = screenWidth - viewWidth; + targetY = (screenHeight - viewHeight) / 2; + break; + case BOTTOM_LEFT: + targetX = 0; + targetY = screenHeight - viewHeight; + break; + case BOTTOM_CENTER: + targetX = (screenWidth - viewWidth) / 2; + targetY = screenHeight - viewHeight; + break; + case BOTTOM_RIGHT: + targetX = screenWidth - viewWidth; + targetY = screenHeight - viewHeight; + break; + default: + targetX = (screenWidth - viewWidth) / 2; + targetY = 0; + break; + } + + // 使用动画平滑移动到目标位置 + view.animate() + .translationX(targetX - layoutParams.leftMargin) + .translationY(targetY - layoutParams.topMargin) + .setDuration(200) + .withEndAction(() -> { + // 动画结束后更新实际的布局参数 + layoutParams.leftMargin = targetX; + layoutParams.topMargin = targetY; + view.setTranslationX(0); + view.setTranslationY(0); + view.setLayoutParams(layoutParams); + + // 保存位置到SharedPreferences + savePerformanceOverlayPosition(targetX, targetY); + + // 重新配置文字对齐 + configureTextAlignment(); + }) + .start(); + } + + private void savePerformanceOverlayPosition(int x, int y) { + SharedPreferences prefs = activity.getSharedPreferences("performance_overlay", Activity.MODE_PRIVATE); + prefs.edit() + .putBoolean("has_custom_position", true) + .putInt("left_margin", x) + .putInt("top_margin", y) + .apply(); + } + + /** + * 获取View的实际尺寸,如果未测量则使用估计值 + */ + private int[] getViewDimensions(View view) { + int width = view.getWidth(); + int height = view.getHeight(); + + // 如果View尺寸为0(还未测量),使用估计值 + if (width == 0) { + width = 300; // 估计宽度 + } + if (height == 0) { + height = 50; // 估计高度 + } + + return new int[]{width, height}; + } + + /** + * 获取父容器的尺寸 + */ + private int[] getParentDimensions(View view) { + View parent = (View) view.getParent(); + return new int[]{parent.getWidth(), parent.getHeight()}; + } + +} diff --git a/app/src/main/java/com/limelight/ShortcutTrampoline.java b/app/src/main/java/com/limelight/ShortcutTrampoline.java index 6157379eca..dc5703400b 100644 --- a/app/src/main/java/com/limelight/ShortcutTrampoline.java +++ b/app/src/main/java/com/limelight/ShortcutTrampoline.java @@ -21,6 +21,8 @@ import com.limelight.utils.ServerHelper; import com.limelight.utils.SpinnerDialog; import com.limelight.utils.UiHelper; +import com.limelight.utils.AppCacheKeys; +import com.limelight.utils.AppCacheManager; import org.xmlpull.v1.XmlPullParserException; @@ -178,8 +180,15 @@ public void run() { // If a game is running, we'll make the stream the top level activity if (details.runningGameId != 0) { - intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, - new NvApp(null, details.runningGameId, false), details, managerBinder)); + // Try to get the complete NvApp object from the cached app list + NvApp actualApp = getNvAppById(details.runningGameId, uuidString); + if (actualApp != null) { + intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, actualApp, details, managerBinder)); + } else { + // Fallback: create a basic NvApp object if we can't find the complete one + intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, + new NvApp(null, details.runningGameId, false), details, managerBinder)); + } } // Now start the activities @@ -269,6 +278,56 @@ protected boolean validateInput(String uuidString, String appIdString, String na return true; } + /** + * 根据appId从缓存的应用列表中获取完整的NvApp对象 + * @param appId 应用ID + * @param uuidString PC的UUID + * @return 完整的NvApp对象,如果找不到则返回null + */ + private NvApp getNvAppById(int appId, String uuidString) { + try { + String rawAppList = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); + if (rawAppList.isEmpty()) { + // 如果缓存为空,尝试从SharedPreferences获取上一次的应用信息 + return getLastNvAppFromPreferences(appId, uuidString); + } + + List applist = NvHTTP.getAppListByReader(new StringReader(rawAppList)); + for (NvApp app : applist) { + if (app.getAppId() == appId) { + // 保存这个应用信息到SharedPreferences,供下次使用 + AppCacheManager cacheManager = new AppCacheManager(this); + cacheManager.saveAppInfo(uuidString, app); + return app; + } + } + + // 如果在应用列表中找不到,尝试从SharedPreferences获取 + return getLastNvAppFromPreferences(appId, uuidString); + } catch (IOException | XmlPullParserException e) { + // 如果读取缓存失败,尝试从SharedPreferences获取 + e.printStackTrace(); + return getLastNvAppFromPreferences(appId, uuidString); + } + } + + /** + * 从SharedPreferences获取上一次的完整NvApp对象 + * @param appId 应用ID + * @param uuidString PC的UUID + * @return 完整的NvApp对象,如果找不到则返回null + */ + private NvApp getLastNvAppFromPreferences(int appId, String uuidString) { + try { + AppCacheManager cacheManager = new AppCacheManager(this); + return cacheManager.getAppInfo(uuidString, appId); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); diff --git a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java index dc25cc8912..6730db9b03 100644 --- a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java +++ b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java @@ -6,6 +6,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; +import android.media.Spatializer; import android.media.audiofx.AudioEffect; import android.os.Build; @@ -17,17 +18,26 @@ public class AndroidAudioRenderer implements AudioRenderer { private final Context context; private final boolean enableAudioFx; + private final boolean enableSpatializer; private AudioTrack track; + private Spatializer spatializer; - public AndroidAudioRenderer(Context context, boolean enableAudioFx) { + public AndroidAudioRenderer(Context context, boolean enableAudioFx, boolean enableSpatializer) { this.context = context; this.enableAudioFx = enableAudioFx; + this.enableSpatializer = enableSpatializer; } private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) { AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME); + + // Enable spatialization attribute if supported and requested + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && enableSpatializer) { + attributesBuilder.setSpatializationBehavior(AudioAttributes.SPATIALIZATION_BEHAVIOR_AUTO); + } + AudioFormat format = new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(sampleRate) @@ -182,6 +192,36 @@ public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRat return -2; } + // Initialize Spatializer if supported and enabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && enableSpatializer) { + try { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + spatializer = audioManager.getSpatializer(); + + if (spatializer != null && spatializer.isAvailable()) { + // Check if the track can be spatialized + AudioAttributes attributes = track.getAudioAttributes(); + AudioFormat trackFormat = track.getFormat(); + + if (spatializer.canBeSpatialized(attributes, trackFormat)) { + LimeLog.info("Spatializer is available and track can be spatialized"); + LimeLog.info("Spatializer enabled: " + spatializer.isEnabled()); + LimeLog.info("Spatializer level: " + spatializer.getImmersiveAudioLevel()); + } else { + LimeLog.warning("Spatializer is available but track cannot be spatialized"); + spatializer = null; + } + } else { + LimeLog.info("Spatializer is not available on this device"); + spatializer = null; + } + } catch (Exception e) { + LimeLog.warning("Failed to initialize Spatializer: " + e.getMessage()); + e.printStackTrace(); + spatializer = null; + } + } + return 0; } diff --git a/app/src/main/java/com/limelight/binding/audio/AudioDiagnostics.java b/app/src/main/java/com/limelight/binding/audio/AudioDiagnostics.java new file mode 100644 index 0000000000..f4be478c80 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/AudioDiagnostics.java @@ -0,0 +1,173 @@ +package com.limelight.binding.audio; + +import android.content.Context; +import com.limelight.LimeLog; +import com.limelight.R; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 音频诊断工具类 + * 用于监控和分析音频流的连续性 + */ +public class AudioDiagnostics { + + private static final AtomicLong totalFramesCaptured = new AtomicLong(0); + private static final AtomicLong totalFramesEncoded = new AtomicLong(0); + private static final AtomicLong totalFramesSent = new AtomicLong(0); + private static final AtomicLong droppedFrames = new AtomicLong(0); + private static final AtomicLong encodingErrors = new AtomicLong(0); + private static final AtomicLong sendingErrors = new AtomicLong(0); + + private static long lastReportTime = 0; + private static final long REPORT_INTERVAL_MS = 5000; // 每5秒报告一次 + + /** + * 记录捕获的帧 + */ + public static void recordFrameCaptured() { + totalFramesCaptured.incrementAndGet(); + checkAndReport(); + } + + /** + * 记录编码的帧 + */ + public static void recordFrameEncoded() { + totalFramesEncoded.incrementAndGet(); + checkAndReport(); + } + + /** + * 记录发送的帧 + */ + public static void recordFrameSent() { + totalFramesSent.incrementAndGet(); + checkAndReport(); + } + + /** + * 记录丢弃的帧 + */ + public static void recordFrameDropped() { + droppedFrames.incrementAndGet(); + checkAndReport(); + } + + /** + * 记录编码错误 + */ + public static void recordEncodingError() { + encodingErrors.incrementAndGet(); + checkAndReport(); + } + + /** + * 记录发送错误 + */ + public static void recordSendingError() { + sendingErrors.incrementAndGet(); + checkAndReport(); + } + + /** + * 检查并报告统计信息 + */ + private static void checkAndReport() { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastReportTime >= REPORT_INTERVAL_MS) { + reportStatistics(); + lastReportTime = currentTime; + } + } + + /** + * 报告统计信息 + */ + public static void reportStatistics() { + long captured = totalFramesCaptured.get(); + long encoded = totalFramesEncoded.get(); + long sent = totalFramesSent.get(); + long dropped = droppedFrames.get(); + long encErrors = encodingErrors.get(); + long sendErrors = sendingErrors.get(); + + // 计算连续性指标 + double captureToEncodeRatio = captured > 0 ? (double) encoded / captured : 0; + double encodeToSendRatio = encoded > 0 ? (double) sent / encoded : 0; + double overallContinuity = captured > 0 ? (double) sent / captured : 0; + + LimeLog.info("=== 音频诊断报告 ==="); + LimeLog.info("捕获帧数: " + captured); + LimeLog.info("编码帧数: " + encoded); + LimeLog.info("发送帧数: " + sent); + LimeLog.info("丢弃帧数: " + dropped); + LimeLog.info("编码错误: " + encErrors); + LimeLog.info("发送错误: " + sendErrors); + LimeLog.info("捕获到编码比例: " + String.format("%.2f%%", captureToEncodeRatio * 100)); + LimeLog.info("编码到发送比例: " + String.format("%.2f%%", encodeToSendRatio * 100)); + LimeLog.info("整体连续性: " + String.format("%.2f%%", overallContinuity * 100)); + + // 分析问题 + if (captureToEncodeRatio < 0.95) { + LimeLog.warning("编码效率较低,可能存在编码器问题"); + } + if (encodeToSendRatio < 0.95) { + LimeLog.warning("发送效率较低,可能存在网络或队列问题"); + } + if (overallContinuity < 0.90) { + LimeLog.warning("整体音频连续性较差,需要检查整个音频管道"); + } + if (dropped > 0) { + LimeLog.warning("检测到帧丢弃,可能存在缓冲区不足问题"); + } + } + + /** + * 重置统计信息 + */ + public static void resetStatistics() { + totalFramesCaptured.set(0); + totalFramesEncoded.set(0); + totalFramesSent.set(0); + droppedFrames.set(0); + encodingErrors.set(0); + sendingErrors.set(0); + lastReportTime = 0; + LimeLog.info("音频诊断统计已重置"); + } + + /** + * 获取当前统计信息 + */ + public static String getCurrentStats() { + long captured = totalFramesCaptured.get(); + long encoded = totalFramesEncoded.get(); + long sent = totalFramesSent.get(); + long dropped = droppedFrames.get(); + + double continuity = captured > 0 ? (double) sent / captured : 0; + + return String.format("音频连续性: %.1f%% (捕获:%d 编码:%d 发送:%d 丢弃:%d)", + continuity * 100, captured, encoded, sent, dropped); + } + + /** + * 获取当前统计信息(使用字符串资源) + */ + public static String getCurrentStats(Context context) { + if (context == null) { + return getCurrentStats(); + } + + long captured = totalFramesCaptured.get(); + long encoded = totalFramesEncoded.get(); + long sent = totalFramesSent.get(); + long dropped = droppedFrames.get(); + + double continuity = captured > 0 ? (double) sent / captured : 0; + + return context.getString(R.string.mic_stats_continuity, + continuity * 100, captured, encoded, sent, dropped); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/audio/MicrophoneCapture.java b/app/src/main/java/com/limelight/binding/audio/MicrophoneCapture.java new file mode 100644 index 0000000000..345b26c73a --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/MicrophoneCapture.java @@ -0,0 +1,374 @@ +package com.limelight.binding.audio; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaRecorder; +import android.media.audiofx.AcousticEchoCanceler; +import android.media.audiofx.AutomaticGainControl; +import android.media.audiofx.NoiseSuppressor; +import android.os.Build; +import android.os.Process; +import android.os.SystemClock; + +import com.limelight.LimeLog; + +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicBoolean; + +public class MicrophoneCapture { + private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO; + private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; + + private Thread captureThread; + private AudioRecord audioRecord; + private final AtomicBoolean running = new AtomicBoolean(false); + private int bufferSize; + private MicrophoneDataCallback dataCallback; + + // 音频效果器 + private AcousticEchoCanceler echoCanceler; + private AutomaticGainControl gainControl; + private NoiseSuppressor noiseSuppressor; + + // 音频帧缓冲 + private byte[] frameBuffer; + private int frameBufferPos = 0; + + // 时序控制 + private long lastFrameTime = 0; + private long frameCount = 0; + + public interface MicrophoneDataCallback { + void onMicrophoneData(byte[] data, int offset, int length); + } + + public MicrophoneCapture(MicrophoneDataCallback callback) { + this.dataCallback = callback; + // 使用更小的缓冲区进行更频繁的读取 + this.bufferSize = MicrophoneConfig.CAPTURE_BUFFER_SIZE; + // 初始化帧缓冲区 + this.frameBuffer = new byte[MicrophoneConfig.BYTES_PER_FRAME]; + } + + public boolean start() { + if (running.get()) { + return true; // 已经运行中 + } + + try { + // 检查最小缓冲区大小 + int minBufferSize = AudioRecord.getMinBufferSize(MicrophoneConfig.SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT); + if (minBufferSize == AudioRecord.ERROR_BAD_VALUE) { + LimeLog.severe("不支持的音频参数"); + return false; + } else if (minBufferSize == AudioRecord.ERROR) { + LimeLog.severe("无法获取最小缓冲区大小"); + return false; + } + + // 确保缓冲区大小足够 + bufferSize = Math.max(minBufferSize * 2, MicrophoneConfig.CAPTURE_BUFFER_SIZE); + + // 根据配置选择音频源 + int audioSource = MicrophoneConfig.useVoiceCommunication() ? + MediaRecorder.AudioSource.VOICE_COMMUNICATION : + MediaRecorder.AudioSource.MIC; + + audioRecord = new AudioRecord(audioSource, + MicrophoneConfig.SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize); + + if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + LimeLog.severe("无法初始化AudioRecord,状态: " + audioRecord.getState()); + release(); + return false; + } + + // 尝试初始化音频效果器 + initializeAudioEffects(); + + running.set(true); + lastFrameTime = SystemClock.elapsedRealtimeNanos(); + + captureThread = new Thread(() -> { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + + ByteBuffer buffer = ByteBuffer.allocateDirect(bufferSize); + byte[] data = new byte[bufferSize]; + + try { + audioRecord.startRecording(); + LimeLog.info("麦克风捕获已启动,缓冲区大小: " + bufferSize + " 字节"); + + while (running.get()) { + int bytesRead = audioRecord.read(buffer, bufferSize); + if (bytesRead > 0) { + buffer.get(data, 0, bytesRead); + buffer.clear(); + + // 处理音频数据,确保帧的连续性 + processAudioData(data, 0, bytesRead); + } else if (bytesRead == AudioRecord.ERROR_INVALID_OPERATION) { + LimeLog.warning("AudioRecord读取错误: ERROR_INVALID_OPERATION"); + break; + } else if (bytesRead == AudioRecord.ERROR_BAD_VALUE) { + LimeLog.warning("AudioRecord读取错误: ERROR_BAD_VALUE"); + break; + } + } + } catch (SecurityException e) { + LimeLog.severe("麦克风权限不足: " + e.getMessage()); + } catch (Exception e) { + LimeLog.severe("麦克风捕获出错: " + e.getMessage()); + } finally { + if (audioRecord.getState() == AudioRecord.STATE_INITIALIZED) { + try { + audioRecord.stop(); + } catch (Exception e) { + LimeLog.warning("停止AudioRecord时出错: " + e.getMessage()); + } + } + } + }, "MicrophoneCapture"); + + captureThread.start(); + return true; + } catch (SecurityException e) { + LimeLog.severe("麦克风权限不足: " + e.getMessage()); + release(); + return false; + } catch (Exception e) { + LimeLog.severe("无法创建麦克风捕获: " + e.getMessage()); + release(); + return false; + } + } + + /** + * 处理音频数据,确保发送完整的帧 + */ + private void processAudioData(byte[] data, int offset, int length) { + int remainingBytes = length; + int dataOffset = offset; + + while (remainingBytes > 0) { + // 计算当前帧还需要多少字节 + int bytesNeeded = MicrophoneConfig.BYTES_PER_FRAME - frameBufferPos; + int bytesToCopy = Math.min(remainingBytes, bytesNeeded); + + // 复制数据到帧缓冲区 + System.arraycopy(data, dataOffset, frameBuffer, frameBufferPos, bytesToCopy); + frameBufferPos += bytesToCopy; + dataOffset += bytesToCopy; + remainingBytes -= bytesToCopy; + + // 如果帧缓冲区满了,发送完整的帧 + if (frameBufferPos >= MicrophoneConfig.BYTES_PER_FRAME) { + // 检查时序 + long currentTime = SystemClock.elapsedRealtimeNanos(); + long timeDiff = currentTime - lastFrameTime; + + if (MicrophoneConfig.ENABLE_AUDIO_SYNC && + timeDiff < MicrophoneConfig.FRAME_INTERVAL_NS * 0.8) { + // 帧间隔太短,等待一下 + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + + // 发送帧 + dataCallback.onMicrophoneData(frameBuffer, 0, MicrophoneConfig.BYTES_PER_FRAME); + frameBufferPos = 0; // 重置缓冲区位置 + lastFrameTime = currentTime; + frameCount++; + + // 记录诊断信息 + AudioDiagnostics.recordFrameCaptured(); + + // 每100帧记录一次统计信息 + if (frameCount % 100 == 0) { + LimeLog.info("麦克风帧统计: " + frameCount + " 帧, 平均间隔: " + + (timeDiff / 1000000) + "ms"); + } + } + } + } + + public void stop() { + running.set(false); + + if (captureThread != null) { + try { + captureThread.join(300); + } catch (InterruptedException e) { + // 忽略 + } + captureThread = null; + } + + release(); + } + + /** + * 初始化音频效果器(回声消除、自动增益控制、噪声抑制) + */ + private void initializeAudioEffects() { + if (audioRecord == null) { + LimeLog.warning("AudioRecord为空,无法初始化音频效果器"); + return; + } + + int audioSessionId = audioRecord.getAudioSessionId(); + LimeLog.info("开始初始化音频效果器,AudioSessionId: " + audioSessionId); + + // 1. 回声消除器 (AEC) + if (MicrophoneConfig.enableAcousticEchoCanceler()) { + if (AcousticEchoCanceler.isAvailable()) { + try { + echoCanceler = AcousticEchoCanceler.create(audioSessionId); + if (echoCanceler != null) { + int result = echoCanceler.setEnabled(true); + if (result == 0) { + LimeLog.info("✓ 回声消除器(AEC)已启用"); + } else { + LimeLog.warning("回声消除器启用失败,错误码: " + result); + } + } else { + LimeLog.warning("无法创建回声消除器实例"); + } + } catch (Exception e) { + LimeLog.warning("初始化回声消除器失败: " + e.getMessage()); + } + } else { + LimeLog.info("设备不支持硬件回声消除(AEC)"); + } + } else { + LimeLog.info("回声消除器已被配置禁用"); + } + + // 2. 自动增益控制 (AGC) + if (MicrophoneConfig.enableAutomaticGainControl()) { + if (AutomaticGainControl.isAvailable()) { + try { + gainControl = AutomaticGainControl.create(audioSessionId); + if (gainControl != null) { + int result = gainControl.setEnabled(true); + if (result == 0) { + LimeLog.info("✓ 自动增益控制(AGC)已启用"); + } else { + LimeLog.warning("自动增益控制启用失败,错误码: " + result); + } + } else { + LimeLog.warning("无法创建自动增益控制实例"); + } + } catch (Exception e) { + LimeLog.warning("初始化自动增益控制失败: " + e.getMessage()); + } + } else { + LimeLog.info("设备不支持自动增益控制(AGC)"); + } + } else { + LimeLog.info("自动增益控制已被配置禁用"); + } + + // 3. 噪声抑制器 (NS) + if (MicrophoneConfig.enableNoiseSuppressor()) { + if (NoiseSuppressor.isAvailable()) { + try { + noiseSuppressor = NoiseSuppressor.create(audioSessionId); + if (noiseSuppressor != null) { + int result = noiseSuppressor.setEnabled(true); + if (result == 0) { + LimeLog.info("✓ 噪声抑制器(NS)已启用"); + } else { + LimeLog.warning("噪声抑制器启用失败,错误码: " + result); + } + } else { + LimeLog.warning("无法创建噪声抑制器实例"); + } + } catch (Exception e) { + LimeLog.warning("初始化噪声抑制器失败: " + e.getMessage()); + } + } else { + LimeLog.info("设备不支持噪声抑制(NS)"); + } + } else { + LimeLog.info("噪声抑制器已被配置禁用"); + } + } + + /** + * 释放音频效果器资源 + */ + private void releaseAudioEffects() { + if (echoCanceler != null) { + try { + echoCanceler.setEnabled(false); + echoCanceler.release(); + LimeLog.info("回声消除器已释放"); + } catch (Exception e) { + LimeLog.warning("释放回声消除器失败: " + e.getMessage()); + } + echoCanceler = null; + } + + if (gainControl != null) { + try { + gainControl.setEnabled(false); + gainControl.release(); + LimeLog.info("自动增益控制已释放"); + } catch (Exception e) { + LimeLog.warning("释放自动增益控制失败: " + e.getMessage()); + } + gainControl = null; + } + + if (noiseSuppressor != null) { + try { + noiseSuppressor.setEnabled(false); + noiseSuppressor.release(); + LimeLog.info("噪声抑制器已释放"); + } catch (Exception e) { + LimeLog.warning("释放噪声抑制器失败: " + e.getMessage()); + } + noiseSuppressor = null; + } + } + + private void release() { + // 先释放音频效果器 + releaseAudioEffects(); + + if (audioRecord != null) { + if (audioRecord.getState() == AudioRecord.STATE_INITIALIZED) { + audioRecord.stop(); + } + audioRecord.release(); + audioRecord = null; + } + } + + /** + * 检查音频效果器是否真正在工作 + * 通过分析音频数据的特征来判断 + */ + public boolean isAudioEffectsWorking() { + return (echoCanceler != null && echoCanceler.getEnabled()) || + (gainControl != null && gainControl.getEnabled()) || + (noiseSuppressor != null && noiseSuppressor.getEnabled()); + } + + /** + * 获取当前使用的音频源类型 + */ + public String getAudioSourceInfo() { + if (MicrophoneConfig.useVoiceCommunication()) { + return "VOICE_COMMUNICATION (系统级AEC/AGC/NS)"; + } else { + return "MIC (使用硬件音频效果器)"; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/audio/MicrophoneConfig.java b/app/src/main/java/com/limelight/binding/audio/MicrophoneConfig.java new file mode 100644 index 0000000000..8f0864ff40 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/MicrophoneConfig.java @@ -0,0 +1,154 @@ +package com.limelight.binding.audio; + +import android.content.Context; +import com.limelight.preferences.PreferenceConfiguration; + +/** + * 麦克风配置类 + * 管理麦克风相关的配置参数 + */ +public class MicrophoneConfig { + // 音频参数 + public static final int SAMPLE_RATE = 48000; // 采样率 + public static final int CHANNELS = 1; // 声道数(单声道) + private static int opusBitrate = 64; // Opus编码比特率 (默认64 kbps) + + // 网络参数 + public static final int DEFAULT_MIC_PORT = 47996; // 默认麦克风端口 + public static final int MAX_QUEUE_SIZE = 5; + public static final int HOST_REQUEST_CHECK_INTERVAL_MS = 500; // 检查主机请求状态的间隔 + + // 权限请求码 + public static final int PERMISSION_REQUEST_MICROPHONE = 1001; + + // 延迟参数 + public static final int PERMISSION_DELAY_MS = 100; // 权限授予后的延迟时间 + + // 音频连续性参数 + public static final int FRAME_SIZE_MS = 20; // Opus帧大小 (毫秒) + public static final int SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_SIZE_MS / 1000; // 每帧采样数 (960) + public static final int BYTES_PER_FRAME = SAMPLES_PER_FRAME * CHANNELS * 2; // 每帧字节数 (1920) + + // 发送线程参数 + public static final int SENDER_THREAD_SLEEP_MS = 5; // 发送线程睡眠时间 (从100减少到5) + public static final int SENDER_ERROR_RETRY_MS = 5; // 发送错误重试时间 (从10减少到5) + + // 音频捕获优化参数 + public static final int CAPTURE_BUFFER_SIZE_MS = 40; // 捕获缓冲区大小 (毫秒) + public static final int CAPTURE_BUFFER_SIZE = SAMPLE_RATE * CAPTURE_BUFFER_SIZE_MS / 1000 * CHANNELS * 2; // 捕获缓冲区字节数 + public static final int FRAME_INTERVAL_MS = 20; // 帧间隔时间 (毫秒) + public static final long FRAME_INTERVAL_NS = FRAME_INTERVAL_MS * 1000000L; // 帧间隔纳秒 + + // 音频质量参数 + public static final boolean ENABLE_AUDIO_SYNC = true; // 启用音频同步 + public static final int MAX_FRAME_DELAY_MS = 50; // 最大帧延迟 (毫秒) + + // 回声消除和音频处理参数 + private static boolean enableAEC = true; // 启用回声消除器 + private static boolean enableAGC = true; // 启用自动增益控制 + private static boolean enableNS = true; // 启用噪声抑制 + private static boolean useVoiceComm = false; // 使用VOICE_COMMUNICATION音频源(自动启用AEC+AGC+NS) + + /** + * 获取当前配置的Opus比特率 + * @return 比特率(bps) + */ + public static int getOpusBitrate() { + return opusBitrate; + } + + /** + * 设置Opus比特率 + * @param bitrateKbps 比特率(kbps) + */ + public static void setOpusBitrate(int bitrateKbps) { + opusBitrate = bitrateKbps * 1000; // 转换为bps + } + + /** + * 从配置中更新比特率设置 + * @param context 上下文 + */ + public static void updateBitrateFromConfig(Context context) { + if (context != null) { + PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); + setOpusBitrate(config.micBitrate); + } + } + + // ========== 回声消除和音频处理配置方法 ========== + + /** + * 是否启用回声消除器(AEC) + */ + public static boolean enableAcousticEchoCanceler() { + return enableAEC; + } + + /** + * 设置是否启用回声消除器(AEC) + */ + public static void setEnableAcousticEchoCanceler(boolean enable) { + enableAEC = enable; + } + + /** + * 是否启用自动增益控制(AGC) + */ + public static boolean enableAutomaticGainControl() { + return enableAGC; + } + + /** + * 设置是否启用自动增益控制(AGC) + */ + public static void setEnableAutomaticGainControl(boolean enable) { + enableAGC = enable; + } + + /** + * 是否启用噪声抑制(NS) + */ + public static boolean enableNoiseSuppressor() { + return enableNS; + } + + /** + * 设置是否启用噪声抑制(NS) + */ + public static void setEnableNoiseSuppressor(boolean enable) { + enableNS = enable; + } + + /** + * 是否使用VOICE_COMMUNICATION音频源 + * VOICE_COMMUNICATION会自动启用系统级的AEC、AGC、NS + */ + public static boolean useVoiceCommunication() { + return useVoiceComm; + } + + /** + * 设置是否使用VOICE_COMMUNICATION音频源 + */ + public static void setUseVoiceCommunication(boolean use) { + useVoiceComm = use; + } + + /** + * 获取音频处理配置的摘要信息 + */ + public static String getAudioProcessingConfigSummary() { + StringBuilder summary = new StringBuilder(); + summary.append("音频处理配置:\n"); + summary.append("音频源: ").append(useVoiceComm ? "VOICE_COMMUNICATION" : "MIC").append("\n"); + summary.append("回声消除(AEC): ").append(enableAEC ? "启用" : "禁用").append("\n"); + summary.append("自动增益(AGC): ").append(enableAGC ? "启用" : "禁用").append("\n"); + summary.append("噪声抑制(NS): ").append(enableNS ? "启用" : "禁用"); + return summary.toString(); + } + + private MicrophoneConfig() { + // 私有构造函数,防止实例化 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/audio/MicrophoneEvent.java b/app/src/main/java/com/limelight/binding/audio/MicrophoneEvent.java new file mode 100644 index 0000000000..37139b867d --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/MicrophoneEvent.java @@ -0,0 +1,69 @@ +package com.limelight.binding.audio; + +/** + * 麦克风事件类 + * 定义麦克风相关的事件类型和数据结构 + */ +public class MicrophoneEvent { + + /** + * 麦克风事件类型 + */ + public enum EventType { + PERMISSION_GRANTED, // 权限授予 + PERMISSION_DENIED, // 权限拒绝 + STARTED, // 麦克风启动 + STOPPED, // 麦克风停止 + PAUSED, // 麦克风暂停 + RESUMED, // 麦克风恢复 + ERROR, // 错误 + HOST_REQUESTED, // 主机请求麦克风 + HOST_STOPPED_REQUEST // 主机停止请求麦克风 + } + + private final EventType type; + private final String message; + private final long timestamp; + private final Throwable error; + + public MicrophoneEvent(EventType type) { + this(type, null, null); + } + + public MicrophoneEvent(EventType type, String message) { + this(type, message, null); + } + + public MicrophoneEvent(EventType type, String message, Throwable error) { + this.type = type; + this.message = message; + this.error = error; + this.timestamp = System.currentTimeMillis(); + } + + public EventType getType() { + return type; + } + + public String getMessage() { + return message; + } + + public long getTimestamp() { + return timestamp; + } + + public Throwable getError() { + return error; + } + + @Override + public String toString() { + return "MicrophoneEvent{" + + "type=" + type + + ", message='" + message + '\'' + + ", timestamp=" + timestamp + + ", error=" + error + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/audio/MicrophoneManager.java b/app/src/main/java/com/limelight/binding/audio/MicrophoneManager.java new file mode 100644 index 0000000000..25f10e4bd3 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/MicrophoneManager.java @@ -0,0 +1,409 @@ +package com.limelight.binding.audio; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.widget.ImageButton; +import android.widget.Toast; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.nvstream.NvConnection; +import com.limelight.preferences.PreferenceConfiguration; + +/** + * 麦克风管理器 + * 负责管理麦克风的权限、状态和UI更新 + */ +public class MicrophoneManager { + private static final String TAG = "MicrophoneManager"; + + private final Context context; + private final NvConnection connection; + private MicrophoneStream microphoneStream; + private ImageButton micButton; + private boolean enableMic; + + public interface MicrophoneStateListener { + void onMicrophoneStateChanged(boolean isActive); + void onPermissionRequested(); + } + + private MicrophoneStateListener stateListener; + + public MicrophoneManager(Context context, NvConnection connection, boolean enableMic) { + this.context = context; + this.connection = connection; + this.enableMic = enableMic; + } + + /** + * 设置状态监听器 + */ + public void setStateListener(MicrophoneStateListener listener) { + this.stateListener = listener; + } + + /** + * 设置麦克风按钮 + */ + public void setMicrophoneButton(ImageButton button) { + this.micButton = button; + setupMicrophoneButton(); + } + + /** + * 初始化麦克风流 + */ + public boolean initializeMicrophoneStream() { + if (!enableMic) { + LimeLog.info("麦克风功能已禁用"); + return false; + } + + if (microphoneStream != null) { + LimeLog.info("麦克风流已存在"); + return true; + } + + if (!hasMicrophonePermission()) { + showMessage(context.getString(R.string.mic_permission_required)); + return false; + } + + try { + MicrophoneConfig.updateBitrateFromConfig(context); + microphoneStream = new MicrophoneStream(connection); + + if (!microphoneStream.start()) { + showMessage("无法启动麦克风流"); + return false; + } + + LimeLog.info("麦克风流启动成功"); + + if (!microphoneStream.isMicrophoneAvailable()) { + showMessage("主机不支持麦克风功能"); + } + + // 初始化后默认暂停麦克风 + if (microphoneStream.isRunning()) { + microphoneStream.pause(); + LimeLog.info("麦克风流已初始化,默认状态为关闭"); + } + + return true; + } catch (Exception e) { + LimeLog.warning("初始化麦克风流失败: " + e.getMessage()); + showMessage("初始化麦克风流失败: " + e.getMessage()); + return false; + } + } + + /** + * 切换麦克风状态 + */ + public void toggleMicrophone() { + if (!checkMicrophonePermission()) return; + + if (microphoneStream != null) { + if (microphoneStream.isRunning()) { + pauseMicrophone(); + } else { + resumeMicrophone(); + } + } else if (connection != null) { + if (initializeMicrophoneStream()) { + updateMicrophoneButtonState(); + showMessage(context.getString(R.string.mic_disabled)); + } else { + showMessage("麦克风状态切换: 初始化失败"); + } + } else { + showMessage("麦克风状态切换: 连接不存在"); + } + + updateMicrophoneButtonState(); + } + + /** + * 暂停麦克风 + */ + public void pauseMicrophone() { + if (microphoneStream != null && microphoneStream.isRunning()) { + microphoneStream.pause(); + showMessage(context.getString(R.string.mic_disabled)); + notifyStateChange(false); + } + } + + /** + * 恢复麦克风 + */ + public void resumeMicrophone() { + if (!checkMicrophonePermission()) return; + + if (microphoneStream != null && !microphoneStream.isRunning()) { + if (microphoneStream.resume()) { + showMessage(context.getString(R.string.mic_enabled)); + notifyStateChange(true); + } else { + restartMicrophoneStream(); + } + } + } + + private void restartMicrophoneStream() { + LimeLog.warning("麦克风恢复失败,尝试重新初始化"); + microphoneStream.stop(); + MicrophoneConfig.updateBitrateFromConfig(context); + + microphoneStream = new MicrophoneStream(connection); + if (microphoneStream.start()) { + showMessage(context.getString(R.string.mic_enabled)); + notifyStateChange(true); + } else { + showMessage("麦克风恢复失败: 重新初始化失败"); + } + } + + private void notifyStateChange(boolean isActive) { + if (stateListener != null) { + stateListener.onMicrophoneStateChanged(isActive); + } + } + + /** + * 获取麦克风当前状态 + */ + public boolean isMicrophoneActive() { + return microphoneStream != null && microphoneStream.isRunning(); + } + + /** + * 检查麦克风是否可用 + */ + public boolean isMicrophoneAvailable() { + return microphoneStream != null && microphoneStream.isMicrophoneAvailable(); + } + + /** + * 检查麦克风是否真正可用 + */ + public boolean isMicrophoneTrulyAvailable() { + return hasMicrophonePermission() && + microphoneStream != null && + microphoneStream.isMicrophoneAvailable(); + } + + /** + * 检查麦克风权限 + */ + public boolean checkMicrophonePermission() { + if (!hasMicrophonePermission()) { + requestMicrophonePermission(); + return false; + } + return true; + } + + /** + * 检查是否有麦克风权限 + */ + public boolean hasMicrophonePermission() { + return ContextCompat.checkSelfPermission(context, + android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + /** + * 请求麦克风权限 + */ + public void requestMicrophonePermission() { + if (context instanceof android.app.Activity) { + ActivityCompat.requestPermissions((android.app.Activity) context, + new String[]{android.Manifest.permission.RECORD_AUDIO}, + MicrophoneConfig.PERMISSION_REQUEST_MICROPHONE); + if (stateListener != null) { + stateListener.onPermissionRequested(); + } + } + } + + /** + * 处理权限请求结果 + */ + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == MicrophoneConfig.PERMISSION_REQUEST_MICROPHONE && + grantResults.length > 0) { + + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + new android.os.Handler().postDelayed(() -> { + if (hasMicrophonePermission()) { + toggleMicrophone(); + } else { + showPermissionError(); + } + }, MicrophoneConfig.PERMISSION_DELAY_MS); + } else { + showPermissionError(); + } + } + } + + /** + * 设置麦克风按钮 + */ + private void setupMicrophoneButton() { + if (micButton == null) return; + + micButton.setVisibility(enableMic ? android.view.View.VISIBLE : android.view.View.GONE); + if (enableMic) { + updateMicrophoneButtonState(); + micButton.setOnClickListener(v -> { + if (checkMicrophonePermission()) { + toggleMicrophone(); + } + }); + } + } + + /** + * 更新麦克风按钮状态 + */ + public void updateMicrophoneButtonState() { + if (micButton == null) return; + + boolean isActive = isMicrophoneActive(); + micButton.setSelected(isActive); + + // 根据配置获取图标资源 + int iconResource = getMicIconResource(isActive); + micButton.setImageResource(iconResource); + + micButton.setContentDescription(context.getString( + isActive ? R.string.mic_enabled : R.string.mic_disabled)); + micButton.setEnabled(true); + } + + /** + * 根据配置获取麦克风图标资源ID + * 使用资源映射表,代码更简洁 + */ + private int getMicIconResource(boolean isActive) { + // 读取配置 + PreferenceConfiguration prefConfig = PreferenceConfiguration.readPreferences(context); + String colorScheme = prefConfig.micIconColor != null ? prefConfig.micIconColor : "solid_white"; + + // 资源映射表:简化代码 + String resourceName = isActive ? + getEnabledIconResourceName(colorScheme) : + getDisabledIconResourceName(colorScheme); + + return context.getResources().getIdentifier( + resourceName, "drawable", context.getPackageName()); + } + + /** + * 获取启用状态的图标资源名称 + */ + private String getEnabledIconResourceName(String colorScheme) { + switch (colorScheme) { + case "gradient_blue": return "ic_btn_mic_gradient_blue"; + case "gradient_purple": return "ic_btn_mic_gradient_purple"; + case "gradient_green": return "ic_btn_mic_gradient_green"; + case "gradient_orange": return "ic_btn_mic_gradient_orange"; + case "gradient_red": return "ic_btn_mic_gradient_red"; + default: return "ic_btn_mic"; + } + } + + /** + * 获取禁用状态的图标资源名称 + */ + private String getDisabledIconResourceName(String colorScheme) { + switch (colorScheme) { + case "gradient_blue": return "ic_btn_mic_gradient_blue_disabled"; + case "gradient_purple": return "ic_btn_mic_gradient_purple_disabled"; + case "gradient_green": return "ic_btn_mic_gradient_green_disabled"; + case "gradient_orange": return "ic_btn_mic_gradient_orange_disabled"; + case "gradient_red": return "ic_btn_mic_gradient_red_disabled"; + default: return "ic_btn_mic_disabled"; + } + } + + /** + * 停止麦克风流 + */ + public void stopMicrophoneStream() { + if (microphoneStream != null) { + microphoneStream.stop(); + microphoneStream = null; + } + } + + private void showPermissionError() { + showMessage(context.getString(R.string.mic_permission_required)); + } + + private void showMessage(String message) { + if (context instanceof android.app.Activity) { + ((android.app.Activity) context).runOnUiThread(() -> { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + }); + } else { + // 如果不是 Activity,使用 Handler 确保在主线程中执行 + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + }); + } + } + + /** + * 测试麦克风状态 + */ + public void testMicrophoneStatus() { + String status = String.format("权限: %s, 流存在: %s, 可用: %s, 激活: %s", + hasMicrophonePermission(), + microphoneStream != null, + isMicrophoneAvailable(), + isMicrophoneActive()); + + LimeLog.info("麦克风状态: " + status); + Toast.makeText(context, status, Toast.LENGTH_LONG).show(); + } + + /** + * 设置麦克风启用状态 + */ + public void setEnableMic(boolean enable) { + this.enableMic = enable; + + if (enable) { + MicrophoneConfig.updateBitrateFromConfig(context); + } + + if (micButton != null) { + setupMicrophoneButton(); + } + } + + /** + * 获取麦克风流实例 + */ + public MicrophoneStream getMicrophoneStream() { + return microphoneStream; + } + + /** + * 设置麦克风默认状态为关闭 + */ + public void setDefaultStateOff() { + if (microphoneStream != null && microphoneStream.isRunning()) { + microphoneStream.pause(); + LimeLog.info("麦克风已设置为默认关闭状态"); + } + updateMicrophoneButtonState(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/audio/MicrophoneStream.java b/app/src/main/java/com/limelight/binding/audio/MicrophoneStream.java new file mode 100644 index 0000000000..29322d74ed --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/MicrophoneStream.java @@ -0,0 +1,505 @@ +package com.limelight.binding.audio; + +import com.limelight.LimeLog; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.jni.MoonBridge; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +public class MicrophoneStream implements MicrophoneCapture.MicrophoneDataCallback { + + private final NvConnection conn; + private MicrophoneCapture capture; + private OpusEncoder encoder; + private Thread senderThread; + private Thread checkHostRequestThread; + private DatagramSocket socket; + private InetAddress host; + private final AtomicBoolean running = new AtomicBoolean(false); // 麦克风流是否运行 + private final AtomicBoolean micActive = new AtomicBoolean(false); // 麦克风是否实际在捕获 + private final AtomicBoolean hostRequested = new AtomicBoolean(false); // 主机是否请求麦克风 + private LinkedBlockingQueue packetQueue; + private int sequenceNumber = 0; + private int micPort; + + public MicrophoneStream(NvConnection conn) { + this.conn = conn; + this.packetQueue = new LinkedBlockingQueue<>(MicrophoneConfig.MAX_QUEUE_SIZE); + + String hostAddress = conn.getHost(); + try { + this.host = InetAddress.getByName(hostAddress); + LimeLog.info("初始化麦克风流 - 主机地址: " + hostAddress); + } catch (Exception e) { + LimeLog.severe("初始化麦克风流 - 主机地址解析失败: " + e.getMessage()); + this.host = null; + } + } + + public boolean start() { + try { + // 重置音频诊断统计 + AudioDiagnostics.resetStatistics(); + + // 如果还没有初始化,先初始化 + if (!running.get()) { + // 启动请求检查线程 + // startHostRequestCheck(); + + // 如果主机已经请求麦克风,立即启动麦克风捕获 + if (MoonBridge.isMicrophoneRequested()) { + LimeLog.info("主机请求麦克风,开始捕获"); + hostRequested.set(true); + return startMicrophoneCapture(); + } else { + LimeLog.info("主机未请求麦克风,将等待请求"); + return true; + } + } else { + // 如果已经初始化,直接启动麦克风捕获 + if (!micActive.get()) { + LimeLog.info("重新启动麦克风捕获"); + return startMicrophoneCapture(); + } else { + LimeLog.info("麦克风已经在运行"); + return true; + } + } + } catch (Exception e) { + LimeLog.severe("启动麦克风流失败: " + e.getMessage()); + cleanup(); + return false; + } + } + + public boolean isMicrophoneAvailable() { + return MoonBridge.isMicrophoneRequested(); + } + + private void startHostRequestCheck() { + if (checkHostRequestThread != null && checkHostRequestThread.isAlive()) { + return; + } + + // 创建一个新状态变量,表示线程应该继续运行 + final AtomicBoolean checkThreadRunning = new AtomicBoolean(true); + + // 启动一个线程定期检查主机是否请求麦克风 + checkHostRequestThread = new Thread(() -> { + LimeLog.info("麦克风请求检查线程已启动"); + + while (checkThreadRunning.get()) { + boolean isRequested = MoonBridge.isMicrophoneRequested(); + + // 主机刚刚开始请求麦克风 + if (isRequested && !hostRequested.get()) { + hostRequested.set(true); + LimeLog.info("检测到主机请求麦克风,开始捕获"); + try { + startMicrophoneCapture(); + } catch (Exception e) { + LimeLog.severe("请求后启动麦克风失败: " + e.getMessage()); + } + } + // 主机刚刚停止请求麦克风 + else if (!isRequested && hostRequested.get()) { + hostRequested.set(false); + LimeLog.info("主机停止请求麦克风,暂停捕获"); + stopMicrophoneCapture(); + } + + try { + Thread.sleep(MicrophoneConfig.HOST_REQUEST_CHECK_INTERVAL_MS); + } catch (InterruptedException e) { + checkThreadRunning.set(false); + break; + } + } + + LimeLog.info("麦克风请求检查线程已结束"); + }, "MicRequestChecker"); + + // 设置中等优先级 + checkHostRequestThread.setPriority(Thread.NORM_PRIORITY); + checkHostRequestThread.start(); + } + + private void stopMicrophoneCapture() { + if (!micActive.get()) { + return; + } + + micActive.set(false); + + if (capture != null) { + capture.stop(); + capture = null; + } + + if (encoder != null) { + encoder.release(); + encoder = null; + } + + if (socket != null) { + socket.close(); + socket = null; + } + + packetQueue.clear(); + + // 不停止发送线程,因为我们将来可能需要重新启动麦克风 + } + + private boolean startMicrophoneCapture() { + if (micActive.get()) { + return true; + } + + try { + // 获取RTSP协商的麦克风端口 + micPort = MoonBridge.getMicPortNumber(); + if (micPort == 0) { + micPort = MicrophoneConfig.DEFAULT_MIC_PORT; + LimeLog.warning("未获取到协商的麦克风端口,使用默认端口: " + MicrophoneConfig.DEFAULT_MIC_PORT); + } else { + LimeLog.info("使用协商的麦克风端口: " + micPort); + } + + // 创建发送socket + socket = new DatagramSocket(); + + // 创建编码器 + encoder = new OpusEncoder(MicrophoneConfig.SAMPLE_RATE, MicrophoneConfig.CHANNELS, MicrophoneConfig.getOpusBitrate()); + + // 创建并启动麦克风捕获 + capture = new MicrophoneCapture(this); + if (!capture.start()) { + LimeLog.severe("无法启动麦克风捕获"); + cleanup(); + return false; + } + + // 如果发送线程还没有启动,启动它 + if (senderThread == null || !senderThread.isAlive()) { + running.set(true); + senderThread = new Thread(this::senderThreadProc, "MicSender"); + senderThread.setPriority(Thread.MAX_PRIORITY); + senderThread.start(); + } + + micActive.set(true); + LimeLog.info("麦克风捕获已启动"); + return true; + } catch (SecurityException e) { + LimeLog.severe("麦克风权限不足: " + e.getMessage()); + cleanup(); + return false; + } catch (Exception e) { + LimeLog.severe("启动麦克风捕获失败: " + e.getMessage()); + cleanup(); + return false; + } + } + + public void stop() { + if (checkHostRequestThread != null) { + checkHostRequestThread.interrupt(); + checkHostRequestThread = null; + } + + running.set(false); + micActive.set(false); + hostRequested.set(false); + + if (capture != null) { + capture.stop(); + capture = null; + } + + if (senderThread != null) { + try { + senderThread.join(300); // 等待最多300ms + } catch (InterruptedException e) {} + senderThread = null; + } + + cleanup(); + LimeLog.info("麦克风流已停止"); + } + + /** + * 暂停麦克风捕获(保持流运行) + */ + public void pause() { + if (micActive.get()) { + stopMicrophoneCapture(); + LimeLog.info("麦克风捕获已暂停"); + } + } + + /** + * 恢复麦克风捕获 + */ + public boolean resume() { + if (!micActive.get() && running.get()) { + LimeLog.info("尝试恢复麦克风捕获"); + boolean result = startMicrophoneCapture(); + if (result) { + LimeLog.info("麦克风捕获恢复成功"); + } else { + LimeLog.warning("麦克风捕获恢复失败"); + } + return result; + } + return false; + } + + public boolean isRunning() { + return running.get() && micActive.get(); + } + + /** + * 检查麦克风流是否已初始化 + */ + public boolean isInitialized() { + return running.get(); + } + + /** + * 获取当前音频连续性状态 + */ + public String getAudioContinuityStatus() { + return AudioDiagnostics.getCurrentStats(); + } + + /** + * 强制生成诊断报告 + */ + public void generateDiagnosticReport() { + AudioDiagnostics.reportStatistics(); + } + + private void cleanup() { + if (encoder != null) { + encoder.release(); + encoder = null; + } + + if (socket != null) { + socket.close(); + socket = null; + } + + packetQueue.clear(); + } + + private InetAddress getCurrentHost() { + try { + String hostAddress = conn.getHost(); + if (hostAddress != null && !hostAddress.isEmpty()) { + return InetAddress.getByName(hostAddress); + } + } catch (Exception e) { + LimeLog.warning("动态获取主机地址失败: " + e.getMessage()); + } + return host; + } + + @Override + public void onMicrophoneData(byte[] data, int offset, int length) { + if (!running.get() || !micActive.get() || encoder == null) { + return; + } + + try { + // 编码音频数据 + byte[] encoded = encoder.encode(data, offset, length); + if (encoded != null) { + // 记录编码成功 + AudioDiagnostics.recordFrameEncoded(); + + int queueSize = packetQueue.size(); + + if (queueSize >= MicrophoneConfig.MAX_QUEUE_SIZE) { + // 队列已满,丢弃最旧的数据包 + packetQueue.poll(); + AudioDiagnostics.recordFrameDropped(); + LimeLog.warning("音频队列已满,丢弃最旧数据包"); + } + + // 将编码数据加入队列 + if (!packetQueue.offer(encoded)) { + // 如果仍然无法加入队列,丢弃当前数据包 + AudioDiagnostics.recordFrameDropped(); + LimeLog.warning("无法将编码数据加入队列,丢弃当前数据包"); + } + } + } catch (Exception e) { + AudioDiagnostics.recordEncodingError(); + LimeLog.warning("音频编码错误: " + e.getMessage()); + } + } + + private void senderThreadProc() { + long lastSendTime = 0; + long sendCount = 0; + long totalLatency = 0; + long maxLatency = 0; + long lastStatsTime = System.currentTimeMillis(); + + while (running.get()) { + try { + // 检查连接状态和麦克风状态 + if (!hostRequested.get() || !micActive.get()) { + Thread.sleep(MicrophoneConfig.SENDER_THREAD_SLEEP_MS); + continue; + } + + // 额外检查:如果连接已断开,立即停止发送 + if (conn == null || !isConnectionActive()) { + LimeLog.info("检测到连接断开,停止麦克风发送"); + break; + } + + long currentTime = System.currentTimeMillis(); + + // 动态调整发送频率,基于队列大小 + int queueSize = packetQueue.size(); + long targetInterval = MicrophoneConfig.FRAME_INTERVAL_MS; + + // 如果队列过大,加快发送频率 + if (queueSize > MicrophoneConfig.MAX_QUEUE_SIZE * 0.7) { + targetInterval = Math.max(5, MicrophoneConfig.FRAME_INTERVAL_MS / 2); + } else if (queueSize < MicrophoneConfig.MAX_QUEUE_SIZE * 0.3) { + // 如果队列较小,可以稍微放慢 + targetInterval = MicrophoneConfig.FRAME_INTERVAL_MS; + } + + if (currentTime - lastSendTime < targetInterval) { + Thread.sleep(1); + continue; + } + + byte[] encoded = packetQueue.poll(); + if (encoded == null) { + // 没有数据可发送,短暂等待 + Thread.sleep(1); + continue; + } + + // 动态获取当前主机地址 + InetAddress currentHost = getCurrentHost(); + if (currentHost == null) { + LimeLog.warning("无法获取主机地址,跳过数据包发送"); + Thread.sleep(1); + continue; + } + + // 计算发送延迟 + long sendLatency = currentTime - lastSendTime; + totalLatency += sendLatency; + maxLatency = Math.max(maxLatency, sendLatency); + + // 构建正确的麦克风数据包头部(12字节) + ByteBuffer packetBuf = ByteBuffer.allocate(encoded.length + 12); + packetBuf.order(ByteOrder.LITTLE_ENDIAN); // 使用小端字节序 + + // flags (1字节) + packetBuf.put((byte) 0x00); + // packetType (1字节) - OPUS编码类型 + packetBuf.put((byte) 0x61); // MIC_PACKET_TYPE_OPUS + // sequenceNumber (2字节) + packetBuf.putShort((short) (sequenceNumber++ & 0xFFFF)); + // timestamp (4字节) + packetBuf.putInt((int)(currentTime & 0xFFFFFFFF)); + // ssrc (4字节) - 同步源标识符 + packetBuf.putInt(0x12345678); // 使用固定SSRC + + // 添加opus编码数据 + packetBuf.put(encoded); + + DatagramPacket packet = new DatagramPacket( + packetBuf.array(), packetBuf.capacity(), + currentHost, micPort); + socket.send(packet); + + lastSendTime = currentTime; + sendCount++; + + // 记录发送成功 + AudioDiagnostics.recordFrameSent(); + + // 每100个包记录一次详细统计信息 + if (sendCount % 100 == 0) { + long currentStatsTime = System.currentTimeMillis(); + long statsInterval = currentStatsTime - lastStatsTime; + double avgLatency = sendCount > 0 ? (double) totalLatency / sendCount : 0; + + LimeLog.info(String.format("麦克风发送统计: 包数=%d, 队列大小=%d, 平均延迟=%.1fms, 最大延迟=%dms, 统计间隔=%dms", + sendCount, queueSize, avgLatency, maxLatency, statsInterval)); + + // 重置统计 + lastStatsTime = currentStatsTime; + totalLatency = 0; + maxLatency = 0; + } + + } catch (InterruptedException e) { + break; + } catch (IOException e) { + AudioDiagnostics.recordSendingError(); + LimeLog.warning("发送麦克风数据错误: " + e.getMessage()); + + // 如果是网络错误,检查连接状态 + if (isNetworkError(e)) { + LimeLog.warning("检测到网络错误,停止麦克风发送"); + break; + } + + try { + Thread.sleep(MicrophoneConfig.SENDER_ERROR_RETRY_MS); + } catch (InterruptedException ex) { + break; + } + } + } + + LimeLog.info("麦克风发送线程已结束"); + } + + /** + * 检查连接是否仍然活跃 + */ + private boolean isConnectionActive() { + try { + // 尝试获取主机地址,如果失败说明连接已断开 + String hostAddress = conn.getHost(); + return hostAddress != null && !hostAddress.isEmpty(); + } catch (Exception e) { + return false; + } + } + + /** + * 检查是否为网络错误 + */ + private boolean isNetworkError(IOException e) { + String message = e.getMessage(); + return message != null && ( + message.contains("Network is unreachable") || + message.contains("No route to host") || + message.contains("Connection refused") || + message.contains("Connection reset") || + message.contains("Broken pipe") + ); + } +} diff --git a/app/src/main/java/com/limelight/binding/audio/OpusEncoder.java b/app/src/main/java/com/limelight/binding/audio/OpusEncoder.java new file mode 100644 index 0000000000..2c6d0bf4e2 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/audio/OpusEncoder.java @@ -0,0 +1,51 @@ +package com.limelight.binding.audio; + +import com.limelight.LimeLog; + +public class OpusEncoder { + private long nativePtr; + private final int sampleRate; + private final int channels; + private final int bitrate; + + static { + System.loadLibrary("moonlight-core"); + } + + public OpusEncoder(int sampleRate, int channels, int bitrate) { + this.sampleRate = sampleRate; + this.channels = channels; + this.bitrate = bitrate; + + nativePtr = nativeInit(sampleRate, channels, bitrate); + if (nativePtr == 0) { + throw new IllegalStateException("无法初始化Opus编码器"); + } + } + + public byte[] encode(byte[] pcmData, int offset, int length) { + if (nativePtr == 0) { + return null; + } + + return nativeEncode(nativePtr, pcmData, offset, length); + } + + public void release() { + if (nativePtr != 0) { + nativeDestroy(nativePtr); + nativePtr = 0; + } + } + + @Override + protected void finalize() throws Throwable { + release(); + super.finalize(); + } + + // 这些方法需要在原生代码中实现 + private static native long nativeInit(int sampleRate, int channels, int bitrate); + private static native byte[] nativeEncode(long handle, byte[] pcmData, int offset, int length); + private static native void nativeDestroy(long handle); +} diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java index f7c8c0d116..2638c5b31c 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java @@ -33,6 +33,7 @@ import android.view.Surface; import android.widget.Toast; +import com.limelight.GameMenu; import com.limelight.LimeLog; import com.limelight.R; import com.limelight.binding.input.driver.AbstractController; @@ -51,7 +52,11 @@ import org.cgutman.shieldcontrollerextensions.SceManager; import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.ArrayList; +import java.util.List; public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener { @@ -128,6 +133,279 @@ public class ControllerHandler implements InputManager.InputDeviceListener, UsbD private final PreferenceConfiguration prefConfig; private short currentControllers, initialControllers; + // Gyro-to-right-stick mapping sensitivity (deg/s for full deflection) + private static final float GYRO_DEFAULT_FULL_DEFLECTION_DPS = 180.0f; + private static final float TRIGGER_ACTIVATE_THRESHOLD = 0.2f; + public static final int GYRO_ACTIVATION_ALWAYS = -1000; + private static float clampFloat(float v, float min, float max) { + return v < min ? min : (v > max ? max : v); + } + + private static short clampShortToStickRange(int v) { + if (v > 0x7FFE) return (short) 0x7FFE; + if (v < -0x7FFE) return (short) -0x7FFE; + return (short) v; + } + + // Treat very small physical stick values as 0 to avoid jitter when summing + private static final short PHYS_EPSILON = 512; // ~1.6% of full scale + private static short denoisePhys(short v) { + return (short)(Math.abs(v) <= PHYS_EPSILON ? 0 : v); + } + + private void applyGyroToRightStick(short controllerNumber, float gyroXDegPerSec, float gyroYDegPerSec) { + // 计算陀螺仪映射到摇杆的值 + float effectiveSensitivity = 180.0f / prefConfig.gyroSensitivityMultiplier; + float scaledX = -clampFloat(gyroXDegPerSec / effectiveSensitivity, -1.0f, 1.0f); + float scaledY = clampFloat(gyroYDegPerSec / effectiveSensitivity, -1.0f, 1.0f); + + // 应用X轴反转设置 + if (prefConfig.gyroInvertXAxis) { + scaledX = -scaledX; + } + + // 应用Y轴反转设置 + if (prefConfig.gyroInvertYAxis) { + scaledY = -scaledY; + } + + short mappedX = (short) (scaledX * 0x7FFE); + short mappedY = (short) (scaledY * 0x7FFE); + + // 更新对应控制器上下文的陀螺仪摇杆值 + GenericControllerContext targetContext = findControllerContext(controllerNumber); + if (targetContext != null) { + updateContextWithGyroData(targetContext, mappedX, mappedY); + } + } + + private GenericControllerContext findControllerContext(short controllerNumber) { + // 首先查找USB设备上下文 + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext context = usbDeviceContexts.valueAt(i); + if (context.controllerNumber == controllerNumber) { + return context; + } + } + + // 查找输入设备上下文 + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext context = inputDeviceContexts.valueAt(i); + if (context.controllerNumber == controllerNumber) { + return context; + } + } + + // 控制器0的默认上下文回退 + if (controllerNumber == 0) { + return defaultContext; + } + + return null; + } + + private void updateContextWithGyroData(GenericControllerContext context, short mappedX, short mappedY) { + context.gyroRightStickX = mappedX; + context.gyroRightStickY = mappedY; + + // 如果陀螺仪到右摇杆映射启用且hold状态激活,则应用融合 + if (prefConfig.gyroToRightStick && context.gyroHoldActive) { + // 按轴叠加并限幅(物理值应用EPS去噪) + short px = denoisePhys(context.physRightStickX); + short py = denoisePhys(context.physRightStickY); + context.rightStickX = clampShortToStickRange(px + context.gyroRightStickX); + context.rightStickY = clampShortToStickRange(py + context.gyroRightStickY); + sendControllerInputPacket(context); + } + } + + public void setGyroToRightStickEnabled(boolean enabled) { + prefConfig.gyroToRightStick = enabled; + + if (enabled) { + // 确保控制器0有可用的sensorManager + InputDeviceContext defaultContext = inputDeviceContexts.get(0); + if (defaultContext != null && defaultContext.sensorManager == null) { + // 如果控制器0没有sensorManager,使用设备陀螺仪作为回退 + defaultContext.sensorManager = deviceSensorManager; + LimeLog.info("controller0 has no sensormanager, fallback to device gyro"); + } + + // 强制重新启用传感器以确保陀螺仪功能正常工作 + enableSensors(); + + handleSetMotionEventState((short) 0, MoonBridge.LI_MOTION_TYPE_GYRO, (short) 120); + recomputeGyroHoldForAllContexts(); + } else { + handleSetMotionEventState((short) 0, MoonBridge.LI_MOTION_TYPE_GYRO, (short) 0); + clearAllGyroStates(); + } + } + + // 在系统重新启用传感器时,检查并恢复陀螺仪功能 + public void onSensorsReenabled() { + if (prefConfig.gyroToRightStick) { + LimeLog.info("Sensors re-enabled, restoring gyro to right stick functionality"); + handleSetMotionEventState((short) 0, MoonBridge.LI_MOTION_TYPE_GYRO, (short) 120); + } + } + + private void clearAllGyroStates() { + // 清除所有控制器的陀螺仪摇杆数据和保持状态 + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext c = usbDeviceContexts.valueAt(i); + c.gyroRightStickX = 0; + c.gyroRightStickY = 0; + c.gyroHoldActive = false; + } + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext c = inputDeviceContexts.valueAt(i); + c.gyroRightStickX = 0; + c.gyroRightStickY = 0; + c.gyroHoldActive = false; + } + defaultContext.gyroRightStickX = 0; + defaultContext.gyroRightStickY = 0; + defaultContext.gyroHoldActive = false; + } + + private void recomputeGyroHoldForAllContexts() { + final boolean useL2 = prefConfig.gyroActivationKeyCode == KeyEvent.KEYCODE_BUTTON_L2; + final boolean useR2 = prefConfig.gyroActivationKeyCode == KeyEvent.KEYCODE_BUTTON_R2; + + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext c = usbDeviceContexts.valueAt(i); + boolean active = false; + if (useL2) { + active = (c.leftTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } else if (useR2) { + active = (c.rightTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } + c.gyroHoldActive = active; + } + + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext c = inputDeviceContexts.valueAt(i); + boolean active = false; + if (useL2) { + active = (c.leftTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } else if (useR2) { + active = (c.rightTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } + c.gyroHoldActive = active; + } + + if (useL2) { + defaultContext.gyroHoldActive = (defaultContext.leftTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } else if (useR2) { + defaultContext.gyroHoldActive = (defaultContext.rightTrigger & 0xFF) / 255.0f >= TRIGGER_ACTIVATE_THRESHOLD; + } else { + defaultContext.gyroHoldActive = false; + } + } + + // Future-proof activation handling helpers + private boolean computeAnalogActivation(float leftTrigger, float rightTrigger) { + if (prefConfig.gyroActivationKeyCode == GYRO_ACTIVATION_ALWAYS) { + return true; + } else if (prefConfig.gyroActivationKeyCode == KeyEvent.KEYCODE_BUTTON_L2) { + return leftTrigger >= TRIGGER_ACTIVATE_THRESHOLD; + } else if (prefConfig.gyroActivationKeyCode == KeyEvent.KEYCODE_BUTTON_R2) { + return rightTrigger >= TRIGGER_ACTIVATE_THRESHOLD; + } + return false; + } + + /** + * 处理来自虚拟控制器的陀螺仪数据 + * 这个方法允许虚拟控制器报告陀螺仪数据到ControllerHandler + */ + public void reportVirtualControllerGyro(float gx, float gy, float gz) { + if (!prefConfig.gyroToRightStick) { + return; + } + + // 使用控制器0(虚拟控制器的默认控制器编号) + applyGyroToRightStick((short) 0, gx, gy); + + // 对于虚拟控制器,如果陀螺仪激活,直接发送数据包 + if (defaultContext.gyroHoldActive) { + // 应用陀螺仪融合到右摇杆 + short px = denoisePhys(defaultContext.physRightStickX); + short py = denoisePhys(defaultContext.physRightStickY); + defaultContext.rightStickX = clampShortToStickRange(px + defaultContext.gyroRightStickX); + defaultContext.rightStickY = clampShortToStickRange(py + defaultContext.gyroRightStickY); + + // 直接发送数据包,不依赖reportOscState + sendControllerInputPacket(defaultContext); + } + } + + private void updateGyroHoldFromDigital(InputDeviceContext context, int keyCode, boolean isDown) { + if (!prefConfig.gyroToRightStick) { + context.gyroHoldActive = false; + return; + } + if (prefConfig.gyroActivationKeyCode == GYRO_ACTIVATION_ALWAYS) { + context.gyroHoldActive = true; + return; + } + if (keyCode == prefConfig.gyroActivationKeyCode) { + boolean was = context.gyroHoldActive; + context.gyroHoldActive = isDown; + if (was && !isDown) { + onGyroHoldDeactivated(context); + } + } + } + + private void updateGyroHoldFromDigital(GenericControllerContext context, int keyCode, boolean isDown) { + if (!prefConfig.gyroToRightStick) { + context.gyroHoldActive = false; + return; + } + if (prefConfig.gyroActivationKeyCode == GYRO_ACTIVATION_ALWAYS) { + context.gyroHoldActive = true; + return; + } + if (keyCode == prefConfig.gyroActivationKeyCode) { + boolean was = context.gyroHoldActive; + context.gyroHoldActive = isDown; + if (was && !isDown) { + onGyroHoldDeactivated(context); + } + } + } + + private void onGyroHoldDeactivated(GenericControllerContext context) { + context.gyroRightStickX = 0; + context.gyroRightStickY = 0; + // 恢复为纯物理值并立即发送 + context.rightStickX = context.physRightStickX; + context.rightStickY = context.physRightStickY; + sendControllerInputPacket(context); + } + + private void onGyroHoldDeactivated(InputDeviceContext context) { + context.gyroRightStickX = 0; + context.gyroRightStickY = 0; + // 立即发送仅物理摇杆的状态,确保停止模拟 + sendControllerInputPacket(context); + } + + private boolean isGyroHoldActiveFor(short controllerNumber) { + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext c = usbDeviceContexts.valueAt(i); + if (c.controllerNumber == controllerNumber && c.gyroHoldActive) return true; + } + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext c = inputDeviceContexts.valueAt(i); + if (c.controllerNumber == controllerNumber && c.gyroHoldActive) return true; + } + if (defaultContext.controllerNumber == controllerNumber && defaultContext.gyroHoldActive) return true; + return false; + } + public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) { this.activityContext = activityContext; this.conn = conn; @@ -313,6 +591,9 @@ public void enableSensors() { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); deviceContext.enableSensors(); } + + // 如果陀螺仪模拟右摇杆功能已启用,需要重新激活 + onSensorsReenabled(); } private static boolean hasJoystickAxes(InputDevice device) { @@ -769,8 +1050,10 @@ else if (deviceVibrator.hasVibrator()) { (Build.VERSION.SDK_INT == Build.VERSION_CODES.S && (context.vendorId == 0x054c || context.vendorId == 0x057e))) && // Sony or Nintendo prefConfig.gamepadMotionSensors) { - if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { - context.sensorManager = dev.getSensorManager(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { + context.sensorManager = dev.getSensorManager(); + } } } @@ -1065,7 +1348,7 @@ private short maxByMagnitude(short a, short b) { private short getActiveControllerMask() { if (prefConfig.multiController) { - return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0)); + return (short)(currentControllers | initialControllers | ((prefConfig.onscreenController | prefConfig.onscreenKeyboard) ? 1 : 0)); } else { // Only Player 1 is active with multi-controller disabled @@ -1590,13 +1873,18 @@ private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, flo context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); } + // Handle physical right stick separately and then apply gyro fusion if needed + short physX = 0, physY = 0; if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { Vector2d rightStickVector = populateCachedVector(rsX, rsY); handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); - context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); - context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); + physX = (short) (rightStickVector.getX() * 0x7FFE); + physY = (short) (-rightStickVector.getY() * 0x7FFE); + // cache physical right stick and apply EPS denoising + context.physRightStickX = denoisePhys(physX); + context.physRightStickY = denoisePhys(physY); } if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { @@ -1630,6 +1918,25 @@ private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, flo context.rightTrigger = (byte)(rt * 0xFF); } + // Handle gyro hold activation edge detection for analog triggers + boolean wasHold = context.gyroHoldActive; + context.gyroHoldActive = prefConfig.gyroToRightStick && computeAnalogActivation(lt, rt); + if (wasHold && !context.gyroHoldActive) { + onGyroHoldDeactivated(context); + } + + // Apply gyro fusion to right stick if needed + if (prefConfig.gyroToRightStick && context.gyroHoldActive) { + // 融合策略:按轴叠加并限幅 + short gx = context.gyroRightStickX; + short gy = context.gyroRightStickY; + context.rightStickX = clampShortToStickRange(physX + gx); + context.rightStickY = clampShortToStickRange(physY + gy); + } else { + context.rightStickX = physX; + context.rightStickY = physY; + } + if (context.hatXAxis != -1 && context.hatYAxis != -1) { context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG); if (hatX < -0.5) { @@ -2203,11 +2510,18 @@ public void onSensorChanged(SensorEvent sensorEvent) { if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) { // Convert from rad/s to deg/s + float gx = sensorEvent.values[x] * xFactor * 57.2957795f; + float gy = sensorEvent.values[y] * yFactor * 57.2957795f; + float gz = sensorEvent.values[z] * zFactor * 57.2957795f; + + if (prefConfig.gyroToRightStick) { + // Map device/controller gyro to right stick + applyGyroToRightStick(controllerNumber, gz, gx); + return; + } + conn.sendControllerMotionEvent((byte) controllerNumber, - motionType, - sensorEvent.values[x] * xFactor * 57.2957795f, - sensorEvent.values[y] * yFactor * 57.2957795f, - sensorEvent.values[z] * zFactor * 57.2957795f); + motionType, gx, gy, gz); } else { // Pass m/s^2 directly without conversion @@ -2371,7 +2685,7 @@ public boolean handleButtonUp(KeyEvent event) { if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 && event.getEventTime() - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS && prefConfig.mouseEmulation) { - context.toggleMouseEmulation(); + gestures.showGameMenu(context); } context.inputMap &= ~ControllerPacket.PLAY_FLAG; break; @@ -2474,6 +2788,7 @@ public boolean handleButtonUp(KeyEvent event) { return true; } context.leftTrigger = 0; + updateGyroHoldFromDigital(context, KeyEvent.KEYCODE_BUTTON_L2, false); break; case KeyEvent.KEYCODE_BUTTON_R2: if (context.rightTriggerAxisUsed) { @@ -2481,6 +2796,7 @@ public boolean handleButtonUp(KeyEvent event) { return true; } context.rightTrigger = 0; + updateGyroHoldFromDigital(context, KeyEvent.KEYCODE_BUTTON_R2, false); break; case KeyEvent.KEYCODE_UNKNOWN: // Paddles aren't mapped in any of the Android key layout files, @@ -2686,6 +3002,7 @@ public boolean handleButtonDown(KeyEvent event) { return true; } context.leftTrigger = (byte)0xFF; + updateGyroHoldFromDigital(context, KeyEvent.KEYCODE_BUTTON_L2, true); break; case KeyEvent.KEYCODE_BUTTON_R2: if (context.rightTriggerAxisUsed) { @@ -2693,6 +3010,7 @@ public boolean handleButtonDown(KeyEvent event) { return true; } context.rightTrigger = (byte)0xFF; + updateGyroHoldFromDigital(context, KeyEvent.KEYCODE_BUTTON_R2, true); break; case KeyEvent.KEYCODE_UNKNOWN: // Paddles aren't mapped in any of the Android key layout files, @@ -2793,12 +3111,30 @@ public void reportOscState(int buttonFlags, defaultContext.leftStickX = leftStickX; defaultContext.leftStickY = leftStickY; - defaultContext.rightStickX = rightStickX; - defaultContext.rightStickY = rightStickY; + // 更新物理右摇杆值(用于陀螺仪融合) + defaultContext.physRightStickX = rightStickX; + defaultContext.physRightStickY = rightStickY; defaultContext.leftTrigger = leftTrigger; defaultContext.rightTrigger = rightTrigger; + // 更新陀螺仪激活状态 + boolean wasHold = defaultContext.gyroHoldActive; + // 将byte类型的触发键值转换为float (0.0-1.0范围) + float leftTriggerFloat = (leftTrigger & 0xFF) / 255.0f; + float rightTriggerFloat = (rightTrigger & 0xFF) / 255.0f; + defaultContext.gyroHoldActive = prefConfig.gyroToRightStick && computeAnalogActivation(leftTriggerFloat, rightTriggerFloat); + + if (wasHold && !defaultContext.gyroHoldActive) { + // 确保我们立即停止任何残留的陀螺仪影响 + onGyroHoldDeactivated(defaultContext); + } + + if (!prefConfig.gyroToRightStick || !defaultContext.gyroHoldActive) { + defaultContext.rightStickX = rightStickX; + defaultContext.rightStickY = rightStickY; + } + defaultContext.inputMap = buttonFlags; sendControllerInputPacket(defaultContext); @@ -2814,6 +3150,14 @@ public void reportControllerState(int controllerId, int buttonFlags, return; } + // Gyro hold activation via analog LT/RT thresholds when mapped to L2/R2 + boolean wasHold = context.gyroHoldActive; + context.gyroHoldActive = prefConfig.gyroToRightStick && computeAnalogActivation(leftTrigger, rightTrigger); + if (wasHold && !context.gyroHoldActive) { + // Ensure we immediately stop any residual gyro influence + onGyroHoldDeactivated(context); + } + Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY); handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); @@ -2821,12 +3165,30 @@ public void reportControllerState(int controllerId, int buttonFlags, context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); - Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY); - - handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); - - context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); - context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); + // Fuse physical right stick with gyro input to avoid jitter. + // Strategy: if gyro-hold is active, take the component with larger magnitude per-axis. + // Otherwise, use physical stick only. + short physX, physY; + { + Vector2d rsv = populateCachedVector(rightStickX, rightStickY); + handleDeadZone(rsv, context.rightStickDeadzoneRadius); + physX = (short) (rsv.getX() * 0x7FFE); + physY = (short) (-rsv.getY() * 0x7FFE); + // cache physical right stick and apply EPS denoising + context.physRightStickX = denoisePhys(physX); + context.physRightStickY = denoisePhys(physY); + } + + if (prefConfig.gyroToRightStick && context.gyroHoldActive) { + // 融合策略:按轴叠加并限幅 + short gx = context.gyroRightStickX; + short gy = context.gyroRightStickY; + context.rightStickX = clampShortToStickRange(physX + gx); + context.rightStickY = clampShortToStickRange(physY + gy); + } else { + context.rightStickX = physX; + context.rightStickY = physY; + } if (leftTrigger <= context.triggerDeadzone) { leftTrigger = 0; @@ -2864,7 +3226,24 @@ public void deviceAdded(AbstractController controller) { usbDeviceContexts.put(controller.getControllerId(), context); } - class GenericControllerContext { + @Override + public void reportControllerMotion(int controllerId, byte motionType, float x, float y, float z) { + UsbDeviceContext context = usbDeviceContexts.get(controllerId); + if (context == null) { + return; + } + + // 当启用"陀螺仪模拟右摇杆"时,将手柄IMU的陀螺仪数据映射为右摇杆输入 + if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO && prefConfig.gyroToRightStick) { + applyGyroToRightStick(context.controllerNumber, x, y); + return; + } + + // 否则照常上报IMU数据到主机 + conn.sendControllerMotionEvent((byte) context.controllerNumber, motionType, x, y, z); + } + + class GenericControllerContext implements GameInputDevice { public int id; public boolean external; @@ -2884,9 +3263,15 @@ class GenericControllerContext { public byte rightTrigger = 0x00; public short rightStickX = 0x0000; public short rightStickY = 0x0000; + public short physRightStickX = 0x0000; + public short physRightStickY = 0x0000; + public short gyroRightStickX = 0x0000; + public short gyroRightStickY = 0x0000; public short leftStickX = 0x0000; public short leftStickY = 0x0000; + public boolean gyroHoldActive; + public boolean mouseEmulationActive; public int mouseEmulationLastInputMap; public final int mouseEmulationReportPeriod = 50; @@ -2917,10 +3302,23 @@ else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogSti } }; + @Override + public List getGameMenuOptions() { + List options = new ArrayList<>(); + options.add(new GameMenu.MenuOption(activityContext.getString(mouseEmulationActive ? + R.string.game_menu_toggle_mouse_off : R.string.game_menu_toggle_mouse_on), + true, this::toggleMouseEmulation, "game_menu_mouse_emulation", true)); + + return options; + } + public void toggleMouseEmulation() { mainThreadHandler.removeCallbacks(mouseEmulationRunnable); mouseEmulationActive = !mouseEmulationActive; - Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show(); + + int messageResId = mouseEmulationActive ? + R.string.game_menu_toggle_mouse_on : R.string.game_menu_toggle_mouse_off; + Toast.makeText(activityContext, messageResId, Toast.LENGTH_SHORT).show(); if (mouseEmulationActive) { mainThreadHandler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod); diff --git a/app/src/main/java/com/limelight/binding/input/GameInputDevice.java b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java new file mode 100644 index 0000000000..96df3ada0f --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java @@ -0,0 +1,16 @@ +package com.limelight.binding.input; + +import com.limelight.GameMenu; + +import java.util.List; + +/** + * Generic Input Device + */ +public interface GameInputDevice { + + /** + * @return list of device specific game menu options, e.g. configure a controller's mouse mode + */ + List getGameMenuOptions(); +} diff --git a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java index 5c5b4cc847..e7cf48ea6d 100644 --- a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java +++ b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java @@ -24,6 +24,14 @@ public class KeyboardTranslator implements InputManager.InputDeviceListener { public static final int VK_0 = 48; public static final int VK_9 = 57; public static final int VK_A = 65; + public static final int VK_B = 66; + public static final int VK_C = 67; + public static final int VK_D = 68; + public static final int VK_G = 71; + public static final int VK_L = 76; + public static final int VK_N = 78; + public static final int VK_O = 79; + public static final int VK_V = 86; public static final int VK_Z = 90; public static final int VK_NUMPAD0 = 96; public static final int VK_BACK_SLASH = 92; @@ -34,8 +42,10 @@ public class KeyboardTranslator implements InputManager.InputDeviceListener { public static final int VK_EQUALS = 61; public static final int VK_ESCAPE = 27; public static final int VK_F1 = 112; + public static final int VK_F11 = 122; public static final int VK_END = 35; public static final int VK_HOME = 36; + public static final int VK_MENU = 18; public static final int VK_NUM_LOCK = 144; public static final int VK_PAGE_UP = 33; public static final int VK_PAGE_DOWN = 34; @@ -54,6 +64,9 @@ public class KeyboardTranslator implements InputManager.InputDeviceListener { public static final int VK_BACK_QUOTE = 192; public static final int VK_QUOTE = 222; public static final int VK_PAUSE = 19; + public static final int VK_LWIN = 91; + public static final int VK_LSHIFT = 160; + public static final int VK_LCONTROL = 162; private static class KeyboardMapping { private final InputDevice device; @@ -188,7 +201,7 @@ else if (keycode >= KeyEvent.KEYCODE_F1 && break; case KeyEvent.KEYCODE_CTRL_LEFT: - translated = 0xA2; + translated = VK_LCONTROL; break; case KeyEvent.KEYCODE_CTRL_RIGHT: @@ -225,7 +238,7 @@ else if (keycode >= KeyEvent.KEYCODE_F1 && break; case KeyEvent.KEYCODE_META_LEFT: - translated = 0x5b; + translated = VK_LWIN; break; case KeyEvent.KEYCODE_META_RIGHT: @@ -277,7 +290,7 @@ else if (keycode >= KeyEvent.KEYCODE_F1 && break; case KeyEvent.KEYCODE_SHIFT_LEFT: - translated = 0xA0; + translated = VK_LSHIFT; break; case KeyEvent.KEYCODE_SHIFT_RIGHT: diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/ControllerManager.java b/app/src/main/java/com/limelight/binding/input/advance_setting/ControllerManager.java new file mode 100644 index 0000000000..1d2daad1fb --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/ControllerManager.java @@ -0,0 +1,117 @@ +package com.limelight.binding.input.advance_setting; + +import android.content.Context; +import android.view.LayoutInflater; +import android.widget.FrameLayout; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.config.PageConfigController; +import com.limelight.binding.input.advance_setting.element.ElementController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.SuperPagesController; + +public class ControllerManager { + + private FrameLayout advanceSettingView; + private FrameLayout fatherLayout; + private PageConfigController pageConfigController; + private TouchController touchController; + private SuperPagesController superPagesController; + private PageDeviceController pageDeviceController; + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private ElementController elementController; + private PageSuperMenuController pageSuperMenuController; + private KeyboardUIController keyboardUIController; + private Context context; + + public ControllerManager(FrameLayout layout, Context context){ + advanceSettingView = layout.findViewById(R.id.advance_setting_view); + this.fatherLayout = layout; + this.context = context; + pageSuperMenuController = new PageSuperMenuController(context,this); + } + + + public PageConfigController getPageConfigController() { + if (pageConfigController == null){ + pageConfigController = new PageConfigController(this,context); + } + return pageConfigController; + } + + + public TouchController getTouchController() { + if (touchController == null){ + FrameLayout layerElement = advanceSettingView.findViewById(R.id.layer_2_element); + touchController = new TouchController((Game) context,this,layerElement.findViewById(R.id.element_touch_view)); + } + return touchController; + } + + + public SuperPagesController getSuperPagesController() { + if (superPagesController == null){ + FrameLayout superPagesBox = advanceSettingView.findViewById(R.id.super_pages_box); + superPagesController = new SuperPagesController(superPagesBox,context); + } + return superPagesController; + } + + public PageDeviceController getPageDeviceController() { + if (pageDeviceController == null){ + pageDeviceController = new PageDeviceController(context,this); + } + return pageDeviceController; + } + + public SuperConfigDatabaseHelper getSuperConfigDatabaseHelper() { + if (superConfigDatabaseHelper == null){ + superConfigDatabaseHelper = new SuperConfigDatabaseHelper(context); + } + return superConfigDatabaseHelper; + } + + public ElementController getElementController() { + if (elementController == null){ + FrameLayout layerElement = advanceSettingView.findViewById(R.id.layer_2_element); + elementController = new ElementController(this,layerElement,context); + } + return elementController; + } + + public PageSuperMenuController getPageSuperMenuController() { + return pageSuperMenuController; + } + + public KeyboardUIController getKeyboardUIController(){ + if (keyboardUIController == null){ + FrameLayout layoutKeyboard = advanceSettingView.findViewById(R.id.layer_6_keyboard); + keyboardUIController = new KeyboardUIController(layoutKeyboard,this,context); + } + return keyboardUIController; + } + + public void refreshLayout(){ + getPageConfigController().initConfig(); + } + + /** + * 隐藏王冠功能界面 + */ + public void hide() { + if (advanceSettingView != null) { + advanceSettingView.setVisibility(android.view.View.GONE); + } + } + + /** + * 显示王冠功能界面 + */ + public void show() { + if (advanceSettingView != null) { + advanceSettingView.setVisibility(android.view.View.VISIBLE); + } + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/ItemPageSuperMenu.java b/app/src/main/java/com/limelight/binding/input/advance_setting/ItemPageSuperMenu.java new file mode 100644 index 0000000000..d1c112eff4 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/ItemPageSuperMenu.java @@ -0,0 +1,25 @@ +package com.limelight.binding.input.advance_setting; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.limelight.R; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; + +public class ItemPageSuperMenu { + private LinearLayout item; + + public ItemPageSuperMenu(String text, View.OnClickListener onClickListener,Context context){ + item = (LinearLayout) LayoutInflater.from(context).inflate(R.layout.item_page_super_menu,null); + ((TextView)item.findViewById(R.id.item_page_super_menu_text)).setText(text); + item.setOnClickListener(onClickListener); + } + + public View getView(){ + return item; + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.java new file mode 100644 index 0000000000..1e5348ed0a --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.java @@ -0,0 +1,81 @@ +package com.limelight.binding.input.advance_setting; + +import android.content.Context; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.SeekBar; + +import com.limelight.R; + +public class KeyboardUIController { + + private FrameLayout keyboardLayout; + private ControllerManager controllerManager; + private SeekBar opacitySeekbar; + private LinearLayout keyboard; + + public KeyboardUIController(FrameLayout keyboardLayout, ControllerManager controllerManager, Context context){ + this.keyboardLayout = keyboardLayout; + this.controllerManager = controllerManager; + opacitySeekbar = keyboardLayout.findViewById(R.id.float_keyboard_seekbar); + keyboard = keyboardLayout.findViewById(R.id.keyboard_drawing); + + opacitySeekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + float alpha = (float) (progress * 0.1); + keyboardLayout.setAlpha(alpha); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + + View.OnTouchListener touchListener = new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + String keyString = (String) v.getTag(); + int keyCode = Integer.parseInt(keyString.substring(1)); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + // 处理按下事件 + v.setBackgroundResource(R.drawable.confirm_square_border); + controllerManager.getElementController().sendKeyEvent(true,(short) keyCode); + return true; + case MotionEvent.ACTION_UP: + // 处理释放事件 + v.setBackgroundResource(R.drawable.square_border); + controllerManager.getElementController().sendKeyEvent(false,(short) keyCode); + return true; + } + return false; + } + }; + for (int i = 0; i < keyboard.getChildCount(); i++){ + LinearLayout keyboardRow = (LinearLayout) keyboard.getChildAt(i); + for (int j = 0; j < keyboardRow.getChildCount(); j++){ + keyboardRow.getChildAt(j).setOnTouchListener(touchListener); + } + } + } + + public void toggle() { + if (keyboardLayout.getVisibility() == View.VISIBLE){ + keyboardLayout.setVisibility(View.INVISIBLE); + } else { + keyboardLayout.setVisibility(View.VISIBLE); + } + + + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/PageDeviceController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/PageDeviceController.java new file mode 100644 index 0000000000..fa0f7c9202 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/PageDeviceController.java @@ -0,0 +1,107 @@ +package com.limelight.binding.input.advance_setting; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.limelight.R; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; + +public class PageDeviceController { + + public interface DeviceCallBack{ + void OnKeyClick(TextView key); + } + + private Context context; + private ControllerManager controllerManager; + private SuperPageLayout devicePage; + private DeviceCallBack deviceCallBack; + private LinearLayout keyboardDrawing; + private FrameLayout mouseDrawing; + private FrameLayout gamepadDrawing; + + public PageDeviceController(Context context, ControllerManager controllerManager) { + this.context = context; + this.controllerManager = controllerManager; + devicePage = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_device,null); + keyboardDrawing = devicePage.findViewById(R.id.keyboard_drawing); + mouseDrawing = devicePage.findViewById(R.id.mouse_drawing); + gamepadDrawing = devicePage.findViewById(R.id.gamepad_drawing); + + View.OnClickListener onClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + // 确保回调不为空,并且点击的是TextView,避免意外的类型转换错误 + if (deviceCallBack != null && v instanceof TextView) { + deviceCallBack.OnKeyClick((TextView) v); + close(); + } + } + }; + setListenersForDevice(devicePage,onClickListener); + + devicePage.findViewById(R.id.device_cancel).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + close(); + } + }); + + + } + + public void open(DeviceCallBack deviceCallBack, int keyboardVisible, int mouseVisible, int gamepadVisible){ + this.deviceCallBack = deviceCallBack; + keyboardDrawing.setVisibility(keyboardVisible); + mouseDrawing.setVisibility(mouseVisible); + gamepadDrawing.setVisibility(gamepadVisible); + controllerManager.getSuperPagesController().openNewPage(devicePage); + } + + private void setListenersForDevice(ViewGroup viewGroup, View.OnClickListener listener) { + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + // 只为带有tag的TextView设置监听器,这些是实际的按键 + if (child instanceof TextView && child.getTag() != null) { + child.setOnClickListener(listener); + } else if (child instanceof ViewGroup) { + setListenersForDevice((ViewGroup) child, listener); + } + } + } + + /** + * 根据按键的tag值(例如 "k51")安全地获取其显示的名称(例如 "W")。 + * @param value 要查找的按键的tag值。 + * @return 按键的显示名称,如果找不到则返回一个安全的默认值。 + */ + public String getKeyNameByValue(String value){ + // 1. 预处理无效的输入值 + if (value == null || value.isEmpty() || value.equals("null")) { + return "空"; // 返回一个明确的“未设置”状态 + } + + // 2. 查找视图 + View foundView = devicePage.findViewWithTag(value); + + // 3. 安全地检查和转换 + if (foundView instanceof TextView) { + // 确保视图是TextView后,才进行转换和获取文本 + return ((TextView) foundView).getText().toString(); + } + + // 4. 如果找不到视图,或者找到的视图不是TextView,返回原始tag值 + // 这对于调试非常有用,用户可以看到是哪个值出了问题 + return value; + } + + + public void close(){ + controllerManager.getSuperPagesController().openNewPage(devicePage.getLastPage()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/PageSuperMenuController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/PageSuperMenuController.java new file mode 100644 index 0000000000..664e260826 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/PageSuperMenuController.java @@ -0,0 +1,110 @@ +package com.limelight.binding.input.advance_setting; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.element.ElementController; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.binding.input.advance_setting.superpage.SuperPagesController; + +public class PageSuperMenuController { + + private Context context; + private ControllerManager controllerManager; + private SuperPagesController superPagesController; + private SuperPageLayout pageNull; + private SuperPageLayout superMenuPage; + private LinearLayout listLayout; + + public PageSuperMenuController(Context context, ControllerManager controllerManager) { + this.context = context; + this.controllerManager = controllerManager; + superMenuPage = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_super_menu,null); + listLayout = superMenuPage.findViewById(R.id.page_super_menu_list); + superPagesController = controllerManager.getSuperPagesController(); + pageNull = superPagesController.getPageNull(); + pageNull.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(superMenuPage); + } + }); + superMenuPage.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(pageNull); + pageNull.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(superMenuPage); + } + }); + } + }); + superMenuPage.findViewById(R.id.page_super_menu_config_page).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + controllerManager.getPageConfigController().open(); + } + }); + + superMenuPage.findViewById(R.id.page_super_menu_edit_mode).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + controllerManager.getElementController().changeMode(ElementController.Mode.Edit); + controllerManager.getElementController().open(); + } + }); + superMenuPage.findViewById(R.id.page_super_menu_disconnect).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ((Game)context).disconnect(); + } + }); + + // 添加切换到普通模式的点击事件 + superMenuPage.findViewById(R.id.page_super_menu_toggle_normal_mode).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 切换到普通模式 +// ((Game)context).setCrownFeatureEnabled(false); + ((Game)context).setcurrentBackKeyMenu(Game.BackKeyMenuMode.GAME_MENU); + superPagesController.returnOperation(); + android.widget.Toast.makeText(context, context.getString(com.limelight.R.string.toast_back_key_menu_switch_1), android.widget.Toast.LENGTH_SHORT).show(); + } + }); + + } + + public void open(){ + superPagesController.openNewPage(pageNull); + pageNull.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(superMenuPage); + } + }); + superMenuPage.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(pageNull); + pageNull.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(superMenuPage); + } + }); + } + }); + } + + public void addItem(ItemPageSuperMenu itemPageSuperMenu){ + listLayout.addView(itemPageSuperMenu.getView(),listLayout.getChildCount() - 1); + } + + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/TouchController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/TouchController.java new file mode 100644 index 0000000000..70973dbe56 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/TouchController.java @@ -0,0 +1,113 @@ +package com.limelight.binding.input.advance_setting; + +import android.os.Build; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + +import com.limelight.Game; +import com.limelight.binding.input.touch.RelativeTouchContext; +import com.limelight.binding.input.touch.TouchContext; + +public class TouchController { + + final private Game game; + final private ControllerManager controllerManager; + final private View touchView; + private double xFactor; + private double yFactor; + + // --- 新增:一个可复用的、用于屏蔽所有触摸的监听器 --- + // 这比每次都创建一个新的匿名内部类效率更高。 + private final View.OnTouchListener blockingListener = new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + // 消费掉事件,什么也不做。这就像一个“触摸护盾”。 + return true; + } + }; + + public TouchController(Game game, ControllerManager controllerManager, View touchView) { + this.game = game; + this.controllerManager = controllerManager; + this.touchView = touchView; + + // 将初始状态设置为“激活” + touchView.setOnTouchListener(game); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + touchView.requestUnbufferedDispatch( + InputDevice.SOURCE_CLASS_BUTTON | + InputDevice.SOURCE_CLASS_JOYSTICK | + InputDevice.SOURCE_CLASS_POINTER | + InputDevice.SOURCE_CLASS_POSITION | + InputDevice.SOURCE_CLASS_TRACKBALL + ); + } + } + + // --- 以下的旧方法保持不变 --- + public void adjustTouchSense(int sense) { + for (TouchContext aTouchContext : game.getRelativeTouchContextMap()) { + ((RelativeTouchContext) aTouchContext).adjustMsense(sense * 0.01); + } + } + + public void setTouchMode(boolean enableRelativeTouch) { + game.setTouchMode(enableRelativeTouch); + } + + public void setEnhancedTouch(boolean enableRelativeTouch) { + game.setEnhancedTouch(enableRelativeTouch); + } + + public void mouseMove(float deltaX, float deltaY, double sense) { + float preDeltaX = deltaX; + float preDeltaY = deltaY; + xFactor = Game.REFERENCE_HORIZ_RES / (double) touchView.getWidth() * sense; + yFactor = Game.REFERENCE_VERT_RES / (double) touchView.getHeight() * sense; + deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); + deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); + if (preDeltaX < 0) { + deltaX = -deltaX; + } + if (preDeltaY < 0) { + deltaY = -deltaY; + } + game.mouseMove((int) deltaX, (int) deltaY); + } + + // --- 修改及新增的触摸状态控制方法 --- + + /** + * 控制虚拟手柄的触摸是“激活”状态还是“屏蔽”状态。 + * @param enable true 为激活 (由 Game 处理触摸), false 为屏蔽 (触摸被消费掉)。 + */ + public void enableTouch(boolean enable) { + if (enable) { + // 状态 1: 激活 - 触摸事件由 Game Activity 处理,用于虚拟按键。 + touchView.setOnTouchListener(game); + } else { + // 状态 2: 屏蔽 - 触摸事件被 blockingListener 拦截并消费掉。 + touchView.setOnTouchListener(blockingListener); + } + } + + /** + * --- 这是新增的关键方法 --- + * 控制触摸事件是否应该完全“绕过”虚拟按键层。 + * 这用于实现底层 StreamView 的手势缩放等功能。 + * @param bypass true 表示移除监听器,让事件“穿透”过去。 + * false 表示恢复默认的监听器 (即“激活”状态)。 + */ + public void setTouchBypass(boolean bypass) { + if (bypass) { + // 状态 3: 绕过 - 不设置任何监听器,这样触摸事件就会传递给 + // 下层的视图 (例如,用于手势操作的 StreamView)。 + touchView.setOnTouchListener(null); + } else { + // 当关闭“绕过”模式时,恢复到默认的“激活”状态。 + touchView.setOnTouchListener(game); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/config/PageConfigController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/config/PageConfigController.java new file mode 100644 index 0000000000..a7c55ca769 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/config/PageConfigController.java @@ -0,0 +1,470 @@ +package com.limelight.binding.input.advance_setting.config; + +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.Game; // 确保导入 Game 类 +import com.limelight.R; +import com.limelight.binding.input.advance_setting.ControllerManager; +import com.limelight.binding.input.advance_setting.element.ElementController; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; + +import java.util.ArrayList; +import java.util.List; + +public class PageConfigController { + + + private static final String CURRENT_CONFIG_KEY = "current_config_id"; + public static final String COLUMN_STRING_CONFIG_NAME = "config_name"; + public static final String COLUMN_BOOLEAN_TOUCH_ENABLE = "touch_enable"; + public static final String COLUMN_BOOLEAN_TOUCH_MODE = "touch_mode"; + private static final String COLUMN_INT_TOUCH_SENSE = "touch_sense"; + public static final String COLUMN_BOOLEAN_GAME_VIBRATOR = "game_vibrator"; + public static final String COLUMN_BOOLEAN_BUTTON_VIBRATOR = "button_vibrator"; + public static final String COLUMN_LONG_CONFIG_ID = "config_id"; + private static final String COLUMN_INT_MOUSE_WHEEL_SPEED = "mouse_wheel_speed"; + public static final String COLUMN_BOOLEAN_ENHANCED_TOUCH = "enhanced_touch"; + + + private SuperPageLayout pageConfig; + private Context context; + private ControllerManager controllerManager; + private Long currentConfigId = 0L; + private Spinner configSelectSpinner; + private LinearLayout enhancedTouchLayout; + + private List configIds = new ArrayList<>(); + private List configNames = new ArrayList<>(); + + + + public PageConfigController(ControllerManager controllerManager, Context context){ + this.context = context; + this.pageConfig = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_config,null); + this.enhancedTouchLayout = pageConfig.findViewById(R.id.enhanced_touch_layout); + this.controllerManager = controllerManager; + configSelectSpinner = pageConfig.findViewById(R.id.config_select_spinner); + + //新增布局按钮 + pageConfig.findViewById(R.id.add_config_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + SuperPageLayout pageWindow = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_window,null); + TextView title = pageWindow.findViewById(R.id.window_title); + title.setText("配置名称"); + EditText editText = pageWindow.findViewById(R.id.window_edittext); + //窗口确认按钮 + pageWindow.findViewById(R.id.window_confirm).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String configName = editText.getText().toString(); + if (!configName.matches("^.{1,10}$")){ + Toast.makeText(context,"名称只能由1-20个字符组成",Toast.LENGTH_SHORT).show(); + return; + } + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_LONG_CONFIG_ID,System.currentTimeMillis()); + contentValues.put(COLUMN_STRING_CONFIG_NAME,configName); + contentValues.put(COLUMN_BOOLEAN_TOUCH_ENABLE,String.valueOf(true)); + contentValues.put(COLUMN_BOOLEAN_TOUCH_MODE,String.valueOf(true)); + contentValues.put(COLUMN_INT_TOUCH_SENSE,100); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().insertConfig(contentValues); + returnPrePage(pageWindow.getLastPage()); + loadAllConfigToSpinner(); + loadCurrentConfig(); + } + }); + //窗口取消按钮 + pageWindow.findViewById(R.id.window_cancel).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + returnPrePage(pageWindow.getLastPage()); + } + }); + controllerManager.getSuperPagesController().openNewPage(pageWindow); + } + }); + //重命名布局按钮 + pageConfig.findViewById(R.id.rename_config_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + SuperPageLayout pageWindow = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_window,null); + TextView title = pageWindow.findViewById(R.id.window_title); + title.setText("配置名称"); + EditText editText = pageWindow.findViewById(R.id.window_edittext); + editText.setText((String)configSelectSpinner.getSelectedItem()); + //窗口确认按钮 + pageWindow.findViewById(R.id.window_confirm).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentConfigId.equals(0L)){ + returnPrePage(pageWindow.getLastPage()); + return; + } + String configNewName = editText.getText().toString(); + if (!configNewName.matches("^.{1,10}$")){ + Toast.makeText(context,"名称只能由1-20个字符组成",Toast.LENGTH_SHORT).show(); + return; + } + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_CONFIG_NAME,configNewName); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + returnPrePage(pageWindow.getLastPage()); + loadAllConfigToSpinner(); + loadCurrentConfig(); + } + }); + //窗口取消按钮 + pageWindow.findViewById(R.id.window_cancel).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + returnPrePage(pageWindow.getLastPage()); + } + }); + controllerManager.getSuperPagesController().openNewPage(pageWindow); + } + }); + //删除布局按钮 + pageConfig.findViewById(R.id.delete_config_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + SuperPageLayout pageWindow = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_window,null); + TextView title = pageWindow.findViewById(R.id.window_title); + String titleString = "是否删除:" + configNames.get(configIds.indexOf(currentConfigId)); + title.setText(titleString); + pageWindow.findViewById(R.id.window_edittext).setVisibility(View.GONE); + //窗口确认按钮 + pageWindow.findViewById(R.id.window_confirm).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentConfigId.equals(0L)){ + returnPrePage(pageWindow.getLastPage()); + return; + } + controllerManager.getSuperConfigDatabaseHelper().deleteConfig(currentConfigId); + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); + editor.putLong(CURRENT_CONFIG_KEY,0L); + editor.apply(); + loadAllConfigToSpinner(); + loadCurrentConfig(); + returnPrePage(pageWindow.getLastPage()); + } + }); + //窗口取消按钮 + pageWindow.findViewById(R.id.window_cancel).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + returnPrePage(pageWindow.getLastPage()); + } + }); + controllerManager.getSuperPagesController().openNewPage(pageWindow); + } + }); + + // 退出王冠配置按钮 + pageConfig.findViewById(R.id.exit_crown_config_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 将 Game Activity 中的 currentBackKeyMenu 标志位设为 GAME_MENU,以切换回GAME_MENU模式 + ((Game)context).setcurrentBackKeyMenu(Game.BackKeyMenuMode.GAME_MENU); + + // 关闭当前的高级设置页面,相当于按返回键 + controllerManager.getSuperPagesController().returnOperation(); + + // 显示提示信息 + Toast.makeText(context, context.getString(R.string.toast_back_key_menu_switch_1), Toast.LENGTH_SHORT).show(); + } + }); + + } + + public void initConfig(){ + loadAllConfigToSpinner(); + loadCurrentConfig(); + } + + private void loadAllConfigToSpinner(){ + configIds = controllerManager.getSuperConfigDatabaseHelper().queryAllConfigIds(); + //判断是否有default布局 + if (!configIds.contains(0L)){ + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_LONG_CONFIG_ID,0L); + contentValues.put(COLUMN_STRING_CONFIG_NAME,"default"); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().insertConfig(contentValues); + configIds = controllerManager.getSuperConfigDatabaseHelper().queryAllConfigIds(); + } + configNames.clear(); + for (Long configId : configIds){ + String name = (String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(configId,COLUMN_STRING_CONFIG_NAME,"default"); + configNames.add(name); + } + ArrayAdapter adapter = new ArrayAdapter<>( + context, + R.layout.app_spinner_item, + configNames + ); + configSelectSpinner.setAdapter(adapter); + + } + + private void loadCurrentConfig(){ + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + currentConfigId = sharedPreferences.getLong(CURRENT_CONFIG_KEY,0L); + //spinner选中 + for (int i = 0;i < configIds.size();i ++){ + if (currentConfigId.equals(configIds.get(i))){ + configSelectSpinner.setOnItemSelectedListener(null); + configSelectSpinner.setSelection(i); + configSelectSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + Long configId = configIds.get(position); + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); + editor.putLong(CURRENT_CONFIG_KEY,configId); + editor.apply(); + if (!configId.equals(currentConfigId)){ + loadCurrentConfig(); + } + + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + break; + } + } + + loadMouseEnable(); + loadMouseMode(); + loadMouseSense(); + loadMouseWheelSpeed(); + loadGameVibrator(); + loadButtonVibrator(); + loadEnhancedTouch(); + controllerManager.getElementController().loadAllElement(currentConfigId); + if (currentConfigId == 0L){ + pageConfig.findViewById(R.id.rename_config_button).setVisibility(View.GONE); + pageConfig.findViewById(R.id.delete_config_button).setVisibility(View.GONE); + } else { + pageConfig.findViewById(R.id.rename_config_button).setVisibility(View.VISIBLE); + pageConfig.findViewById(R.id.delete_config_button).setVisibility(View.VISIBLE); + } + } + + private void loadMouseEnable(){ + //mouse enable + Switch mouseEnableSwitch = pageConfig.findViewById(R.id.mouse_enable_switch); + boolean mouseEnable = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_BOOLEAN_TOUCH_ENABLE, String.valueOf(true))); + //设置switch + mouseEnableSwitch.setOnCheckedChangeListener(null); + mouseEnableSwitch.setChecked(mouseEnable); + //做实际的设置 + controllerManager.getTouchController().enableTouch(mouseEnable); + //设置listener + mouseEnableSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_BOOLEAN_TOUCH_ENABLE,String.valueOf(isChecked)); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + //做实际的设置 + controllerManager.getTouchController().enableTouch(isChecked); + } + }); + } + + private void loadMouseMode(){ + //mouse mode + Switch mouseModeSwitch = pageConfig.findViewById(R.id.trackpad_enable_switch); + boolean mouseMode = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(true))); + if (mouseMode) { + enhancedTouchLayout.setVisibility(View.GONE); // 如果是触控板模式,隐藏 + } else { + enhancedTouchLayout.setVisibility(View.VISIBLE); // 否则,显示 + } + mouseModeSwitch.setOnCheckedChangeListener(null); + mouseModeSwitch.setChecked(mouseMode); + controllerManager.getTouchController().setTouchMode(mouseMode); + mouseModeSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + // 如果开启了触控板模式,隐藏“多点触控模式”布局 + enhancedTouchLayout.setVisibility(View.GONE); + } else { + // 如果关闭了触控板模式,显示“多点触控模式”布局 + enhancedTouchLayout.setVisibility(View.VISIBLE); + } + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_BOOLEAN_TOUCH_MODE,String.valueOf(isChecked)); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + //做实际的设置 + controllerManager.getTouchController().setTouchMode(isChecked); + } + }); + } + + private void loadMouseSense(){ + NumberSeekbar mouseSenseSeekBar = pageConfig.findViewById(R.id.mouse_sense_number_seekbar); + int mouseSense = ((Long) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_INT_TOUCH_SENSE, 100L)).intValue(); + mouseSenseSeekBar.setValueWithNoCallBack(mouseSense); + controllerManager.getTouchController().adjustTouchSense(mouseSense); + mouseSenseSeekBar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_TOUCH_SENSE,seekBar.getProgress()); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + //做实际的设置 + controllerManager.getTouchController().adjustTouchSense(seekBar.getProgress()); + } + }); + } + + private void loadMouseWheelSpeed() { + NumberSeekbar mouseWheelSpeedSeekBar = pageConfig.findViewById(R.id.mouse_wheel_speed_number_seekbar); + int mouseWheelSpeed = ((Long) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_INT_MOUSE_WHEEL_SPEED, 20L)).intValue(); + mouseWheelSpeedSeekBar.setValueWithNoCallBack(mouseWheelSpeed); + // 数值越小,滚动越快,所以用 120 - mouseWheelSpeed 来转换 + ElementController.setMouseScrollRepeatInterval(120 - mouseWheelSpeed); + mouseWheelSpeedSeekBar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_MOUSE_WHEEL_SPEED, seekBar.getProgress()); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + //做实际的设置 + // 数值越小,滚动越快,所以用 120 - seekBar.getProgress() 来转换 + ElementController.setMouseScrollRepeatInterval(120 - seekBar.getProgress()); + } + }); + } + + + private void loadGameVibrator(){ + //mouse mode + Switch gameVibratorSwitch = pageConfig.findViewById(R.id.game_vibrator_enable_switch); + boolean gameVibrator = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_BOOLEAN_GAME_VIBRATOR, String.valueOf(false))); + gameVibratorSwitch.setOnCheckedChangeListener(null); + gameVibratorSwitch.setChecked(gameVibrator); + controllerManager.getElementController().setGameVibrator(gameVibrator); + gameVibratorSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_BOOLEAN_GAME_VIBRATOR,String.valueOf(isChecked)); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + //做实际的设置 + controllerManager.getElementController().setGameVibrator(isChecked); + } + }); + } + + private void loadButtonVibrator(){ + //mouse mode + Switch buttonVibratorSwitch = pageConfig.findViewById(R.id.button_vibrator_enable_switch); + boolean buttonVibrator = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, COLUMN_BOOLEAN_BUTTON_VIBRATOR, String.valueOf(false))); + buttonVibratorSwitch.setOnCheckedChangeListener(null); + buttonVibratorSwitch.setChecked(buttonVibrator); + controllerManager.getElementController().setButtonVibrator(buttonVibrator); + buttonVibratorSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_BOOLEAN_BUTTON_VIBRATOR,String.valueOf(isChecked)); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId,contentValues); + //做实际的设置 + controllerManager.getElementController().setButtonVibrator(isChecked); + } + }); + } + + private void loadEnhancedTouch() { + Switch enhancedTouchSwitch = pageConfig.findViewById(R.id.enhanced_touch_switch); + + // 从数据库读取值,提供一个默认值 'false' + String dbValue = (String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute( + currentConfigId, + COLUMN_BOOLEAN_ENHANCED_TOUCH, + String.valueOf(false) + ); + boolean isEnabled = Boolean.parseBoolean(dbValue); + // 更新 UI,但不触发监听器 + enhancedTouchSwitch.setOnCheckedChangeListener(null); + enhancedTouchSwitch.setChecked(isEnabled); + controllerManager.getTouchController().setEnhancedTouch(isEnabled); + // 重新设置监听器,用于用户手动操作 + enhancedTouchSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + ContentValues contentValues = new ContentValues(); + // 使用我们新定义的常量来保存新的状态 + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(isChecked)); + // 更新到数据库 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + controllerManager.getTouchController().setEnhancedTouch(isChecked); + }); + } + + public Long getCurrentConfigId(){ + return currentConfigId; + } + + public void open(){ + controllerManager.getSuperPagesController().openNewPage(pageConfig); + pageConfig.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + controllerManager.getSuperPagesController().openNewPage(controllerManager.getSuperPagesController().getPageNull()); + } + }); + + } + + public void returnPrePage(SuperPageLayout prePage){ + controllerManager.getSuperPagesController().openNewPage(prePage); + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/AnalogStick.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/AnalogStick.java new file mode 100644 index 0000000000..0cfc6b3463 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/AnalogStick.java @@ -0,0 +1,832 @@ +//手柄摇杆 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +public class AnalogStick extends Element { + + private static final String COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS = COLUMN_INT_ELEMENT_SENSE; + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + private int moveMode = 0; // 0: 绝对位置模式, 1: 相对移动模式 + + /** + * Listener interface to update registered observers. + */ + public interface AnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private boolean isFirstTouch = true; + private float FirstTouchX = 0; + private float FirstTouchY = 0; + + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private AnalogStick analogStick; + + private ElementController.SendEventHandler middleValueSendHandler; + private ElementController.SendEventHandler valueSendHandler; + private String middleValue; + private String value; + private int radius; + private int deadZoneRadius; //dead zone radius + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + private SuperPageLayout analogStickPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + private final Paint paintStick = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + private AnalogStick.STICK_STATE stick_state = AnalogStick.STICK_STATE.NO_MOVEMENT; + private AnalogStick.CLICK_STATE click_state = AnalogStick.CLICK_STATE.SINGLE; + + private AnalogStickListener listener; + private long timeLastClick = 0; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? 0 : Math.PI; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public AnalogStick(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + + this.pageDeviceController = pageDeviceController; + this.analogStick = this; + + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels; + super.widthMin = 100; + super.heightMax = displayMetrics.heightPixels; + super.heightMin = 100; + + paintBackground.setStyle(Paint.Style.FILL); + paintStick.setStyle(Paint.Style.STROKE); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + try { + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + deadZoneRadius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + middleValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_MIDDLE_VALUE); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + moveMode = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_MODE)).intValue(); + } catch (Exception e) { + if (radius == 0) radius = 100; + if (deadZoneRadius == 0) deadZoneRadius = 30; + if (thick == 0) thick = 5; + if (normalColor == 0) normalColor = 0xF0888888; + if (pressedColor == 0) pressedColor = 0xF00000FF; + if (backgroundColor == 0) backgroundColor = 0x00FFFFFF; + if (middleValue == null) middleValue = "g64"; + if (value == null) value = "LS"; + if (moveMode == 0) moveMode = 0; + System.out.println("加载按摇杆时发生错误,已应用默认值: " + e.getMessage()); + } + middleValueSendHandler = controller.getSendEventHandler(middleValue); + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + + valueSendHandler = controller.getSendEventHandler(value); + + listener = new AnalogStickListener() { + @Override + public void onMovement(float x, float y) { + valueSendHandler.sendEvent((int) (x * 0x7FFE), (int) (y * 0x7FFE)); + } + + @Override + public void onClick() { + elementController.buttonVibrator(); + } + + @Override + public void onDoubleClick() { + middleValueSendHandler.sendEvent(true); + } + + @Override + public void onRevoke() { + middleValueSendHandler.sendEvent(false); + } + }; + + } + + + private void notifyOnMovement(float x, float y) { + // notify listeners + listener.onMovement(x, y); + } + + private void notifyOnClick() { + // notify listeners + listener.onClick(); + } + + private void notifyOnDoubleClick() { + // notify listeners + listener.onDoubleClick(); + } + + private void notifyOnRevoke() { + // notify listeners + listener.onRevoke(); + } + + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + paintBackground.setColor(backgroundColor); + canvas.drawCircle(radius, radius, radius_complete, paintBackground); + + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == AnalogStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(radius, radius, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(radius, radius, radius_dead_zone, paintStick); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paintStick.setColor(normalColor); + canvas.drawCircle(radius, radius, radius_analog_stick, paintStick); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paintStick.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paintStick); + break; + } + } + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = radius - correlated_x; + position_stick_y = radius - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == AnalogStick.STICK_STATE.MOVED_ACTIVE || + movement_radius > radius_dead_zone) ? + AnalogStick.STICK_STATE.MOVED_ACTIVE : AnalogStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == AnalogStick.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + AnalogStick.CLICK_STATE lastClickState = click_state; + + if (isFirstTouch) { + isFirstTouch = false; + FirstTouchX = event.getX(); + FirstTouchY = event.getY(); + } + + + // get absolute way for each axis + if (moveMode == 1) { + relative_x = event.getX() - FirstTouchX; + relative_y = event.getY() - FirstTouchY; + } else { + relative_x = -(radius - event.getX()); + relative_y = -(radius - event.getY()); + } + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + // set to dead zoned, will be corrected in update position if necessary + stick_state = AnalogStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == AnalogStick.CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = AnalogStick.CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = AnalogStick.CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + FirstTouchX = 0; + FirstTouchY = 0; + isFirstTouch = true; + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = AnalogStick.STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } + + @Override + protected SuperPageLayout getInfoPage() { + if (analogStickPage == null) { + analogStickPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_analog_stick, null); + centralXNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_central_x); + centralYNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_central_y); + + } + + NumberSeekbar radiusNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_radius); + TextView middleValueTextView = analogStickPage.findViewById(R.id.page_analog_stick_middle_value); + RadioGroup modeRadioGroup = analogStickPage.findViewById(R.id.page_analog_stick_value); + Switch moveModeSwitch = analogStickPage.findViewById(R.id.page_analog_stick_move_mode); + NumberSeekbar deadZoneRadiusNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_sense); + NumberSeekbar thickNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_thick); + NumberSeekbar layerNumberSeekbar = analogStickPage.findViewById(R.id.page_analog_stick_layer); + ElementEditText normalColorEditText = analogStickPage.findViewById(R.id.page_analog_stick_normal_color); + ElementEditText pressedColorEditText = analogStickPage.findViewById(R.id.page_analog_stick_pressed_color); + ElementEditText backgroundColorEditText = analogStickPage.findViewById(R.id.page_analog_stick_background_color); + Button copyButton = analogStickPage.findViewById(R.id.page_analog_stick_copy); + Button deleteButton = analogStickPage.findViewById(R.id.page_analog_stick_delete); + + RadioButton radioButton = modeRadioGroup.findViewWithTag(value); + radioButton.setChecked(true); + modeRadioGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + setElementValue(group.findViewById(checkedId).getTag().toString()); + save(); + } + }); + + middleValueTextView.setText(pageDeviceController.getKeyNameByValue(middleValue)); + middleValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementMiddleValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + moveModeSwitch.setChecked(moveMode == 1); + moveModeSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + moveMode = isChecked ? 1 : 0; + save(); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + deadZoneRadiusNumberSeekbar.setValueWithNoCallBack(deadZoneRadius); + deadZoneRadiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementDeadZoneRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(widthMax < heightMax ? widthMax / 2 : heightMax / 2); + radiusNumberSeekbar.setProgressMin(widthMin / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColorEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_ANALOG_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + contentValues.put(COLUMN_INT_ELEMENT_MODE, moveMode); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(analogStickPage); + elementController.deleteElement(analogStick); + } + }); + + + return analogStickPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + contentValues.put(COLUMN_INT_ELEMENT_MODE, moveMode); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (analogStickPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + public void setElementValue(String value) { + this.value = value; + valueSendHandler = elementController.getSendEventHandler(value); + } + + public void setElementMiddleValue(String middleValue) { + this.middleValue = middleValue; + middleValueSendHandler = elementController.getSendEventHandler(middleValue); + } + + public void setElementRadius(int radius) { + this.radius = radius; + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + setElementWidth(radius * 2); + setElementHeight(radius * 2); + invalidate(); + } + + public void setElementDeadZoneRadius(int deadZoneRadius) { + this.deadZoneRadius = deadZoneRadius; + radius_dead_zone = getPercent(radius, deadZoneRadius); + invalidate(); + } + + public void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + public void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + public void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + public void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_ANALOG_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "LS"); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, "g64"); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, 30); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 200); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 200); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 400); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 400); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 100); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_MODE, 0); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCombineButton.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCombineButton.java new file mode 100644 index 0000000000..8ef97dbf28 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCombineButton.java @@ -0,0 +1,744 @@ +//组合键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class DigitalCombineButton extends Element { + + private static final String COLUMN_STRING_ELEMENT_VALUE_1 = COLUMN_STRING_ELEMENT_VALUE; + private static final String COLUMN_STRING_ELEMENT_VALUE_2 = COLUMN_STRING_ELEMENT_UP_VALUE; + private static final String COLUMN_STRING_ELEMENT_VALUE_3 = COLUMN_STRING_ELEMENT_DOWN_VALUE; + private static final String COLUMN_STRING_ELEMENT_VALUE_4 = COLUMN_STRING_ELEMENT_LEFT_VALUE; + private static final String COLUMN_STRING_ELEMENT_VALUE_5 = COLUMN_STRING_ELEMENT_RIGHT_VALUE; + + /** + * Listener interface to update registered observers. + */ + public interface DigitalCombineButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private PageDeviceController pageDeviceController; + private DigitalCombineButton digitalCombineButton; + + private DigitalCombineButtonListener listener; + private ElementController.SendEventHandler value1SendHandler; + private ElementController.SendEventHandler value2SendHandler; + private ElementController.SendEventHandler value3SendHandler; + private ElementController.SendEventHandler value4SendHandler; + private ElementController.SendEventHandler value5SendHandler; + private String text; + private String value1; + private String value2; + private String value3; + private String value4; + private String value5; + private int radius; + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + // New member variables + private int normalTextColor; + private int pressedTextColor; + private int textSizePercent; + + private SuperPageLayout digitalCombineButtonPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + + public DigitalCombineButton(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.digitalCombineButton = this; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 50; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 50; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + + + text = (String) attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value1 = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE_1); + value2 = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE_2); + value3 = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE_3); + value4 = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE_4); + value5 = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE_5); + + // Load new text properties with backward compatibility + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)) { + normalTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + normalTextColor = normalColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)) { + pressedTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + pressedTextColor = pressedColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)) { + textSizePercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)).intValue(); + } else { + // Default based on original hardcoded logic + textSizePercent = 25; + } + + value1SendHandler = controller.getSendEventHandler(value1); + value2SendHandler = controller.getSendEventHandler(value2); + value3SendHandler = controller.getSendEventHandler(value3); + value4SendHandler = controller.getSendEventHandler(value4); + value5SendHandler = controller.getSendEventHandler(value5); + listener = new DigitalCombineButtonListener() { + @Override + public void onClick() { + value1SendHandler.sendEvent(true); + value2SendHandler.sendEvent(true); + value3SendHandler.sendEvent(true); + value4SendHandler.sendEvent(true); + value5SendHandler.sendEvent(true); + } + + @Override + public void onLongClick() { + + } + + @Override + public void onRelease() { + value1SendHandler.sendEvent(false); + value2SendHandler.sendEvent(false); + value3SendHandler.sendEvent(false); + value4SendHandler.sendEvent(false); + value5SendHandler.sendEvent(false); + } + }; + } + + @Override + protected void onElementDraw(Canvas canvas) { + + // Get element dimensions + int elementWidth = getElementWidth(); + int elementHeight = getElementHeight(); + + // Set text size based on percentage of height + float textSize = getPercent(elementHeight, textSizePercent); + paintText.setTextSize(textSize); + // Set text color based on press state using new properties + paintText.setColor(isPressed() ? pressedTextColor : normalTextColor); + // Border + paintBorder.setStrokeWidth(thick); + paintBorder.setColor(isPressed() ? pressedColor : normalColor); + // Background color + paintBackground.setColor(backgroundColor); + + float centerX = elementWidth / 2f; + // Calculate the baseline Y for vertical centering + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float baselineY = elementHeight / 2f - (fontMetrics.top + fontMetrics.bottom) / 2f; + + // Drawing bounds + rect.left = rect.top = (float) thick / 2; + rect.right = getElementWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + // Draw background + canvas.drawRoundRect(rect, radius, radius, paintBackground); + // Draw border + canvas.drawRoundRect(rect, radius, radius, paintBorder); + // Draw text using the calculated precise coordinates + canvas.drawText(text, centerX, baselineY, paintText); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // Drawing bounds + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // Border + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + } + } + + private void onClickCallback() { + // notify listenersbuttonListener.onClick(); + System.out.println("onClickCallback"); + listener.onClick(); + elementController.getHandler().removeCallbacks(longClickRunnable); + elementController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + + } + + private void onLongClickCallback() { + // notify listeners + listener.onLongClick(); + } + + private void onReleaseCallback() { + // notify listeners + System.out.println("onReleaseCallback"); + listener.onRelease(); + + // We may be called for a release without a prior click + elementController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + int action = event.getActionMasked(); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + elementController.buttonVibrator(); + setPressed(true); + onClickCallback(); + invalidate(); + return true; + } + case MotionEvent.ACTION_MOVE: { + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + onReleaseCallback(); + invalidate(); + return true; + } + default: { + } + } + return true; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_1, value1); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_2, value2); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_3, value3); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_4, value4); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_5, value5); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Save new text properties + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (digitalCombineButtonPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalCombineButtonPage == null) { + digitalCombineButtonPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_combine_button, null); + centralXNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_central_x); + centralYNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_central_y); + + } + + NumberSeekbar widthNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_width); + NumberSeekbar heightNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_height); + NumberSeekbar radiusNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_radius); + ElementEditText textElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_text); + TextView value1TextView = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_value_1); + TextView value2TextView = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_value_2); + TextView value3TextView = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_value_3); + TextView value4TextView = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_value_4); + TextView value5TextView = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_value_5); + NumberSeekbar thickNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_thick); + NumberSeekbar layerNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_layer); + ElementEditText normalColorElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_normal_color); + ElementEditText pressedColorElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_pressed_color); + ElementEditText backgroundColorElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_background_color); + Button copyButton = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_copy); + Button deleteButton = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_delete); + + // Find new views for text properties (assuming these IDs exist in the XML layout) + NumberSeekbar textSizeNumberSeekbar = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_text_size); + ElementEditText normalTextColorElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_normal_text_color); + ElementEditText pressedTextColorElementEditText = digitalCombineButtonPage.findViewById(R.id.page_digital_combine_button_pressed_text_color); + + textElementEditText.setTextWithNoTextChangedCallBack(text); + textElementEditText.setOnTextChangedListener(text -> { + setElementText(text); + save(); + }); + + setupValueTextView(value1TextView, value1, this::setElementValue1); + setupValueTextView(value2TextView, value2, this::setElementValue2); + setupValueTextView(value3TextView, value3, this::setElementValue3); + setupValueTextView(value4TextView, value4, this::setElementValue4); + setupValueTextView(value5TextView, value5, this::setElementValue5); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + // Setup for new text size seekbar + textSizeNumberSeekbar.setProgressMin(10); // 10% + textSizeNumberSeekbar.setProgressMax(150); // 150% + textSizeNumberSeekbar.setValueWithNoCallBack(textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSizePercent(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // Refactored setup for all color pickers + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, this::setElementNormalTextColor); + setupColorPickerButton(pressedTextColorElementEditText, () -> this.pressedTextColor, this::setElementPressedTextColor); + + + copyButton.setOnClickListener(v -> { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_1, value1); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_2, value2); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_3, value3); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_4, value4); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_5, value5); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Add new properties for copy + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.addElement(contentValues); + }); + + deleteButton.setOnClickListener(v -> { + elementController.toggleInfoPage(digitalCombineButtonPage); + elementController.deleteElement(digitalCombineButton); + }); + + return digitalCombineButtonPage; + } + + private interface StringConsumer { + void accept(String value); + } + + private void setupValueTextView(TextView textView, String initialValue, StringConsumer valueSetter) { + textView.setText(pageDeviceController.getKeyNameByValue(initialValue)); + textView.setOnClickListener(v -> { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + ((TextView) v).setText(key.getText()); + valueSetter.accept(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + }); + } + + protected void setElementText(String text) { + this.text = text; + invalidate(); + } + + protected void setElementValue1(String value1) { + this.value1 = value1; + value1SendHandler = elementController.getSendEventHandler(value1); + } + + protected void setElementValue2(String value2) { + this.value2 = value2; + value2SendHandler = elementController.getSendEventHandler(value2); + } + + protected void setElementValue3(String value3) { + this.value3 = value3; + value3SendHandler = elementController.getSendEventHandler(value3); + } + + protected void setElementValue4(String value4) { + this.value4 = value4; + value4SendHandler = elementController.getSendEventHandler(value4); + } + + protected void setElementValue5(String value5) { + this.value5 = value5; + value5SendHandler = elementController.getSendEventHandler(value5); + } + + protected void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + // New setters for text properties + protected void setElementNormalTextColor(int normalTextColor) { + this.normalTextColor = normalTextColor; + invalidate(); + } + + protected void setElementPressedTextColor(int pressedTextColor) { + this.pressedTextColor = pressedTextColor; + invalidate(); + } + + protected void setElementTextSizePercent(int textSizePercent) { + this.textSizePercent = textSizePercent; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, "组合键"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_1, "k29"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_2, "null"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_3, "null"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_4, "null"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE_5, "null"); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 100); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 0); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + // Add new properties with good defaults + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); // White + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); // Light Grey for pressed state + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCommonButton.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCommonButton.java new file mode 100644 index 0000000000..ad46be70a7 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalCommonButton.java @@ -0,0 +1,710 @@ +//普通按键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.binding.input.virtual_controller.DigitalButton; +import com.limelight.binding.input.virtual_controller.VirtualControllerElement; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class DigitalCommonButton extends Element { + + /** + * Listener interface to update registered observers. + */ + public interface DigitalCommonButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private PageDeviceController pageDeviceController; + private DigitalCommonButton digitalCommonButton; + + private DigitalCommonButtonListener listener; + private ElementController.SendEventHandler valueSendHandler; + private String text; + private String value; + private int radius; + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + // New member variables + private int normalTextColor; + private int pressedTextColor; + private int textSizePercent; + + private SuperPageLayout digitalButtonPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + private DigitalCommonButton movingButton; + + + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + + public DigitalCommonButton(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.digitalCommonButton = this; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 50; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 50; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + + + text = (String) attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + + // Load new text properties with backward compatibility + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)) { + normalTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + normalTextColor = normalColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)) { + pressedTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + pressedTextColor = pressedColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)) { + textSizePercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)).intValue(); + } else { + // Default based on original hardcoded logic + textSizePercent = 25; + } + + valueSendHandler = controller.getSendEventHandler(value); + listener = new DigitalCommonButtonListener() { + @Override + public void onClick() { + valueSendHandler.sendEvent(true); + } + + @Override + public void onLongClick() { + + } + + @Override + public void onRelease() { + valueSendHandler.sendEvent(false); + } + }; + } + + boolean inRange(float x, float y) { + return (this.getX() < x && this.getX() + this.getWidth() > x) && + (this.getY() < y && this.getY() + this.getHeight() > y); + } + + public boolean checkMovement(float x, float y, DigitalCommonButton movingButton) { + + // save current pressed state + boolean wasPressed = isPressed(); + + // check if the movement directly happened on the button + if ((this.movingButton == null || movingButton == this.movingButton) + && this.inRange(x, y)) { + // set button pressed state depending on moving button pressed state + if (this.isPressed() != movingButton.isPressed()) { + this.setPressed(movingButton.isPressed()); + } + } + // check if the movement is outside of the range and the movement button + // is the saved moving button + else if (movingButton == this.movingButton) { + this.setPressed(false); + } + + // check if a change occurred + if (wasPressed != isPressed()) { + if (isPressed()) { + // is pressed set moving button and emit click event + this.movingButton = movingButton; + onClickCallback(); + } else { + // no longer pressed reset moving button and emit release event + this.movingButton = null; + onReleaseCallback(); + } + + invalidate(); + + return true; + } + + return false; + } + + private void checkMovementForAllButtons(float x, float y) { + if (inRange(x, y)) { + return; + } + for (Element element : elementController.getElements()) { + if (element != this && element instanceof DigitalCommonButton && element.getVisibility() == VISIBLE) { + ((DigitalCommonButton) element).checkMovement(x, y, this); + } + } + } + + @Override + protected void onElementDraw(Canvas canvas) { + + // Get element dimensions + int elementWidth = getElementWidth(); + int elementHeight = getElementHeight(); + + // Set text size based on percentage of height + float textSize = getPercent(elementHeight, textSizePercent); + paintText.setTextSize(textSize); + // Set text color based on press state using new properties + paintText.setColor(isPressed() ? pressedTextColor : normalTextColor); + // Border + paintBorder.setStrokeWidth(thick); + paintBorder.setColor(isPressed() ? pressedColor : normalColor); + // Background color + paintBackground.setColor(backgroundColor); + + float centerX = elementWidth / 2f; + // Calculate the baseline Y for vertical centering + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float baselineY = elementHeight / 2f - (fontMetrics.top + fontMetrics.bottom) / 2f; + + // Drawing bounds + rect.left = rect.top = (float) thick / 2; + rect.right = getElementWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + // Draw background + canvas.drawRoundRect(rect, radius, radius, paintBackground); + // Draw border + canvas.drawRoundRect(rect, radius, radius, paintBorder); + // Draw text using the calculated precise coordinates + canvas.drawText(text, centerX, baselineY, paintText); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // Drawing bounds + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // Border + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + } + } + + private void onClickCallback() { + // notify listenersbuttonListener.onClick(); + System.out.println("onClickCallback"); + listener.onClick(); + elementController.getHandler().removeCallbacks(longClickRunnable); + elementController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + + } + + private void onLongClickCallback() { + // notify listeners + listener.onLongClick(); + } + + private void onReleaseCallback() { + // notify listeners + System.out.println("onReleaseCallback"); + listener.onRelease(); + + // We may be called for a release without a prior click + elementController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + float x = getX() + event.getX(); + float y = getY() + event.getY(); + int action = event.getActionMasked(); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + movingButton = null; + elementController.buttonVibrator(); + setPressed(true); + onClickCallback(); + invalidate(); + return true; + } + case MotionEvent.ACTION_MOVE: { + checkMovementForAllButtons(x, y); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + onReleaseCallback(); + + checkMovementForAllButtons(x, y); + + invalidate(); + return true; + } + default: { + } + } + return true; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Save new text properties + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (digitalButtonPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalButtonPage == null) { + digitalButtonPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_common_button, null); + centralXNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_central_x); + centralYNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_central_y); + + } + + NumberSeekbar widthNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_width); + NumberSeekbar heightNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_height); + NumberSeekbar radiusNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_radius); + NumberSeekbar layerNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_layer); + ElementEditText textElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_text); + TextView valueTextView = digitalButtonPage.findViewById(R.id.page_digital_common_button_value); + NumberSeekbar thickNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_thick); + ElementEditText normalColorElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_normal_color); + ElementEditText pressedColorElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_pressed_color); + ElementEditText backgroundColorElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_background_color); + Button copyButton = digitalButtonPage.findViewById(R.id.page_digital_common_button_copy); + Button deleteButton = digitalButtonPage.findViewById(R.id.page_digital_common_button_delete); + + // Find new views for text properties (assuming these IDs exist in the XML layout) + NumberSeekbar textSizeNumberSeekbar = digitalButtonPage.findViewById(R.id.page_digital_common_button_text_size); + ElementEditText normalTextColorElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_normal_text_color); + ElementEditText pressedTextColorElementEditText = digitalButtonPage.findViewById(R.id.page_digital_common_button_pressed_text_color); + + textElementEditText.setTextWithNoTextChangedCallBack(text); + textElementEditText.setOnTextChangedListener(text -> { + setElementText(text); + save(); + }); + + valueTextView.setText(pageDeviceController.getKeyNameByValue(value)); + valueTextView.setOnClickListener(v -> { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + CharSequence text = key.getText(); + ((TextView) v).setText(text); + textElementEditText.setText(text); + setElementValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // Setup for new text size seekbar + textSizeNumberSeekbar.setProgressMin(10); // 10% + textSizeNumberSeekbar.setProgressMax(150); // 150% + textSizeNumberSeekbar.setValueWithNoCallBack(textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSizePercent(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // Setup for all color pickers + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, this::setElementNormalTextColor); + setupColorPickerButton(pressedTextColorElementEditText, () -> this.pressedTextColor, this::setElementPressedTextColor); + + + copyButton.setOnClickListener(v -> { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_COMMON_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Add new properties for copy + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.addElement(contentValues); + }); + + deleteButton.setOnClickListener(v -> { + elementController.toggleInfoPage(digitalButtonPage); + elementController.deleteElement(digitalCommonButton); + }); + + return digitalButtonPage; + } + + protected void setElementText(String text) { + this.text = text; + invalidate(); + } + + protected void setElementValue(String value) { + this.value = value; + valueSendHandler = elementController.getSendEventHandler(value); + } + + protected void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + // New setters for text properties + protected void setElementNormalTextColor(int normalTextColor) { + this.normalTextColor = normalTextColor; + invalidate(); + } + + protected void setElementPressedTextColor(int pressedTextColor) { + this.pressedTextColor = pressedTextColor; + invalidate(); + } + + protected void setElementTextSizePercent(int textSizePercent) { + this.textSizePercent = textSizePercent; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_COMMON_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, "A"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "k29"); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 100); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 0); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + // Add new properties with good defaults + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); // White + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); // Light Grey for pressed state + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + return contentValues; + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + colorDisplay.setBackgroundColor(color); + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + colorDisplay.setOnClickListener(v -> new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, + newColor -> { + colorUpdater.accept(newColor); + save(); + updateColorDisplay(colorDisplay, newColor); + } + ).show()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalMovableButton.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalMovableButton.java new file mode 100644 index 0000000000..d6ac536f0e --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalMovableButton.java @@ -0,0 +1,945 @@ +//可移动按键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.TouchController; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + +/** + * This is a digital button on screen element. It can function as a simple button, + * a joystick-like mouse mover, or a mini-trackpad with tap and drag capabilities. + */ +public class DigitalMovableButton extends Element { + + // --- 定义用于存储 JSON 属性的新列名 --- + public static final String COLUMN_STRING_EXTRA_ATTRIBUTES = "extra_attributes"; + + // --- 触控板逻辑所需的常量 --- + private static final int DRAG_TIME_THRESHOLD = 300; + private static final int TAP_MOVEMENT_THRESHOLD = 20; + + // --- 用于在触控板模式下临时存储原始键值 --- + private String valueBeforeTrackpad; + + // --- 定义鼠标左键的常量 --- + private static final String LEFT_MOUSE_VALUE = "m1"; + + /** + * Listener interface to update registered observers. + */ + public interface DigitalMovableButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private TouchController touchController; + private PageDeviceController pageDeviceController; + private DigitalMovableButton digitalMovableButton; + + private DigitalMovableButtonListener listener; + private ElementController.SendEventHandler valueSendHandler; + private final Game game; + private String text; + private String value; + + private int enableTouch = 0; // 0 = 按钮, 1 = 摇杆。 + private boolean isTrackpadMode = false; + + private int radius; + private int sense; + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + // New member variables + private int normalTextColor; + private int pressedTextColor; + private int textSizePercent; + + private SuperPageLayout digitalMovableButtonPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + + private float lastX; + private float lastY; + + // --- 用于摇杆模式 (Joystick Mode) --- + private boolean isFirstTouch = true; + private float FirstTouchX = 0; + private float FirstTouchY = 0; + + // --- 用于触控板模式 (Trackpad Mode) --- + private final Handler handler = new Handler(Looper.getMainLooper()); + private float originalTouchX = 0; + private float originalTouchY = 0; + private boolean confirmedMove = false; + private boolean confirmedDrag = false; + private final Runnable dragTimerRunnable; + + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + + public DigitalMovableButton(Map attributesMap, + ElementController controller, + TouchController touchController, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.touchController = touchController; + this.pageDeviceController = pageDeviceController; + this.digitalMovableButton = this; + + this.game = (Game) context; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 50; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 50; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + + // Standard properties + text = (String) attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + sense = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_SENSE)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + Object modeObj = attributesMap.get(COLUMN_INT_ELEMENT_MODE); + this.enableTouch = (modeObj != null) ? ((Long) modeObj).intValue() : 0; + + // --- 读取并解析新的 extra_attributes JSON 列 --- + Object extraAttrObj = attributesMap.get(COLUMN_STRING_EXTRA_ATTRIBUTES); + if (extraAttrObj instanceof String) { + String json = (String) extraAttrObj; + // 使用 try-catch 块防止因 JSON 格式错误导致崩溃 + try { + Gson gson = new Gson(); + // 定义要解析的目标类型 + Type type = new TypeToken>() { + }.getType(); + Map extraAttrs = gson.fromJson(json, type); + + if (extraAttrs != null && extraAttrs.containsKey("isTrackpadMode")) { + // Gson 可能会将 JSON 的布尔值解析为 Boolean 对象 + Object trackpadValue = extraAttrs.get("isTrackpadMode"); + if (trackpadValue instanceof Boolean) { + this.isTrackpadMode = (Boolean) trackpadValue; + } + } + } catch (Exception e) { + // 如果 JSON 格式错误,打印错误日志并使用默认值继续运行 + e.printStackTrace(); + } + } + + // 读取文本颜色等属性 + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)) { + normalTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + normalTextColor = normalColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)) { + pressedTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + pressedTextColor = pressedColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)) { + textSizePercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)).intValue(); + } else { + // Default based on original hardcoded logic + textSizePercent = 25; + } + + valueSendHandler = controller.getSendEventHandler(value); + listener = new DigitalMovableButtonListener() { + @Override + public void onClick() { + valueSendHandler.sendEvent(true); + } + + @Override + public void onLongClick() { + + } + + @Override + public void onRelease() { + valueSendHandler.sendEvent(false); + } + }; + + dragTimerRunnable = () -> { + if (confirmedMove) return; + confirmedDrag = true; + listener.onClick(); + elementController.buttonVibrator(); + setPressed(true); + invalidate(); + }; + } + + @Override + protected void onElementDraw(Canvas canvas) { + + // Get element dimensions + int elementWidth = getElementWidth(); + int elementHeight = getElementHeight(); + + // Set text size based on percentage of height + float textSize = getPercent(elementHeight, textSizePercent); + paintText.setTextSize(textSize); + // Set text color based on press state using new properties + paintText.setColor(isPressed() ? pressedTextColor : normalTextColor); + // Border + paintBorder.setStrokeWidth(thick); + paintBorder.setColor(isPressed() ? pressedColor : normalColor); + // Background color + paintBackground.setColor(backgroundColor); + + float centerX = elementWidth / 2f; + // Calculate the baseline Y for vertical centering + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float baselineY = elementHeight / 2f - (fontMetrics.top + fontMetrics.bottom) / 2f; + + // 3. Start drawing + // Drawing bounds + rect.left = rect.top = (float) thick / 2; + rect.right = getElementWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + // Draw background + canvas.drawRoundRect(rect, radius, radius, paintBackground); + // Draw border + canvas.drawRoundRect(rect, radius, radius, paintBorder); + // Draw text using the calculated precise coordinates + canvas.drawText(text, centerX, baselineY, paintText); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // Drawing bounds + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // Border + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + } + } + + private void onClickCallback() { + // notify listenersbuttonListener.onClick(); + System.out.println("onClickCallback"); + listener.onClick(); + elementController.getHandler().removeCallbacks(longClickRunnable); + elementController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + + } + + private void onLongClickCallback() { + // notify listeners + listener.onLongClick(); + } + + private void onReleaseCallback() { + // notify listeners + System.out.println("onReleaseCallback"); + listener.onRelease(); + + // We may be called for a release without a prior click + elementController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + if (isTrackpadMode) { + return handleTrackpadTouchEvent(event); + } else if (enableTouch == 1) { + return handleJoystickTouchEvent(event); + } else { // enableTouch == 0 + return handleButtonTouchEvent(event); + } + } + + private boolean handleTrackpadTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: { + lastX = event.getX(); + lastY = event.getY(); + originalTouchX = event.getX(); + originalTouchY = event.getY(); + confirmedMove = false; + confirmedDrag = false; + handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); + return true; + } + case MotionEvent.ACTION_MOVE: { + float deltaX = event.getX() - lastX; + float deltaY = event.getY() - lastY; + if (!confirmedMove && !confirmedDrag) { + float moveDistance = (float) Math.hypot(event.getX() - originalTouchX, event.getY() - originalTouchY); + if (moveDistance > TAP_MOVEMENT_THRESHOLD) { + confirmedMove = true; + handler.removeCallbacks(dragTimerRunnable); + } + } + if (confirmedMove || confirmedDrag) { + touchController.mouseMove(deltaX, deltaY, 0.01 * sense); + } + lastX = event.getX(); + lastY = event.getY(); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + handler.removeCallbacks(dragTimerRunnable); + if (confirmedDrag) { + listener.onRelease(); + } else if (!confirmedMove) { + elementController.buttonVibrator(); + listener.onClick(); + handler.postDelayed(listener::onRelease, 50); + } + setPressed(false); + invalidate(); + return true; + } + } + return false; + } + + private boolean handleButtonTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: { + elementController.buttonVibrator(); + lastX = event.getX(); + lastY = event.getY(); + setPressed(true); + onClickCallback(); + invalidate(); + return true; + } + case MotionEvent.ACTION_MOVE: { + float deltaX = event.getX() - lastX; + float deltaY = event.getY() - lastY; + touchController.mouseMove(deltaX, deltaY, 0.01 * sense); + lastX = event.getX(); + lastY = event.getY(); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + onReleaseCallback(); + invalidate(); + return true; + } + } + return true; + } + + private boolean handleJoystickTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + elementController.buttonVibrator(); + setPressed(true); + onClickCallback(); + invalidate(); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + setPressed(false); + onReleaseCallback(); + invalidate(); + break; + } + if (isFirstTouch) { + isFirstTouch = false; + FirstTouchX = event.getX(); + FirstTouchY = event.getY(); + } + float touchXTemp, touchYTemp; + touchXTemp = (float) (game.getStreamView().getWidth() / 2 + (event.getX() - FirstTouchX) * sense * 0.01); + touchYTemp = (float) (game.getStreamView().getHeight() / 2 + (event.getY() - FirstTouchY) * sense * 0.01); + MotionEvent EventTemp = MotionEvent.obtain(event.getDownTime(), event.getEventTime(), action, touchXTemp, touchYTemp, event.getPressure(), event.getSize(), event.getMetaState(), event.getXPrecision(), event.getYPrecision(), event.getDeviceId(), event.getEdgeFlags()); + if (touchXTemp < 0 || touchXTemp > game.getStreamView().getWidth() || touchYTemp < 0 || touchYTemp > game.getStreamView().getHeight()) { + FirstTouchX = event.getX(); + FirstTouchY = event.getY(); + EventTemp.setAction(MotionEvent.ACTION_CANCEL); + } + try { + game.getHandleMotionEvent(game.getStreamView(), EventTemp); + } catch (NullPointerException e) { + System.out.println("NullPointerException"); + } + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + FirstTouchX = 0; + FirstTouchY = 0; + isFirstTouch = true; + } + return true; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_MODE, enableTouch); + contentValues.put(COLUMN_INT_ELEMENT_SENSE, sense); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + + // --- 创建并保存 extra_attributes 的 JSON 字符串 --- + Map extraAttrs = new HashMap<>(); + extraAttrs.put("isTrackpadMode", isTrackpadMode); + + Gson gson = new Gson(); + String json = gson.toJson(extraAttrs); + + contentValues.put(COLUMN_STRING_EXTRA_ATTRIBUTES, json); + + elementController.updateElement(elementId, contentValues); + } + + @Override + protected void updatePage() { + if (digitalMovableButtonPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalMovableButtonPage == null) { + digitalMovableButtonPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_movable_button, null); + centralXNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_central_x); + centralYNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_central_y); + } + + // --- 获取所有需要的控件 --- + Switch joystickModeSwitch = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_enable_touch); + View joystickModeContainer = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_joystick_mode_container); + Switch trackpadModeSwitch = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_trackpad_mode); + View trackpadModeContainer = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_trackpad_mode_container); + TextView valueTextView = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_value); + + Runnable updateValueViewState = () -> { + if (isTrackpadMode) { + valueTextView.setText(pageDeviceController.getKeyNameByValue(LEFT_MOUSE_VALUE)); + valueTextView.setEnabled(false); + valueTextView.setAlpha(0.5f); // 设置为半透明,视觉上更明确 + } else { + valueTextView.setText(pageDeviceController.getKeyNameByValue(this.value)); + valueTextView.setEnabled(true); + valueTextView.setAlpha(1.0f); + } + }; + + // 根据初始状态设置控件的可见性 + if (isTrackpadMode) { + joystickModeContainer.setVisibility(View.GONE); + } else if (enableTouch == 1) { + trackpadModeContainer.setVisibility(View.GONE); + } + + // 设置开关的初始选中状态 + joystickModeSwitch.setChecked(enableTouch == 1); + trackpadModeSwitch.setChecked(isTrackpadMode); + + // 调用辅助方法,初始化键值UI + updateValueViewState.run(); + + // --- 为开关设置新的监听器,包含所有联动逻辑 --- + joystickModeSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + enableTouch = isChecked ? 1 : 0; + trackpadModeContainer.setVisibility(isChecked ? View.GONE : View.VISIBLE); + if (isChecked && isTrackpadMode) { + isTrackpadMode = false; + trackpadModeSwitch.setChecked(false); + // 因为触控板模式被关闭了,所以需要更新键值UI + updateValueViewState.run(); + } + save(); + }); + + trackpadModeSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + isTrackpadMode = isChecked; + joystickModeContainer.setVisibility(isChecked ? View.GONE : View.VISIBLE); + + if (isChecked) { + // --- 开启触控板模式 --- + // 1. 保存当前键值 (如果还没保存过) + if (valueBeforeTrackpad == null) { + valueBeforeTrackpad = this.value; + } + // 2. 强制设置键值为鼠标左键 + setElementValue(LEFT_MOUSE_VALUE); + } else { + // --- 关闭触控板模式 --- + // 1. 恢复之前的键值 (如果存在) + if (valueBeforeTrackpad != null) { + setElementValue(valueBeforeTrackpad); + valueBeforeTrackpad = null; // 清空临时存储 + } + } + + // 确保与摇杆模式互斥 + if (isChecked && enableTouch == 1) { + enableTouch = 0; + joystickModeSwitch.setChecked(false); + } + + // 更新键值UI状态 + updateValueViewState.run(); + save(); + }); + + NumberSeekbar widthNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_width); + NumberSeekbar heightNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_height); + NumberSeekbar radiusNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_radius); + ElementEditText textElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_text); + NumberSeekbar senseNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_sense); + NumberSeekbar thickNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_thick); + NumberSeekbar layerNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_layer); + ElementEditText normalColorElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_normal_color); + ElementEditText pressedColorElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_pressed_color); + ElementEditText backgroundColorElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_background_color); + Button copyButton = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_copy); + Button deleteButton = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_delete); + NumberSeekbar textSizeNumberSeekbar = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_text_size); + ElementEditText normalTextColorElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_normal_text_color); + ElementEditText pressedTextColorElementEditText = digitalMovableButtonPage.findViewById(R.id.page_digital_movable_button_pressed_text_color); + + textElementEditText.setTextWithNoTextChangedCallBack(text); + textElementEditText.setOnTextChangedListener(text -> { + setElementText(text); + save(); + }); + + valueTextView.setOnClickListener(v -> { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + CharSequence text = key.getText(); + ((TextView) v).setText(text); + textElementEditText.setText(text); + setElementValue(key.getTag().toString()); + // 当用户手动选择一个键值后,清除临时的“恢复值” + valueBeforeTrackpad = null; + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + @Override + public void onStartTrackingTouch(SeekBar seekBar) { } + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + senseNumberSeekbar.setValueWithNoCallBack(sense); + senseNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementSense(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + // Setup for new text size seekbar + textSizeNumberSeekbar.setProgressMin(10); // 10% + textSizeNumberSeekbar.setProgressMax(150); // 150% + textSizeNumberSeekbar.setValueWithNoCallBack(textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSizePercent(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // Setup for all color pickers + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, this::setElementNormalTextColor); + setupColorPickerButton(pressedTextColorElementEditText, () -> this.pressedTextColor, this::setElementPressedTextColor); + + + copyButton.setOnClickListener(v -> { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_MODE, enableTouch); + contentValues.put(COLUMN_STRING_EXTRA_ATTRIBUTES, new Gson().toJson(new HashMap() {{ put("isTrackpadMode", isTrackpadMode); }})); + contentValues.put(COLUMN_INT_ELEMENT_SENSE, sense); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Add new properties for copy + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.addElement(contentValues); + }); + + deleteButton.setOnClickListener(v -> { + elementController.toggleInfoPage(digitalMovableButtonPage); + elementController.deleteElement(digitalMovableButton); + }); + + return digitalMovableButtonPage; + } + + protected void setElementText(String text) { + this.text = text; + invalidate(); + } + + protected void setElementValue(String value) { + this.value = value; + valueSendHandler = elementController.getSendEventHandler(value); + } + + protected void setElementSense(int sense) { + this.sense = sense; + } + + protected void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); // Added invalidate() for immediate visual feedback + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + // New setters for text properties + protected void setElementNormalTextColor(int normalTextColor) { + this.normalTextColor = normalTextColor; + invalidate(); + } + + protected void setElementPressedTextColor(int pressedTextColor) { + this.pressedTextColor = pressedTextColor; + invalidate(); + } + + protected void setElementTextSizePercent(int textSizePercent) { + this.textSizePercent = textSizePercent; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, "A"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "k29"); + contentValues.put(COLUMN_INT_ELEMENT_MODE, 0); // 默认摇杆模式关闭 + contentValues.put(COLUMN_INT_ELEMENT_SENSE, 100); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 100); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 0); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + + // --- 为 extra_attributes 设置默认的 JSON 值 --- + Map extraAttrs = new HashMap<>(); + extraAttrs.put("isTrackpadMode", false); // 默认触控板模式关闭 + contentValues.put(COLUMN_STRING_EXTRA_ATTRIBUTES, new Gson().toJson(extraAttrs)); + + return contentValues; + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + colorDisplay.setBackgroundColor(color); + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + colorDisplay.setOnClickListener(v -> new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, + newColor -> { + colorUpdater.accept(newColor); + save(); + updateColorDisplay(colorDisplay, newColor); + } + ).show()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalPad.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalPad.java new file mode 100644 index 0000000000..8a4b73c12f --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalPad.java @@ -0,0 +1,711 @@ +//十字键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +public class DigitalPad extends Element { + + + public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0; + int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION; + public final static int DIGITAL_PAD_DIRECTION_LEFT = 1; + public final static int DIGITAL_PAD_DIRECTION_UP = 2; + public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4; + public final static int DIGITAL_PAD_DIRECTION_DOWN = 8; + private DigitalPadListener listener; + private static final int DPAD_MARGIN = 5; + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private DigitalPad digitalPad; + + private ElementController.SendEventHandler upValueSenderHandler; + private ElementController.SendEventHandler downValueSenderHandler; + private ElementController.SendEventHandler leftValueSenderHandler; + private ElementController.SendEventHandler rightValueSenderHandler; + private String upValue; + private String downValue; + private String leftValue; + private String rightValue; + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + private SuperPageLayout digitalPadPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + //加上这个防止一些无用的按键多次触发,发送大量数据,导致卡顿 + private int lastDirection = 0; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Path pathBackground = new Path(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + public DigitalPad(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.digitalPad = this; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 150; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 150; + + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + upValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_UP_VALUE); + downValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_DOWN_VALUE); + leftValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_LEFT_VALUE); + rightValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_RIGHT_VALUE); + + upValueSenderHandler = controller.getSendEventHandler(upValue); + downValueSenderHandler = controller.getSendEventHandler(downValue); + leftValueSenderHandler = controller.getSendEventHandler(leftValue); + rightValueSenderHandler = controller.getSendEventHandler(rightValue); + listener = new DigitalPad.DigitalPadListener() { + @Override + public void onDirectionChange(int direction) { + int directionChange = lastDirection ^ direction; + if ((directionChange & DIGITAL_PAD_DIRECTION_LEFT) != 0) { + leftValueSenderHandler.sendEvent((direction & DIGITAL_PAD_DIRECTION_LEFT) != 0); + } + if ((directionChange & DIGITAL_PAD_DIRECTION_RIGHT) != 0) { + rightValueSenderHandler.sendEvent((direction & DIGITAL_PAD_DIRECTION_RIGHT) != 0); + } + if ((directionChange & DIGITAL_PAD_DIRECTION_UP) != 0) { + upValueSenderHandler.sendEvent((direction & DIGITAL_PAD_DIRECTION_UP) != 0); + } + if ((directionChange & DIGITAL_PAD_DIRECTION_DOWN) != 0) { + downValueSenderHandler.sendEvent((direction & DIGITAL_PAD_DIRECTION_DOWN) != 0); + } + lastDirection = direction; + } + }; + + } + + + @Override + protected void onElementDraw(Canvas canvas) { + + + paintBorder.setStrokeWidth(thick); + int correctedBorderPosition = thick + DPAD_MARGIN; + + paintBackground.setStyle(Paint.Style.FILL); + paintBackground.setStrokeWidth(10); + paintBackground.setColor(backgroundColor); + pathBackground.reset(); + pathBackground.moveTo(getPercent(getWidth(), 33), correctedBorderPosition); + pathBackground.lineTo(getPercent(getWidth(), 66), correctedBorderPosition); + pathBackground.lineTo(getWidth() - correctedBorderPosition, getPercent(getHeight(), 33)); + pathBackground.lineTo(getWidth() - correctedBorderPosition, getPercent(getHeight(), 66)); + pathBackground.lineTo(getPercent(getWidth(), 66), getHeight() - correctedBorderPosition); + pathBackground.lineTo(getPercent(getWidth(), 33), getHeight() - correctedBorderPosition); + pathBackground.lineTo(correctedBorderPosition, getPercent(getHeight(), 66)); + pathBackground.lineTo(correctedBorderPosition, getPercent(getHeight(), 33)); + pathBackground.lineTo(getPercent(getWidth(), 33), correctedBorderPosition); + canvas.drawPath(pathBackground, paintBackground); + + + if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) { + // draw no direction rect + paintBorder.setStyle(Paint.Style.STROKE); + paintBorder.setColor(normalColor); + canvas.drawRect( + getPercent(getWidth(), 36), getPercent(getHeight(), 36), + getPercent(getWidth(), 63), getPercent(getHeight(), 63), + paintBorder + ); + } + + // draw left rect + paintBorder.setColor( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : normalColor); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawRect( + correctedBorderPosition, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + paintBorder + ); + + + // draw up rect + paintBorder.setColor( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : normalColor); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), correctedBorderPosition, + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + paintBorder + ); + + // draw right rect + paintBorder.setColor( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : normalColor); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + getWidth() - correctedBorderPosition, getPercent(getHeight(), 66), + paintBorder + ); + + // draw down rect + paintBorder.setColor( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : normalColor); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight() - correctedBorderPosition, + paintBorder + ); + + // draw left up line + paintBorder.setColor(( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 + ) ? pressedColor : normalColor + ); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawLine( + correctedBorderPosition, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), correctedBorderPosition, + paintBorder + ); + + // draw up right line + paintBorder.setColor(( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 && + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 + ) ? pressedColor : normalColor + ); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 66), correctedBorderPosition, + getWidth() - correctedBorderPosition, getPercent(getHeight(), 33), + paintBorder + ); + + // draw right down line + paintBorder.setColor(( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 + ) ? pressedColor : normalColor + ); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getWidth() - paintBorder.getStrokeWidth(), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight() - correctedBorderPosition, + paintBorder + ); + + // draw down left line + paintBorder.setColor(( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 && + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 + ) ? pressedColor : normalColor + ); + paintBorder.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 33), getHeight() - correctedBorderPosition, + correctedBorderPosition, getPercent(getHeight(), 66), + paintBorder + ); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + } + } + + private void newDirectionCallback(int direction) { + + // notify listeners + listener.onDirectionChange(direction); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + elementController.buttonVibrator(); + case MotionEvent.ACTION_MOVE: { + direction = 0; + + if (event.getX() < getPercent(getWidth(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_LEFT; + } + if (event.getX() > getPercent(getWidth(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_RIGHT; + } + if (event.getY() > getPercent(getHeight(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_DOWN; + } + if (event.getY() < getPercent(getHeight(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_UP; + } + newDirectionCallback(direction); + invalidate(); + + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + direction = 0; + newDirectionCallback(direction); + invalidate(); + + return true; + } + default: { + } + } + + return true; + } + + public interface DigitalPadListener { + void onDirectionChange(int direction); + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalPadPage == null) { + digitalPadPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_pad, null); + centralXNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_central_x); + centralYNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_central_y); + } + + NumberSeekbar widthNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_width); + NumberSeekbar heightNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_height); + TextView upValueTextView = digitalPadPage.findViewById(R.id.page_digital_pad_up_value); + TextView downValueTextView = digitalPadPage.findViewById(R.id.page_digital_pad_down_value); + TextView leftValueTextView = digitalPadPage.findViewById(R.id.page_digital_pad_left_value); + TextView rightValueTextView = digitalPadPage.findViewById(R.id.page_digital_pad_right_value); + NumberSeekbar thickNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_thick); + NumberSeekbar layerNumberSeekbar = digitalPadPage.findViewById(R.id.page_digital_pad_layer); + ElementEditText normalColorElementEditText = digitalPadPage.findViewById(R.id.page_digital_pad_normal_color); + ElementEditText pressedColorElementEditText = digitalPadPage.findViewById(R.id.page_digital_pad_pressed_color); + ElementEditText backgroundColorElementEditText = digitalPadPage.findViewById(R.id.page_digital_pad_background_color); + Button copyButton = digitalPadPage.findViewById(R.id.page_digital_pad_copy); + Button deleteButton = digitalPadPage.findViewById(R.id.page_digital_pad_delete); + + + upValueTextView.setText(pageDeviceController.getKeyNameByValue(upValue)); + upValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementUpValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + downValueTextView.setText(pageDeviceController.getKeyNameByValue(downValue)); + downValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementDownValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + leftValueTextView.setText(pageDeviceController.getKeyNameByValue(leftValue)); + leftValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementLeftValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + rightValueTextView.setText(pageDeviceController.getKeyNameByValue(rightValue)); + rightValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementRightValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_PAD); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(digitalPadPage); + elementController.deleteElement(digitalPad); + } + }); + + return digitalPadPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (digitalPadPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + public void setElementUpValue(String upValue) { + this.upValue = upValue; + upValueSenderHandler = elementController.getSendEventHandler(upValue); + } + + public void setElementDownValue(String downValue) { + this.downValue = downValue; + downValueSenderHandler = elementController.getSendEventHandler(downValue); + } + + public void setElementLeftValue(String leftValue) { + this.leftValue = leftValue; + leftValueSenderHandler = elementController.getSendEventHandler(leftValue); + } + + public void setElementRightValue(String rightValue) { + this.rightValue = rightValue; + rightValueSenderHandler = elementController.getSendEventHandler(rightValue); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_PAD); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, "k51"); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, "k47"); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, "k29"); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, "k32"); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 300); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 300); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalStick.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalStick.java new file mode 100644 index 0000000000..efefd422fc --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalStick.java @@ -0,0 +1,908 @@ +//按键摇杆 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +public class DigitalStick extends Element { + + private static final String COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS = COLUMN_INT_ELEMENT_SENSE; + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface DigitalStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private DigitalStick digitalStick; + + private ElementController.SendEventHandler middleValueSendHandler; + private ElementController.SendEventHandler upValueSendHandler; + private ElementController.SendEventHandler downValueSendHandler; + private ElementController.SendEventHandler leftValueSendHandler; + private ElementController.SendEventHandler rightValueSendHandler; + private String middleValue; + private String upValue; + private String downValue; + private String leftValue; + private String rightValue; + private int radius; + private int deadZoneRadius; //dead zone radius + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + private SuperPageLayout digitalStickPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + private final Paint paintStick = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + private DigitalStick.STICK_STATE stick_state = DigitalStick.STICK_STATE.NO_MOVEMENT; + private DigitalStick.CLICK_STATE click_state = DigitalStick.CLICK_STATE.SINGLE; + + private DigitalStickListener listener; + private long timeLastClick = 0; + private boolean upIsPressed = false; + private boolean downIsPressed = false; + private boolean leftIsPressed = false; + private boolean rightIsPressed = false; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? 0 : Math.PI; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public DigitalStick(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + + this.pageDeviceController = pageDeviceController; + this.digitalStick = this; + + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels; + super.widthMin = 100; + super.heightMax = displayMetrics.heightPixels; + super.heightMin = 100; + + paintBackground.setStyle(Paint.Style.FILL); + paintStick.setStyle(Paint.Style.STROKE); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + deadZoneRadius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_SENSE)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + middleValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_MIDDLE_VALUE); + upValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_UP_VALUE); + downValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_DOWN_VALUE); + leftValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_LEFT_VALUE); + rightValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_RIGHT_VALUE); + middleValueSendHandler = controller.getSendEventHandler(middleValue); + upValueSendHandler = controller.getSendEventHandler(upValue); + downValueSendHandler = controller.getSendEventHandler(downValue); + leftValueSendHandler = controller.getSendEventHandler(leftValue); + rightValueSendHandler = controller.getSendEventHandler(rightValue); + + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + + listener = new DigitalStickListener() { + @Override + public void onMovement(float x, float y) { + if (x < -deadZoneRadius * 0.01 && !leftIsPressed) { + leftValueSendHandler.sendEvent(true); + leftIsPressed = true; + } else if (x > -deadZoneRadius * 0.01 && leftIsPressed) { + leftValueSendHandler.sendEvent(false); + leftIsPressed = false; + } + if (x > deadZoneRadius * 0.01 && !rightIsPressed) { + rightValueSendHandler.sendEvent(true); + rightIsPressed = true; + } else if (x < deadZoneRadius * 0.01 && rightIsPressed) { + rightValueSendHandler.sendEvent(false); + rightIsPressed = false; + } + if (y < -deadZoneRadius * 0.01 && !downIsPressed) { + downValueSendHandler.sendEvent(true); + downIsPressed = true; + } else if (y > -deadZoneRadius * 0.01 && downIsPressed) { + downValueSendHandler.sendEvent(false); + downIsPressed = false; + } + if (y > deadZoneRadius * 0.01 && !upIsPressed) { + upValueSendHandler.sendEvent(true); + upIsPressed = true; + } else if (y < deadZoneRadius * 0.01 && upIsPressed) { + upValueSendHandler.sendEvent(false); + upIsPressed = false; + } + } + + @Override + public void onClick() { + elementController.buttonVibrator(); + } + + @Override + public void onDoubleClick() { + middleValueSendHandler.sendEvent(true); + } + + @Override + public void onRevoke() { + middleValueSendHandler.sendEvent(false); + } + }; + + } + + + private void notifyOnMovement(float x, float y) { + // notify listeners + listener.onMovement(x, y); + } + + private void notifyOnClick() { + // notify listeners + listener.onClick(); + } + + private void notifyOnDoubleClick() { + // notify listeners + listener.onDoubleClick(); + } + + private void notifyOnRevoke() { + // notify listeners + listener.onRevoke(); + } + + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + paintBackground.setColor(backgroundColor); + canvas.drawCircle(radius, radius, radius_complete, paintBackground); + + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == DigitalStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(radius, radius, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(radius, radius, radius_dead_zone, paintStick); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paintStick.setColor(normalColor); + canvas.drawCircle(radius, radius, radius_analog_stick, paintStick); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paintStick.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paintStick); + break; + } + } + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = radius - correlated_x; + position_stick_y = radius - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == DigitalStick.STICK_STATE.MOVED_ACTIVE || + movement_radius > radius_dead_zone) ? + DigitalStick.STICK_STATE.MOVED_ACTIVE : DigitalStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == DigitalStick.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + DigitalStick.CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(radius - event.getX()); + relative_y = -(radius - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + // set to dead zoned, will be corrected in update position if necessary + stick_state = DigitalStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == DigitalStick.CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = DigitalStick.CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = DigitalStick.CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = DigitalStick.STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalStickPage == null) { + digitalStickPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_stick, null); + centralXNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_central_x); + centralYNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_central_y); + + } + + NumberSeekbar radiusNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_radius); + TextView middleValueTextView = digitalStickPage.findViewById(R.id.page_digital_stick_middle_value); + TextView upValueTextView = digitalStickPage.findViewById(R.id.page_digital_stick_up_value); + TextView downValueTextView = digitalStickPage.findViewById(R.id.page_digital_stick_down_value); + TextView leftValueTextView = digitalStickPage.findViewById(R.id.page_digital_stick_left_value); + TextView rightValueTextView = digitalStickPage.findViewById(R.id.page_digital_stick_right_value); + NumberSeekbar deadZoneRadiusNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_sense); + NumberSeekbar thickNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_thick); + NumberSeekbar layerNumberSeekbar = digitalStickPage.findViewById(R.id.page_digital_stick_layer); + ElementEditText normalColorEditText = digitalStickPage.findViewById(R.id.page_digital_stick_normal_color); + ElementEditText pressedColorEditText = digitalStickPage.findViewById(R.id.page_digital_stick_pressed_color); + ElementEditText backgroundColorEditText = digitalStickPage.findViewById(R.id.page_digital_stick_background_color); + Button copyButton = digitalStickPage.findViewById(R.id.page_digital_stick_copy); + Button deleteButton = digitalStickPage.findViewById(R.id.page_digital_stick_delete); + + + middleValueTextView.setText(pageDeviceController.getKeyNameByValue(middleValue)); + middleValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementMiddleValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + upValueTextView.setText(pageDeviceController.getKeyNameByValue(upValue)); + upValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementUpValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + downValueTextView.setText(pageDeviceController.getKeyNameByValue(downValue)); + downValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementDownValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + leftValueTextView.setText(pageDeviceController.getKeyNameByValue(leftValue)); + leftValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementLeftValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + rightValueTextView.setText(pageDeviceController.getKeyNameByValue(rightValue)); + rightValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementRightValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + deadZoneRadiusNumberSeekbar.setValueWithNoCallBack(deadZoneRadius); + deadZoneRadiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementDeadZoneRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(widthMax < heightMax ? widthMax / 2 : heightMax / 2); + radiusNumberSeekbar.setProgressMin(widthMin / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColorEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(digitalStickPage); + elementController.deleteElement(digitalStick); + } + }); + + + return digitalStickPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (digitalStickPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + public void setElementMiddleValue(String middleValue) { + this.middleValue = middleValue; + middleValueSendHandler = elementController.getSendEventHandler(middleValue); + } + + public void setElementUpValue(String upValue) { + this.upValue = upValue; + upValueSendHandler = elementController.getSendEventHandler(upValue); + } + + public void setElementDownValue(String downValue) { + this.downValue = downValue; + downValueSendHandler = elementController.getSendEventHandler(downValue); + } + + public void setElementLeftValue(String leftValue) { + this.leftValue = leftValue; + leftValueSendHandler = elementController.getSendEventHandler(leftValue); + } + + public void setElementRightValue(String rightValue) { + this.rightValue = rightValue; + rightValueSendHandler = elementController.getSendEventHandler(rightValue); + } + + public void setElementRadius(int radius) { + this.radius = radius; + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + setElementWidth(radius * 2); + setElementHeight(radius * 2); + invalidate(); + } + + public void setElementDeadZoneRadius(int deadZoneRadius) { + this.deadZoneRadius = deadZoneRadius; + radius_dead_zone = getPercent(radius, deadZoneRadius); + invalidate(); + } + + public void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + public void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + public void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + public void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, "k51"); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, "k47"); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, "k29"); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, "k32"); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, "k59"); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, 30); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 200); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 200); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 400); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 400); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 100); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalSwitchButton.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalSwitchButton.java new file mode 100644 index 0000000000..d67d54f63e --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/DigitalSwitchButton.java @@ -0,0 +1,693 @@ +//开关按键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class DigitalSwitchButton extends Element { + + /** + * Listener interface to update registered observers. + */ + public interface DigitalSwitchButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private DigitalSwitchButton digitalSwitchButton; + + private DigitalSwitchButtonListener listener; + private ElementController.SendEventHandler valueSendHandler; + private String text; + private String value; + private int radius; + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + // New member variables for text color and size + private int normalTextColor; + private int pressedTextColor; + private int textSizePercent; + + private SuperPageLayout digitalSwitchButtonPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + + public DigitalSwitchButton(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.digitalSwitchButton = this; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 50; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 50; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + + + text = (String) attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + + // Load new text properties with backward compatibility + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)) { + normalTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + normalTextColor = normalColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)) { + pressedTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + pressedTextColor = pressedColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)) { + textSizePercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)).intValue(); + } else { + // Default based on original hardcoded logic + textSizePercent = 25; + } + + valueSendHandler = controller.getSendEventHandler(value); + listener = new DigitalSwitchButtonListener() { + @Override + public void onClick() { + valueSendHandler.sendEvent(true); + } + + @Override + public void onLongClick() { + + } + + @Override + public void onRelease() { + valueSendHandler.sendEvent(false); + } + }; + } + + @Override + protected void onElementDraw(Canvas canvas) { + + // 获取元素尺寸 + int elementWidth = getElementWidth(); + int elementHeight = getElementHeight(); + + // 1. 设置画笔属性 + // 根据百分比设置字体大小 + float textSize = getPercent(elementHeight, textSizePercent); + paintText.setTextSize(textSize); + // 设置字体颜色 + paintText.setColor(isPressed() ? pressedTextColor : normalTextColor); + // 边框 + paintBorder.setStrokeWidth(thick); + paintBorder.setColor(isPressed() ? pressedColor : normalColor); + // 背景颜色 + paintBackground.setColor(backgroundColor); + + // 2. 计算精确的居中坐标 + // 水平中心 X 坐标 + float centerX = elementWidth / 2f; + + // 计算垂直居中的基线 Y 坐标 + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float baselineY = elementHeight / 2f - (fontMetrics.top + fontMetrics.bottom) / 2f; + + // 3. 开始绘制 + // 绘画范围 + rect.left = rect.top = (float) thick / 2; + rect.right = elementWidth - rect.left; + rect.bottom = elementHeight - rect.top; + // 绘制背景 + canvas.drawRoundRect(rect, radius, radius, paintBackground); + // 绘制边框 + canvas.drawRoundRect(rect, radius, radius, paintBorder); + + // 绘制文字 (使用计算出的精确坐标) + canvas.drawText(text, centerX, baselineY, paintText); + + // 4. 绘制编辑模式的虚线框 + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + } + } + + private void onClickCallback() { + // notify listenersbuttonListener.onClick(); + System.out.println("onClickCallback"); + listener.onClick(); + elementController.getHandler().removeCallbacks(longClickRunnable); + elementController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + + } + + private void onLongClickCallback() { + // notify listeners + listener.onLongClick(); + } + + private void onReleaseCallback() { + // notify listeners + System.out.println("onReleaseCallback"); + listener.onRelease(); + + // We may be called for a release without a prior click + elementController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + int action = event.getActionMasked(); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + elementController.buttonVibrator(); + if (isPressed()) { + setPressed(false); + onReleaseCallback(); + } else { + setPressed(true); + onClickCallback(); + } + invalidate(); + return true; + } + case MotionEvent.ACTION_MOVE: { + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + return true; + } + default: { + } + } + return true; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Save new text properties + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (digitalSwitchButtonPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + @Override + protected SuperPageLayout getInfoPage() { + if (digitalSwitchButtonPage == null) { + digitalSwitchButtonPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_digital_switch_button, null); + centralXNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_central_x); + centralYNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_central_y); + + } + + NumberSeekbar widthNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_width); + NumberSeekbar heightNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_height); + NumberSeekbar radiusNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_radius); + ElementEditText textElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_text); + TextView valueTextView = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_value); + NumberSeekbar thickNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_thick); + NumberSeekbar layerNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_layer); + ElementEditText normalColorElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_normal_color); + ElementEditText pressedColorElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_pressed_color); + ElementEditText backgroundColorElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_background_color); + // Find new views for text properties (assuming these IDs exist in the XML layout) + NumberSeekbar textSizeNumberSeekbar = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_text_size); + ElementEditText normalTextColorElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_normal_text_color); + ElementEditText pressedTextColorElementEditText = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_pressed_text_color); + Button copyButton = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_copy); + Button deleteButton = digitalSwitchButtonPage.findViewById(R.id.page_digital_switch_button_delete); + + textElementEditText.setTextWithNoTextChangedCallBack(text); + textElementEditText.setOnTextChangedListener(new ElementEditText.OnTextChangedListener() { + @Override + public void textChanged(String text) { + setElementText(text); + save(); + } + }); + + valueTextView.setText(pageDeviceController.getKeyNameByValue(value)); + valueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + CharSequence text = key.getText(); + // page页设置值文本 + ((TextView) v).setText(text); + // element text 设置文本 + textElementEditText.setText(text); + setElementValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + // Setup for new text size seekbar + textSizeNumberSeekbar.setProgressMin(10); // 10% + textSizeNumberSeekbar.setProgressMax(150); // 150% + textSizeNumberSeekbar.setValueWithNoCallBack(textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSizePercent(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // Refactored setup for all color pickers + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, this::setElementNormalTextColor); + setupColorPickerButton(pressedTextColorElementEditText, () -> this.pressedTextColor, this::setElementPressedTextColor); + + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + // Add new properties for copy + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(digitalSwitchButtonPage); + elementController.deleteElement(digitalSwitchButton); + } + }); + + + return digitalSwitchButtonPage; + } + + protected void setElementText(String text) { + this.text = text; + invalidate(); + } + + protected void setElementValue(String value) { + this.value = value; + valueSendHandler = elementController.getSendEventHandler(value); + } + + protected void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + // New setter methods for text properties + protected void setElementNormalTextColor(int normalTextColor) { + this.normalTextColor = normalTextColor; + invalidate(); + } + + protected void setElementPressedTextColor(int pressedTextColor) { + this.pressedTextColor = pressedTextColor; + invalidate(); + } + + protected void setElementTextSizePercent(int textSizePercent) { + this.textSizePercent = textSizePercent; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, "A"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "k29"); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 100); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 0); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + // Add new text properties with good defaults for new buttons + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); // White + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); // Light Grey for pressed state + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/EditGridView.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/EditGridView.java new file mode 100644 index 0000000000..67ba4d5238 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/EditGridView.java @@ -0,0 +1,49 @@ +package com.limelight.binding.input.advance_setting.element; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.view.View; + +public class EditGridView extends View { + + private final static int minDisplayWidth = 3; + private Paint paint; + private int editGridWidth = 1; + + public EditGridView(Context context) { + super(context); + paint = new Paint(); + paint.setColor(0xFF00F5FF); + paint.setStrokeWidth(2); // 设置网格线宽为2像素 + this.setAlpha(0.4f); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (editGridWidth < minDisplayWidth) return; + drawGrid(canvas); + } + + private void drawGrid(Canvas canvas) { + float width = getWidth(); + float height = getHeight(); + + // 绘制垂直线 + for (float x = 0; x <= width; x += editGridWidth) { + canvas.drawLine(x, 0, x, height, paint); + } + + // 绘制水平线 + for (float y = 0; y <= height; y += editGridWidth) { + canvas.drawLine(0, y, width, y, paint); + } + } + + public void setEditGridWidth(int editGridWidth) { + this.editGridWidth = editGridWidth; + invalidate(); + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/Element.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/Element.java new file mode 100644 index 0000000000..f5e4a0a411 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/Element.java @@ -0,0 +1,356 @@ +package com.limelight.binding.input.advance_setting.element; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.text.Spanned; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.widget.FrameLayout; + +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; + +import java.util.Map; + +public abstract class Element extends View { + + + public static final String COLUMN_LONG_CONFIG_ID = "config_id"; + public static final String COLUMN_LONG_ELEMENT_ID = "element_id"; + public static final String COLUMN_INT_ELEMENT_TYPE = "element_type"; + public static final String COLUMN_STRING_ELEMENT_VALUE = "element_value"; + public static final String COLUMN_STRING_ELEMENT_MIDDLE_VALUE = "element_middle_value"; + public static final String COLUMN_STRING_ELEMENT_UP_VALUE = "element_up_value"; + public static final String COLUMN_STRING_ELEMENT_DOWN_VALUE = "element_down_value"; + public static final String COLUMN_STRING_ELEMENT_LEFT_VALUE = "element_left_value"; + public static final String COLUMN_STRING_ELEMENT_RIGHT_VALUE = "element_right_value"; + public static final String COLUMN_STRING_ELEMENT_TEXT = "element_text"; + public static final String COLUMN_INT_ELEMENT_WIDTH = "element_width"; + public static final String COLUMN_INT_ELEMENT_HEIGHT = "element_height"; + public static final String COLUMN_INT_ELEMENT_LAYER = "element_layer"; + public static final String COLUMN_INT_ELEMENT_MODE = "element_mode"; + public static final String COLUMN_INT_ELEMENT_SENSE = "element_sense"; + public static final String COLUMN_INT_ELEMENT_CENTRAL_X = "element_central_x"; + public static final String COLUMN_INT_ELEMENT_CENTRAL_Y = "element_central_y"; + public static final String COLUMN_INT_ELEMENT_RADIUS = "element_radius"; + public static final String COLUMN_INT_ELEMENT_OPACITY = "element_opacity"; + public static final String COLUMN_INT_ELEMENT_THICK = "element_thick"; + public static final String COLUMN_INT_ELEMENT_NORMAL_COLOR = "element_color"; + public static final String COLUMN_INT_ELEMENT_PRESSED_COLOR = "element_pressed_color"; + public static final String COLUMN_INT_ELEMENT_BACKGROUND_COLOR = "element_background_color"; + public static final String COLUMN_INT_ELEMENT_FLAG1 = "element_flag1"; + + public static final String COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR = "normalTextColor"; + public static final String COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR = "pressedTextColor"; + public static final String COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT = "textSizePercent"; + public static final String COLUMN_STRING_EXTRA_ATTRIBUTES = "extra_attributes"; + + public static final int ELEMENT_TYPE_DIGITAL_COMMON_BUTTON = 0; + public static final int ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON = 1; + public static final int ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON = 2; + public static final int ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON = 3; + public static final int ELEMENT_TYPE_GROUP_BUTTON = 4; + public static final int ELEMENT_TYPE_DIGITAL_PAD = 20; + public static final int ELEMENT_TYPE_ANALOG_STICK = 30; + public static final int ELEMENT_TYPE_DIGITAL_STICK = 31; + public static final int ELEMENT_TYPE_INVISIBLE_ANALOG_STICK = 32; + public static final int ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK = 33; + public static final int ELEMENT_TYPE_SIMPLIFY_PERFORMANCE = 50; + + public static final int EDIT_COLOR_EDIT = 0xf0dc143c; + public static final int EDIT_COLOR_SELECT = 0xfffe9900; + public static final int EDIT_COLOR_SELECTED = 0xff0112ff; + public final static int ELEMENT_TYPE_WHEEL_PAD = 54; + + + // 在编辑模式下,如果元素被按住超过系统定义的长按时间,就允许拖动, + // 以避免用户想打开按键设置而不是移动按键 + private static final long DRAG_EDIT_LONG_PRESS_TIMEOUT = 250; + private boolean longPressDetected = false; + private final Runnable longPressRunnable = new Runnable() { + @Override + public void run() { + longPressDetected = true; + // 可以添加视觉反馈,比如改变边框颜色 + editColor = 0xff00f91a; // 绿色表示可以拖动 + invalidate(); + } + }; + + + + public interface ElementSelectedCallBack { + void elementSelected(Element element); + } + + + protected class HexInputFilter implements InputFilter { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + for (int i = start; i < end; i++) { + if (!Character.isDigit(source.charAt(i)) && (source.charAt(i) < 'A' || source.charAt(i) > 'F')) { + return ""; + } + } + return null; + } + } + + + protected final Long elementId; + protected final Long configId; + protected final int elementType; + protected int layer; + protected final ElementController elementController; + private Context context; + private final Paint paint = new Paint(); + private final RectF rect = new RectF(); + protected int centralXMax; + protected int centralXMin; + protected int centralYMax; + protected int centralYMin; + protected int widthMax; + protected int widthMin; + protected int heightMax; + protected int heightMin; + private float lastX; + private float lastY; + private boolean isClick = true; + protected int editColor = EDIT_COLOR_EDIT; + private ElementSelectedCallBack elementSelectedCallBack; + + + public Element(Map attributesMap, ElementController elementController, Context context) { + super(context); + this.context = context; + this.elementId = (Long) attributesMap.get(Element.COLUMN_LONG_ELEMENT_ID); + this.configId = (Long) attributesMap.get(Element.COLUMN_LONG_CONFIG_ID); + this.elementType = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_TYPE)).intValue(); + this.layer = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_LAYER)).intValue(); + this.elementController = elementController; + + } + + protected int getElementCentralX() { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + return layoutParams.leftMargin + layoutParams.width / 2; + } + + protected int getElementCentralY() { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + return layoutParams.topMargin + layoutParams.height / 2; + } + + protected int getElementWidth() { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + return layoutParams.width; + } + + protected int getElementHeight() { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + return layoutParams.height; + } + + protected void setElementCentralX(int centralX) { + centralX = elementController.editGridHandle(centralX); + if (centralX > centralXMax) { + centralX = elementController.editGridHandle(centralXMax); + } + innerSetElementCentralX(centralX); + } + + protected void setElementCentralY(int centralY) { + + centralY = elementController.editGridHandle(centralY); + if (centralY > centralYMax) { + centralY = elementController.editGridHandle(centralYMax); + } + + innerSetElementCentralY(centralY); + } + + //inner 方法防止setWidth、Height方法会调用子类重写的setElementCentralX、Y + private void innerSetElementCentralX(int centralX) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + if (centralX > centralXMax) { + layoutParams.leftMargin = centralXMax - layoutParams.width / 2; + } else if (centralX < centralXMin) { + layoutParams.leftMargin = centralXMin - layoutParams.width / 2; + } else { + layoutParams.leftMargin = centralX - layoutParams.width / 2; + } + //保存中心点坐标 + requestLayout(); + } + + private void innerSetElementCentralY(int centralY) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + if (centralY > centralYMax) { + layoutParams.topMargin = centralYMax - layoutParams.height / 2; + } else if (centralY < centralYMin) { + layoutParams.topMargin = centralYMin - layoutParams.height / 2; + } else { + layoutParams.topMargin = centralY - layoutParams.height / 2; + } + requestLayout(); + } + + protected void setElementWidth(int width) { + int centralPosX = getElementCentralX(); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + if (width > widthMax) { + layoutParams.width = widthMax; + } else if (width < widthMin) { + layoutParams.width = widthMin; + } else { + layoutParams.width = width; + } + innerSetElementCentralX(centralPosX); + } + + protected void setElementHeight(int height) { + int centralPosY = getElementCentralY(); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + if (height > heightMax) { + layoutParams.height = heightMax; + } else if (height < heightMin) { + layoutParams.height = heightMin; + } else { + layoutParams.height = height; + } + innerSetElementCentralY(centralPosY); + } + + protected void setElementLayer(int layer) { + this.layer = layer; + elementController.adjustLayer(this); + } + + public void setEditColor(int editColor) { + this.editColor = editColor; + } + + public void setElementSelectedCallBack(ElementSelectedCallBack elementSelectedCallBack) { + this.elementSelectedCallBack = elementSelectedCallBack; + } + + @Override + protected void onDraw(Canvas canvas) { + onElementDraw(canvas); + super.onDraw(canvas); + } + + /** + * 当全局模式(正常、编辑、选择)更改时,由ElementController调用。 + * 子类可以重写此项以更新其状态。 + */ + public void onModeChanged(ElementController.Mode newMode) { + // Default implementation does nothing. + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // 忽略多点触控中的非主手指 + if (event.getActionIndex() != 0) { + return true; + } + + // 确保控制器存在 + if (elementController == null) { + return true; + } + + switch (elementController.getMode()) { + case Normal: + // Normal 模式逻辑 + return onElementTouchEvent(event); + + case Edit: + // Edit 模式逻辑 + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + lastX = event.getX(); + lastY = event.getY(); + isClick = true; + longPressDetected = false; + editColor = 0xffdc143c; // 红色表示初始状态 + invalidate(); + + // 启动长按检测 + elementController.getHandler().removeCallbacks(longPressRunnable); + elementController.getHandler().postDelayed(longPressRunnable, DRAG_EDIT_LONG_PRESS_TIMEOUT); + return true; + } + case MotionEvent.ACTION_MOVE: { + float x = event.getX(); + float y = event.getY(); + float deltaX = x - lastX; + float deltaY = y - lastY; + + // 小位移算作点击 + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.2) { + return true; + } + + // 只有检测到长按或没有开启长按移动后才允许拖动 + if (!elementController.isDragEditEnabled() | longPressDetected) { + isClick = false; + setElementCentralX((int) getX() + getWidth() / 2 + (int) deltaX); + setElementCentralY((int) getY() + getHeight() / 2 + (int) deltaY); + updatePage(); + } + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + // 取消长按检测 + elementController.getHandler().removeCallbacks(longPressRunnable); + + editColor = 0xffdc143c; + invalidate(); + + if (isClick || !longPressDetected) { + elementController.toggleInfoPage(getInfoPage()); + } else { + save(); + } + return true; + } + } + return true; + //组按键选择子按键时使用,可更改为ACTION_DOWN来快速(滑动)选择按键 + case Select: + // 1. 触发时机ACTION_UP (手指抬起时) + if (event.getActionMasked() == MotionEvent.ACTION_UP) { + + // 2. 增加对回调的 null 检查,防止应用崩溃 + if (elementSelectedCallBack != null) { + // 3. 安全地调用回调方法 + // 假设回调接口的方法名是 onElementSelected 或者 elementSelected + elementSelectedCallBack.elementSelected (this); + } + } + // 4. 必须返回 true,表示事件已被处理 + return true; + } + return true; + } + + abstract protected SuperPageLayout getInfoPage(); + + abstract protected void updatePage(); + + abstract protected void save(); + + abstract protected void onElementDraw(Canvas canvas); + + abstract public boolean onElementTouchEvent(MotionEvent event); + + protected final float getPercent(float value, float percent) { + return value / 100 * percent; + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/ElementController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/ElementController.java new file mode 100644 index 0000000000..11ed7eee2f --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/ElementController.java @@ -0,0 +1,1276 @@ +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.preference.PreferenceManager; +import android.media.AudioAttributes; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.VibrationAttributes; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.FrameLayout; +import android.app.AlertDialog; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.Toast; + +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.advance_setting.ControllerManager; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.config.PageConfigController; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.binding.input.advance_setting.superpage.SuperPagesController; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ElementController { + + // 空按键 + private static final String SPECIAL_KEY_NULL = "null"; + // 手柄左摇杆 + private static final String SPECIAL_KEY_GAMEPAD_LEFT_STICK = "LS"; + // 手柄右摇杆 + private static final String SPECIAL_KEY_GAMEPAD_RIGHT_STICK = "RS"; + // 手柄左触发器 + private static final String SPECIAL_KEY_GAMEPAD_LEFT_TRIGGER = "lt"; + // 手柄右触发器 + private static final String SPECIAL_KEY_GAMEPAD_RIGHT_TRIGGER = "rt"; + // 滚轮上滚 + private static final String SPECIAL_KEY_MOUSE_SCROLL_UP = "SU"; + // 滚轮下滚 + private static final String SPECIAL_KEY_MOUSE_SCROLL_DOWN = "SD"; + private static final String SPECIAL_KEY_MOUSE_MODE_SWITCH = "MMS"; + private static final String SPECIAL_KEY_CLASSIC_MOUSE_SWITCH = "CMS"; // 经典鼠标 + private static final String SPECIAL_KEY_TRACKPAD_MODE = "TPM"; // 触控板 + private static final String SPECIAL_KEY_MULTI_TOUCH_MODE = "MTM"; // 多点触控 + private static final String SPECIAL_KEY_MOUSE_ENABLE_SWITCH = "MES"; + private static final String SPECIAL_KEY_PC_KEYBOARD_SWITCH = "PKS"; + private static final String SPECIAL_KEY_ANDROID_KEYBOARD_SWITCH = "AKS"; + // 切换配置 + private static final String SPECIAL_KEY_CONFIG_SWITCH = "CSW"; + private static final String SPECIAL_KEY_PAN_ZOOM_MODE = "PZM"; + private static final String SPECIAL_KEY_OPEN_GAME_MENU = "OGM"; + + + + public interface SendEventHandler { + void sendEvent(boolean down); + + void sendEvent(int analog1, int analog2); + } + + + public enum Mode { + Normal, + Edit, + Select + } + + public static class GamepadInputContext { + public short inputMap = 0x0000; + public byte leftTrigger = 0x00; + public byte rightTrigger = 0x00; + public short rightStickX = 0x0000; + public short rightStickY = 0x0000; + public short leftStickX = 0x0000; + public short leftStickY = 0x0000; + } + + + private final Context context; + private final Game game; + private final Handler handler; + private Toast currentToast; + private Vibrator deviceVibrator; + + private final ControllerManager controllerManager; + private final ControllerHandler controllerHandler; + private final PageDeviceController pageDeviceController; + + private GamepadInputContext gamepadInputContext = new GamepadInputContext(); + + + private final List elements = new ArrayList<>(); + private List elementIds; + private Map keyEventRunnableMap = new HashMap<>(); + private Map mouseEventRunnableMap = new HashMap<>(); + private FrameLayout elementsLayout; + private Mode mode = Mode.Normal; + private SuperPageLayout pageEdit; + private SuperPageLayout lastElementSettingPage; + private final int bottomViewAmount; + private EditGridView editGridView; + private int editGridWidth = 1; + private long currentConfigId; + private boolean gameVibrator = false; + private boolean buttonVibrator = false; + + // 滚轮按住事件管理 + private Map mouseScrollRunnableMap = new HashMap<>(); + private static final int MOUSE_SCROLL_INITIAL_DELAY = 150; // 初始延迟(毫秒) + private static int MOUSE_SCROLL_REPEAT_INTERVAL = 100; // 重复间隔(毫秒) + + public static void setMouseScrollRepeatInterval(int interval) { + MOUSE_SCROLL_REPEAT_INTERVAL = interval; + } + + public SuperPagesController getSuperPagesController() { + return controllerManager.getSuperPagesController(); + } + + + /** + * 隐藏所有虚拟按键的容器。 + */ + public void hideAllElementsForTest() { + if (elementsLayout != null) { + elementsLayout.setVisibility(View.INVISIBLE); + } + } + + /** + * 显示所有虚拟按键的容器。 + */ + public void showAllElementsForTest() { + if (elementsLayout != null) { + elementsLayout.setVisibility(View.VISIBLE); + } + } + + // 拖动编辑开关变量 + private boolean dragEditEnabled = true; + + // 设置拖动编辑开关的方法 + public void setDragEditEnabled(boolean enabled) { + this.dragEditEnabled = enabled; + } + + // 获取拖动编辑开关状态的方法 + public boolean isDragEditEnabled() { + return dragEditEnabled; + } + + + public void showToast(String message) { + if (currentToast != null) { + currentToast.cancel(); + } + currentToast = Toast.makeText(context, message, Toast.LENGTH_SHORT); + currentToast.show(); + } + + + public ElementController(ControllerManager controllerManager, FrameLayout layout, final Context context) { + this.elementsLayout = layout; + this.context = context; + this.game = (Game) context; + this.controllerManager = controllerManager; + this.controllerHandler = game.getControllerHandler(); + this.pageDeviceController = controllerManager.getPageDeviceController(); + this.handler = new Handler(Looper.getMainLooper()); + this.pageEdit = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_edit, null); + this.editGridView = new EditGridView(context); + this.bottomViewAmount = elementsLayout.getChildCount(); + this.deviceVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + initEditPage(); + } + + private void initEditPage() { + pageEdit.findViewById(R.id.page_edit_exit_edit_mode).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + changeMode(Mode.Normal); + controllerManager.getPageSuperMenuController().open(); + // 1. 通过公共方法通知 Game Activity 切换回普通菜单模式 + ((Game) context).setcurrentBackKeyMenu(Game.BackKeyMenuMode.GAME_MENU); + + // 2. 显示操作成功的提示信息 + Toast.makeText(context, context.getString(R.string.toast_back_key_menu_switch_1), Toast.LENGTH_SHORT).show(); + } + }); + ((NumberSeekbar) pageEdit.findViewById(R.id.page_edit_edit_grid_width)).setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + editGridWidth = progress; + editGridView.setEditGridWidth(editGridWidth); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + + pageEdit.findViewById(R.id.page_edit_add_digital_common_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalCommonButton.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_digital_switch_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalSwitchButton.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_digital_movable_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalMovableButton.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_pad).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalPad.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_analog_stick).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = AnalogStick.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_digital_stick).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalStick.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_invisible_analog_stick).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = InvisibleAnalogStick.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_invisible_digital_stick).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = InvisibleDigitalStick.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_simplify_performance).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + ContentValues contentValues = SimplifyPerformance.getInitialInfo(); + contentValues.put(Element.COLUMN_INT_ELEMENT_CENTRAL_X, displayMetrics.widthPixels / 2); + contentValues.put(Element.COLUMN_INT_ELEMENT_CENTRAL_Y, 30); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_digital_combine_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = DigitalCombineButton.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_group_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = GroupButton.getInitialInfo(); + addElement(contentValues); + } + }); + pageEdit.findViewById(R.id.page_edit_add_wheel_pad).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = WheelPad.getInitialInfo(); + addElement(contentValues); + } + }); + Switch dragEditSwitch = pageEdit.findViewById(R.id.page_edit_drag_edit_switch); + if (dragEditSwitch != null) { + // 设置初始状态 + dragEditSwitch.setChecked(true); // 默认启用长按移动按键 + + // 添加开关监听器 + dragEditSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + setDragEditEnabled(isChecked); + String message = isChecked ? "长按移动按键" : "可直接拖动按键"; + showToast(message); + } + }); + } + } + + + protected Handler getHandler() { + return handler; + } + + + public void loadAllElement(Long configId) { + currentConfigId = configId; + removeAllElementsOnScreen(); + elementIds = controllerManager.getSuperConfigDatabaseHelper().queryAllElementIds(configId); + + // 用于在第二阶段链接关系的 GroupButton 列表 + List groupButtonsToLink = new ArrayList<>(); + + // --- 阶段一:创建所有 Element 对象 --- + // 遍历所有 elementId,不区分类型,统一调用 loadElement 创建对象 + for (Long elementId : elementIds) { + Element newElement = loadElement(elementId); + + // 如果创建的是一个 GroupButton,将其添加到待链接列表 + if (newElement instanceof GroupButton) { + groupButtonsToLink.add((GroupButton) newElement); + } + } + + // --- 阶段二:链接 GroupButton 的子元素 --- + // 此时,`elements` 列表已经包含了当前配置下的所有 Element 对象 + for (GroupButton gb : groupButtonsToLink) { + // 调用我们将在 GroupButton 类中添加的新方法 + gb.linkChildElements(elements); + } + } + + protected Element addElement(ContentValues contentValues) { + Long configId = controllerManager.getPageConfigController().getCurrentConfigId(); + Long elementId = System.currentTimeMillis(); + contentValues.put(Element.COLUMN_LONG_CONFIG_ID, configId); + contentValues.put(Element.COLUMN_LONG_ELEMENT_ID, elementId); + controllerManager.getSuperConfigDatabaseHelper().insertElement(contentValues); + + return loadElement(elementId); + } + + protected void updateElement(long elementId, ContentValues contentValues) { + controllerManager.getSuperConfigDatabaseHelper().updateElement(currentConfigId, elementId, contentValues); + } + + protected void deleteElement(Element element) { + controllerManager.getSuperConfigDatabaseHelper().deleteElement(currentConfigId, element.elementId); + if (elements.contains(element)) { + elementsLayout.removeView(element); + elements.remove(element); + } + } + + private void removeAllElementsOnScreen() { + for (Element element : elements) { + elementsLayout.removeView(element); + } + elements.clear(); + } + + private Element loadElement(Long elementId) { + Map attributesMap = controllerManager.getSuperConfigDatabaseHelper().queryAllElementAttributes(currentConfigId, elementId); + int type = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_TYPE)).intValue(); + Element element = null; + switch (type) { + case Element.ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + element = new DigitalCommonButton(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + element = new DigitalSwitchButton(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + element = new DigitalMovableButton(attributesMap, + this, + controllerManager.getTouchController(), + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_GROUP_BUTTON: + element = new GroupButton(attributesMap, + this, + pageDeviceController, + controllerManager.getSuperPagesController(), + context); + break; + case Element.ELEMENT_TYPE_DIGITAL_PAD: + element = new DigitalPad(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_ANALOG_STICK: + element = new AnalogStick(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_DIGITAL_STICK: + element = new DigitalStick(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_INVISIBLE_ANALOG_STICK: + element = new InvisibleAnalogStick(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK: + element = new InvisibleDigitalStick(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_SIMPLIFY_PERFORMANCE: + element = new SimplifyPerformance(attributesMap, + this, + context); + break; + case Element.ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + element = new DigitalCombineButton(attributesMap, + this, + pageDeviceController, + context); + break; + case Element.ELEMENT_TYPE_WHEEL_PAD: + element = new WheelPad(attributesMap, + this, + pageDeviceController, + context); + break; + default: + element = new DigitalCommonButton(attributesMap, + this, + pageDeviceController, + context); + break; + } + int elementWidth = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_WIDTH)).intValue(); + int elementHeight = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_HEIGHT)).intValue(); + int elementCentralX = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_CENTRAL_X)).intValue(); + int elementCentralY = ((Long) attributesMap.get(Element.COLUMN_INT_ELEMENT_CENTRAL_Y)).intValue(); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(elementWidth, elementHeight); + layoutParams.leftMargin = elementCentralX - elementWidth / 2; + layoutParams.topMargin = elementCentralY - elementHeight / 2; + + //对element的层级进行排序 + for (int i = 0; i <= elements.size(); i++) { + if (i == elements.size()) { + elements.add(i, element); + elementsLayout.addView(element, i + bottomViewAmount, layoutParams); + break; + } + Element elementExist = elements.get(i); + if (elementExist.elementId + ((long) elementExist.layer << 48) > element.elementId + ((long) element.layer << 48)) { + elements.add(i, element); + elementsLayout.addView(element, i + bottomViewAmount, layoutParams); + break; + } + } + + //限制element的位置范围 + element.setElementHeight(element.getElementHeight()); + element.setElementWidth(element.getElementWidth()); + + return element; + } + + protected void adjustLayer(Element element) { + + int elementWidth = element.getElementWidth(); + int elementHeight = element.getElementHeight(); + int elementCentralX = element.getElementCentralX(); + int elementCentralY = element.getElementCentralY(); + + + elementsLayout.removeView(element); + elements.remove(element); + + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(elementWidth, elementHeight); + layoutParams.leftMargin = elementCentralX - elementWidth / 2; + layoutParams.topMargin = elementCentralY - elementHeight / 2; + //对element的层级进行排序 + for (int i = 0; i <= elements.size(); i++) { + if (i == elements.size()) { + elements.add(i, element); + elementsLayout.addView(element, i + bottomViewAmount, layoutParams); + break; + } + Element elementExist = elements.get(i); + if (elementExist.elementId + ((long) elementExist.layer << 48) > element.elementId + ((long) element.layer << 48)) { + elements.add(i, element); + elementsLayout.addView(element, i + bottomViewAmount, layoutParams); + break; + } + } + + + } + + protected int editGridHandle(int position) { + return position - position % editGridWidth; + } + + + public void toggleInfoPage(SuperPageLayout elementSettingPage) { + if (elementSettingPage == controllerManager.getSuperPagesController().getPageNow()) { + controllerManager.getSuperPagesController().openNewPage( + controllerManager.getSuperPagesController().getPageNull()); + } else { + controllerManager.getSuperPagesController().openNewPage(elementSettingPage); + elementSettingPage.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + controllerManager.getSuperPagesController().openNewPage(controllerManager.getSuperPagesController().getPageNull()); + } + }); + } + } + + public SuperPageLayout getCurrentEditingPage() { + return controllerManager.getSuperPagesController().getPageNow(); + } + + + public void open() { + SuperPagesController superPagesController = controllerManager.getSuperPagesController(); + SuperPageLayout pageNull = superPagesController.getPageNull(); + superPagesController.openNewPage(pageNull); + pageNull.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(pageEdit); + } + }); + pageEdit.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + superPagesController.openNewPage(pageNull); + } + }); + } + + public void changeMode(Mode mode) { + if (this.mode == mode) { + return; + } + + this.mode = mode; + switch (mode) { + case Normal: + controllerManager.getTouchController().enableTouch(true); + this.mode = Mode.Normal; + elementsLayout.removeView(editGridView); + for (Element element : elements) { + element.invalidate(); + } + break; + case Edit: + controllerManager.getTouchController().enableTouch(false); + this.mode = Mode.Edit; + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + layoutParams.leftMargin = 0; + layoutParams.topMargin = 0; + elementsLayout.addView(editGridView, bottomViewAmount, layoutParams); + for (Element element : elements) { + element.setEditColor(Element.EDIT_COLOR_EDIT); + element.invalidate(); + } + break; + case Select: + elementsLayout.removeView(editGridView); + this.mode = Mode.Select; + for (Element element : elements) { + element.setEditColor(Element.EDIT_COLOR_SELECT); + element.invalidate(); + } + break; + } + // 无论切换到什么模式,都通知所有元素,并让它们重绘 + for (Element element : elements) { + element.onModeChanged(mode); // <-- 调用新方法 + + // 我们仍然需要根据模式设置颜色 + if (mode == Mode.Edit) { + element.setEditColor(Element.EDIT_COLOR_EDIT); + } else if (mode == Mode.Select) { + element.setEditColor(Element.EDIT_COLOR_SELECT); + } + + element.invalidate(); + } + } + + public Mode getMode() { + return mode; + } + + //其他辅助方法---------------------------------- + public List getElements() { + return elements; + } + + /** + * 根据提供的元素ID查找并返回对应的 Element 对象。 + * + * @param elementId 要查找的元素的ID。 + * @return 如果找到,则返回 Element 对象;否则返回 null。 + */ + public Element findElementById(long elementId) { + // 直接遍历类成员 'elements' 列表 + for (Element element : elements) { + if (element.elementId.equals(elementId)) { + return element; + } + } + return null; // 如果没有找到 + } + + + // 添加开始滚轮按住的方法 + public void startMouseScrollHold(int scrollDirection) { + // 立即发送一次滚轮事件(按下事件) + game.mouseVScroll((byte) scrollDirection); + + // 创建持续滚动的Runnable + final int direction = scrollDirection; + Runnable scrollRunnable = new Runnable() { + @Override + public void run() { + // 发送重复的滚轮事件(按住事件) + game.mouseVScroll((byte) direction); + // 继续安排下一次滚动 + handler.postDelayed(this, MOUSE_SCROLL_REPEAT_INTERVAL); + } + }; + + // 取消之前相同方向的滚动任务 + if (mouseScrollRunnableMap.containsKey(scrollDirection)) { + handler.removeCallbacks(mouseScrollRunnableMap.get(scrollDirection)); + } + + // 存储新的滚动任务 + mouseScrollRunnableMap.put(scrollDirection, scrollRunnable); + + // 安排首次重复滚动(延迟执行,区分按下和按住) + handler.postDelayed(scrollRunnable, MOUSE_SCROLL_INITIAL_DELAY); + } + + // 添加停止滚轮按住的方法 + public void stopMouseScrollHold(int scrollDirection) { + if (mouseScrollRunnableMap.containsKey(scrollDirection)) { + handler.removeCallbacks(mouseScrollRunnableMap.get(scrollDirection)); + mouseScrollRunnableMap.remove(scrollDirection); + } + } + + // 辅助方法,创建一个什么都不做的安全处理器 + private SendEventHandler createEmptyHandler() { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + } + + @Override + public void sendEvent(int analog1, int analog2) { + } + }; + } + + + public SendEventHandler getSendEventHandler(String key) { + if (key.matches("k\\d+")) { + + int keyCode = Integer.parseInt(key.substring(1)); + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + sendKeyEvent(down, (short) keyCode); + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + + } else if (key.matches("m\\d+")) { + int mouseCode = Integer.parseInt(key.substring(1)); + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + sendMouseEvent(mouseCode, down); + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + + } else if (key.startsWith("gb")) { + // 前缀是 "gb",说明这一定是一个组按键的ID + String idString = key.substring(2); // 从第3个字符开始截取 (跳过 "gb") + try { + long elementId = Long.parseLong(idString); + Element element = findElementById(elementId); + + if (element instanceof GroupButton) { + final GroupButton groupButton = (GroupButton) element; + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + // 在UI线程上执行按钮的动作 + handler.post(groupButton::triggerAction); + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + // GroupButton 不处理模拟量事件 + } + }; + } else { + // 找到了ID,但它不是GroupButton,或者在加载期间没找到(返回null) + // 这是一个无效的配置,返回一个安全的空处理器 + LimeLog.warning("EventHandler:" + "Invalid GroupButton configuration for key: " + key); + return createEmptyHandler(); + } + } catch (NumberFormatException e) { + // ID部分无法解析为long,无效配置 + LimeLog.warning("EventHandler:" + "Failed to parse GroupButton ID for key: " + key + e); + return createEmptyHandler(); + } + + } else if (key.matches("g\\d+")) { + int padCode = Integer.parseInt(key.substring(1)); + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + gamepadInputContext.inputMap |= padCode; + } else { + gamepadInputContext.inputMap &= ~padCode; + } + sendGamepadEvent(); + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + + } else if (key.equals(SPECIAL_KEY_GAMEPAD_LEFT_STICK)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + + } + + @Override + public void sendEvent(int analog1, int analog2) { + gamepadInputContext.leftStickX = (short) analog1; + gamepadInputContext.leftStickY = (short) analog2; + sendGamepadEvent(); + } + }; + } else if (key.equals(SPECIAL_KEY_GAMEPAD_RIGHT_STICK)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + + } + + @Override + public void sendEvent(int analog1, int analog2) { + gamepadInputContext.rightStickX = (short) analog1; + gamepadInputContext.rightStickY = (short) analog2; + sendGamepadEvent(); + } + }; + } else if (key.equals(SPECIAL_KEY_GAMEPAD_LEFT_TRIGGER)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + gamepadInputContext.leftTrigger = (byte) 0xFF; + } else { + gamepadInputContext.leftTrigger = (byte) 0; + } + sendGamepadEvent(); + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_GAMEPAD_RIGHT_TRIGGER)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + gamepadInputContext.rightTrigger = (byte) 0xFF; + } else { + gamepadInputContext.rightTrigger = (byte) 0; + } + sendGamepadEvent(); + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_MOUSE_SCROLL_UP)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + startMouseScrollHold(1); // 开始向上滚动按住 + } else { + stopMouseScrollHold(1); // 停止向上滚动按住 + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_MOUSE_SCROLL_DOWN)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + startMouseScrollHold(-1); // 开始向下滚动按住 + } else { + stopMouseScrollHold(-1); // 停止向下滚动按住 + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_NULL)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_MOUSE_MODE_SWITCH)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + // 获取当前设置 + boolean touchMode = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(false))); + boolean enhancedTouch = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(false))); + + // 确定当前模式并切换到下一个模式 + // 模式1: 经典鼠标模式 (touchMode=false, enhancedTouch=false) + // 模式2: 多点触控模式 (touchMode=false, enhancedTouch=true) + // 模式3: 触控板模式 (touchMode=true, enhancedTouch=false) + + ContentValues contentValues = new ContentValues(); + + if (!touchMode && !enhancedTouch) { + // 当前是经典鼠标模式 -> 切换到多点触控模式 + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(false)); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(true)); + controllerManager.getTouchController().setTouchMode(false); + controllerManager.getTouchController().setEnhancedTouch(true); + showToast("多点触控模式"); + } else if (!touchMode && enhancedTouch) { + // 当前是多点触控模式 -> 切换到触控板模式 + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(true)); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(false)); + controllerManager.getTouchController().setTouchMode(true); + controllerManager.getTouchController().setEnhancedTouch(false); + showToast("触控板模式"); + } else { + // 当前是触控板模式 -> 切换到经典鼠标模式 + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(false)); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(false)); + controllerManager.getTouchController().setTouchMode(false); + controllerManager.getTouchController().setEnhancedTouch(false); + showToast("经典鼠标模式"); + } + + // 保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + } + + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_CLASSIC_MOUSE_SWITCH)) { + return switchMouseMode(false, false, "经典鼠标模式"); + } else if (key.equals(SPECIAL_KEY_TRACKPAD_MODE)) { + return switchMouseMode(true, false, "触控板模式"); + } else if (key.equals(SPECIAL_KEY_MULTI_TOUCH_MODE)) { + return switchMouseMode(false, true, "多点触控模式"); + } else if (key.equals(SPECIAL_KEY_PC_KEYBOARD_SWITCH)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + controllerManager.getKeyboardUIController().toggle(); + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_ANDROID_KEYBOARD_SWITCH)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + game.toggleKeyboard(); + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_CONFIG_SWITCH)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (!down) { + return; + } + + try { + // 读取所有配置ID与名称 + List configIds = controllerManager.getSuperConfigDatabaseHelper().queryAllConfigIds(); + List configNames = new ArrayList<>(); + for (Long id : configIds) { + String name = (String) controllerManager.getSuperConfigDatabaseHelper() + .queryConfigAttribute(id, PageConfigController.COLUMN_STRING_CONFIG_NAME, "default"); + configNames.add(name); + } + + // 弹出选择对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.AppDialogStyle); + builder.setTitle("选择配置") + .setItems(configNames.toArray(new String[0]), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (which < 0 || which >= configIds.size()) return; + Long newConfigId = configIds.get(which); + String newName = configNames.get(which); + + // 保存到首选项 + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putLong("current_config_id", newConfigId) + .apply(); + + // 刷新配置与元素 + try { + controllerManager.getPageConfigController().initConfig(); + } catch (Exception ignored) {} + loadAllElement(newConfigId); + + showToast("已切换到: " + newName); + } + }) + .setNegativeButton(R.string.game_menu_cancel, null) + .show(); + } catch (Exception e) { + Toast.makeText(context, "无法加载配置列表", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + } + }; + } else if (key.equals(SPECIAL_KEY_PAN_ZOOM_MODE)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + Toast.makeText(game, game.getisTouchOverrideEnabled()?"已关闭平移/缩放":"已开启平移/缩放", Toast.LENGTH_SHORT).show(); + game.setisTouchOverrideEnabled(!game.getisTouchOverrideEnabled()); + } + } + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } else if (key.equals(SPECIAL_KEY_OPEN_GAME_MENU)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + game.showGameMenu( null); + } + } + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } + else if (key.equals(SPECIAL_KEY_MOUSE_ENABLE_SWITCH)) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + boolean mouseEnable = Boolean.parseBoolean((String) controllerManager.getSuperConfigDatabaseHelper().queryConfigAttribute(currentConfigId, PageConfigController.COLUMN_BOOLEAN_TOUCH_ENABLE, String.valueOf(true))); + ContentValues contentValues = new ContentValues(); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_ENABLE, String.valueOf(!mouseEnable)); + //保存到数据库中 + controllerManager.getSuperConfigDatabaseHelper().updateConfig(currentConfigId, contentValues); + //做实际的设置 + controllerManager.getTouchController().enableTouch(!mouseEnable); + if (!mouseEnable) { + showToast("开启触控"); + } else { + showToast("关闭触控"); + } + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } + return null; + } + + //鼠标模式切换 + private SendEventHandler switchMouseMode(boolean touchMode, boolean enhancedTouch, String toastMessage) { + return new SendEventHandler() { + @Override + public void sendEvent(boolean down) { + if (down) { + ContentValues contentValues = new ContentValues(); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_TOUCH_MODE, String.valueOf(touchMode)); + contentValues.put(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, String.valueOf(enhancedTouch)); + controllerManager.getTouchController().setTouchMode(touchMode); + controllerManager.getTouchController().setEnhancedTouch(enhancedTouch); + showToast(toastMessage); + controllerManager.getSuperConfigDatabaseHelper(). + + updateConfig(currentConfigId, contentValues); + } + } + + @Override + public void sendEvent(int analog1, int analog2) { + + } + }; + } + + + public void sendKeyEvent(boolean buttonDown, short keyCode) { + game.keyboardEvent(buttonDown, keyCode); + //如果map中有对应按键的runnable,则删除该按键的runnable。 + if (keyEventRunnableMap.containsKey(keyCode)) { + handler.removeCallbacks(keyEventRunnableMap.get(keyCode)); + } + Runnable runnable = new Runnable() { + @Override + public void run() { + game.keyboardEvent(buttonDown, keyCode); + } + }; + //把这个按键的runnable放到map中,以便这个按键重新发送的时候,重置runnable。 + keyEventRunnableMap.put(keyCode, runnable); + + + handler.postDelayed(runnable, 50); + handler.postDelayed(runnable, 75); + } + + public void sendMouseEvent(int mouseId, boolean down) { + game.mouseButtonEvent(mouseId, down); + if (mouseEventRunnableMap.containsKey(mouseId)) { + handler.removeCallbacks(mouseEventRunnableMap.get(mouseId)); + } + Runnable runnable = new Runnable() { + @Override + public void run() { + game.mouseButtonEvent(mouseId, down); + } + }; + //把这个按键的runnable放到map中,以便这个按键重新发送的时候,重置runnable。 + mouseEventRunnableMap.put(mouseId, runnable); + + handler.postDelayed(runnable, 50); + handler.postDelayed(runnable, 75); + } + + public void sendMouseScroll(int scrollDirection) { + game.mouseVScroll((byte) scrollDirection); + Runnable runnable = new Runnable() { + @Override + public void run() { + game.mouseVScroll((byte) scrollDirection); + } + }; + handler.postDelayed(runnable, 50); + handler.postDelayed(runnable, 75); + } + + public void sendGamepadEvent() { + controllerHandler.reportOscState( + gamepadInputContext.inputMap, + gamepadInputContext.leftStickX, + gamepadInputContext.leftStickY, + gamepadInputContext.rightStickX, + gamepadInputContext.rightStickY, + gamepadInputContext.leftTrigger, + gamepadInputContext.rightTrigger + ); + + Runnable runnable = new Runnable() { + @Override + public void run() { + controllerHandler.reportOscState( + gamepadInputContext.inputMap, + gamepadInputContext.leftStickX, + gamepadInputContext.leftStickY, + gamepadInputContext.rightStickX, + gamepadInputContext.rightStickY, + gamepadInputContext.leftTrigger, + gamepadInputContext.rightTrigger + ); + } + }; + handler.postDelayed(runnable, 50); + handler.postDelayed(runnable, 75); + + + } + + public void setButtonVibrator(boolean buttonVibrator) { + this.buttonVibrator = buttonVibrator; + } + + public void setGameVibrator(boolean gameVibrator) { + this.gameVibrator = gameVibrator; + } + + public void buttonVibrator() { + if (buttonVibrator) { + rumbleSingleVibrator((short) 1000, (short) 1000, 50); + } + } + + public void gameVibrator(short lowFreqMotor, short highFreqMotor) { + if (gameVibrator) { + rumbleSingleVibrator(lowFreqMotor, highFreqMotor, 60000); + } + } + + + private void rumbleSingleVibrator(short lowFreqMotor, short highFreqMotor, int vibratorTime) { + // Since we can only use a single amplitude value, compute the desired amplitude + // by taking 80% of the big motor and 33% of the small motor, then capping to 255. + // NB: This value is now 0-255 as required by VibrationEffect. + short lowFreqMotorMSB = (short) ((lowFreqMotor >> 8) & 0xFF); + short highFreqMotorMSB = (short) ((highFreqMotor >> 8) & 0xFF); + int simulatedAmplitude = Math.min(255, (int) ((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33))); + + if (simulatedAmplitude == 0) { + // This case is easy - just cancel the current effect and get out. + // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0 + // because our simulatedAmplitude could be 0 even though our inputs + // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1). + deviceVibrator.cancel(); + return; + } + + // Attempt to use amplitude-based control if we're on Oreo and the device + // supports amplitude-based vibration control. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (deviceVibrator.hasAmplitudeControl()) { + VibrationEffect effect = VibrationEffect.createOneShot(vibratorTime, simulatedAmplitude); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_MEDIA) + .build(); + deviceVibrator.vibrate(effect, vibrationAttributes); + } else { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + deviceVibrator.vibrate(effect, audioAttributes); + } + return; + } + } + + // If we reach this point, we don't have amplitude controls available, so + // we must emulate it by PWMing the vibration. Ick. + long pwmPeriod = 20; + long onTime = (long) ((simulatedAmplitude / 255.0) * pwmPeriod); + long offTime = pwmPeriod - onTime; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_MEDIA) + .build(); + deviceVibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + deviceVibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); + } else { + deviceVibrator.vibrate(new long[]{0, onTime, offTime}, 0); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/GroupButton.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/GroupButton.java new file mode 100644 index 0000000000..0f54d97741 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/GroupButton.java @@ -0,0 +1,1508 @@ +//组按键 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.Toast; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.binding.input.advance_setting.superpage.SuperPagesController; +import com.limelight.utils.ColorPickerDialog; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class GroupButton extends Element { + + private static final String COLUMN_INT_CHILD_VISIBILITY = COLUMN_INT_ELEMENT_SENSE; + + private static final int CHILD_VISIBLE = VISIBLE; + private static final int CHILD_INVISIBLE = INVISIBLE; + + /** + * Listener interface to update registered observers. + */ + public interface GroupButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private PageDeviceController pageDeviceController; + private GroupButton groupButton; + private List childElementList = new ArrayList<>(); + private ElementController elementController; + private Context context; + private SuperPagesController superPagesController; + + private GroupButtonListener listener; + private String text; + private String value; + private int radius; + private int thick; + private int childVisibility; + private int normalColor; + private int pressedColor; + private int backgroundColor; + // New member variables for text properties + private int normalTextColor; + private int pressedTextColor; + private int textSizePercent; + + private float lastX; + private float lastY; + private boolean childPositionAttributeFollow = true; + private boolean childOtherAttributeFollow = false; + private final int initialCentralXMax; + private final int initialCentralXMin; + private final int initialCentralYMax; + private final int initialCentralYMin; + + private SuperPageLayout groupButtonPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + private boolean selectMode = false; + + private boolean movable = false; + private boolean layoutComplete = true; + private boolean resizeXBorder = true; + private boolean resizeYBorder = true; + + private long timerLongClickTimeout = 250; + private final Runnable longClickRunnable = this::onLongClickCallback; + private final Paint paintBorder = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + private boolean hidden = false; + private boolean movableInNormalMode = false; + private boolean userHasManuallySet = false; + private boolean isPermanentlyIndependent = false; + + // 在编辑模式下,如果元素被按住超过系统定义的长按时间,就允许拖动, + // 以避免用户想打开按键设置而不是移动按键 + private static final long DRAG_EDIT_LONG_PRESS_TIMEOUT = 250; + private boolean longPressDetected = false; + private final Runnable longPressRunnable = new Runnable() { + @Override + public void run() { + longPressDetected = true; + // 可以添加视觉反馈,比如改变边框颜色 + editColor = 0xff00f91a; // 绿色表示可以拖动 + invalidate(); + } + }; + + + public GroupButton(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, + SuperPagesController superPagesController, + Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.groupButton = this; + this.elementController = controller; + this.context = context; + this.superPagesController = superPagesController; + + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + initialCentralXMax = displayMetrics.widthPixels; + initialCentralXMin = 0; + initialCentralYMax = displayMetrics.heightPixels; + initialCentralYMin = 0; + super.centralXMax = initialCentralXMax; + super.centralXMin = initialCentralXMin; + super.centralYMax = initialCentralYMax; + super.centralYMin = initialCentralYMin; + super.widthMax = displayMetrics.widthPixels / 2; + super.widthMin = 50; + super.heightMax = displayMetrics.heightPixels / 2; + super.heightMin = 50; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.CENTER); + paintBorder.setStyle(Paint.Style.STROKE); + paintBackground.setStyle(Paint.Style.FILL); + + + text = (String) attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + childVisibility = ((Long) attributesMap.get(COLUMN_INT_CHILD_VISIBILITY)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); // 仅仅读取 value 字符串,后续的链接操作将由 linkChildElements 方法完成 + + // Load new text properties with backward compatibility + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)) { + normalTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + normalTextColor = normalColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)) { + pressedTextColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR)).intValue(); + } else { + // Default to old behavior: use border color + pressedTextColor = pressedColor; + } + + if (attributesMap.containsKey(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)) { + textSizePercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT)).intValue(); + } else { + // Default based on a reasonable value, old logic was complex + textSizePercent = 25; + } + + Object hiddenFlagObj = attributesMap.get(COLUMN_INT_ELEMENT_FLAG1); + this.hidden = (hiddenFlagObj != null) && ((Long) hiddenFlagObj).intValue() == 1; + + this.movableInNormalMode = false; + this.userHasManuallySet = false; + this.isPermanentlyIndependent = false; + if (attributesMap.containsKey(COLUMN_STRING_EXTRA_ATTRIBUTES)) { + String extraAttrsJson = (String) attributesMap.get(COLUMN_STRING_EXTRA_ATTRIBUTES); + if (extraAttrsJson != null && !extraAttrsJson.isEmpty()) { + try { // 使用 try-catch 增加代码健壮性 + JsonObject extraAttrs = JsonParser.parseString(extraAttrsJson).getAsJsonObject(); + if (extraAttrs.has("movableInNormalMode")) { + this.movableInNormalMode = extraAttrs.get("movableInNormalMode").getAsBoolean(); + } + // 加载 userHasManuallySet 状态 + if (extraAttrs.has("userHasManuallySet")) { + this.userHasManuallySet = extraAttrs.get("userHasManuallySet").getAsBoolean(); + } + // 加载永久独立状态 + if (extraAttrs.has("isPermanentlyIndependent")) { + this.isPermanentlyIndependent = extraAttrs.get("isPermanentlyIndependent").getAsBoolean(); + } + } catch (Exception e) { + } + } + } + + listener = new GroupButtonListener() { + @Override + public void onClick() { + } + + @Override + public void onLongClick() { + } + + @Override + public void onRelease() { + // 当按钮被释放时,调用公共的触发方法 + triggerAction(); + } + }; + // 在构造函数的最后,根据当前模式设置初始可见性 + onModeChanged(controller.getMode()); + } + + public void linkChildElements(List allElements) { + if (value == null || value.isEmpty()) { + return; + } + + // 创建一个查找表 (Map) + java.util.Map elementMap = new java.util.HashMap<>(); + for (Element el : allElements) { + elementMap.put(el.elementId, el); + } + + String[] childElementIds = value.split(","); + childElementList.clear(); + + for (String childElementIdString : childElementIds) { + if (childElementIdString.equals("-1")) continue; + try { + Long childElementId = Long.parseLong(childElementIdString); + // 直接从 Map 中获取,效率更高 + Element child = elementMap.get(childElementId); + if (child != null) { + childElementList.add(child); + } + } catch (NumberFormatException e) { + // 忽略无效ID + } + } + + // 现在所有子元素都已链接,可以安全地设置它们的初始可见性了 + setElementChildVisibility(childVisibility); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + layoutComplete = true; + super.onLayout(changed, left, top, right, bottom); + } + + @Override + protected void onElementDraw(Canvas canvas) { + + // Get element dimensions + int elementWidth = getElementWidth(); + int elementHeight = getElementHeight(); + + // Set text size based on percentage of height + float textSize = getPercent(elementHeight, textSizePercent); + paintText.setTextSize(textSize); + // Set text color based on press state using new properties + paintText.setColor(isPressed() ? pressedTextColor : normalTextColor); + // Border + paintBorder.setStrokeWidth(thick); + paintBorder.setColor(isPressed() ? pressedColor : normalColor); + // Background color + paintBackground.setColor(backgroundColor); + + float centerX = elementWidth / 2f; + // Calculate the baseline Y for vertical centering + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float baselineY = elementHeight / 2f - (fontMetrics.top + fontMetrics.bottom) / 2f; + + // Drawing bounds + rect.left = rect.top = (float) thick / 2; + rect.right = getElementWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + // Draw background + canvas.drawRoundRect(rect, radius, radius, paintBackground); + // Draw border + canvas.drawRoundRect(rect, radius, radius, paintBorder); + // Draw text using the calculated precise coordinates + canvas.drawText(text, centerX, baselineY, paintText); + + ElementController.Mode currentMode = elementController.getMode(); + + if (currentMode == ElementController.Mode.Edit || currentMode == ElementController.Mode.Select) { + // 准备绘制边框 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + + if (currentMode == ElementController.Mode.Select && selectMode) { + // 情况1: 当前是 Select 模式,并且这是正在操作的那个 GroupButton + // 强制使用绿色边框 + paintEdit.setColor(0xff00f91a); + } else { + // 情况2: 是 Edit 模式,或者是在 Select 模式下的其他 GroupButton + // (作为“可选”或“已选”项) + // 统一使用 onModeChanged 中设置好的 editColor (红色、橙色或蓝色) + paintEdit.setColor(editColor); + } + + // 执行绘制 + canvas.drawRect(rect, paintEdit); + } + } + + private void onClickCallback() { + // notify listenersbuttonListener.onClick(); + listener.onClick(); + elementController.getHandler().removeCallbacks(longClickRunnable); + elementController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + } + + private void onLongClickCallback() { + // notify listeners + listener.onLongClick(); + if (elementController.getMode() == ElementController.Mode.Normal && movableInNormalMode) { + elementController.buttonVibrator(); + movable = true; + if (childPositionAttributeFollow) { + for (Element element : childElementList) { + element.setAlpha(0.5f); + } + } + invalidate(); + } + } + + private void onReleaseCallback() { + // notify listeners + listener.onRelease(); + // We may be called for a release without a prior click + elementController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionIndex() != 0) return true; + + switch (elementController.getMode()) { + case Normal: + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + elementController.buttonVibrator(); + resizeXBorder = true; + resizeYBorder = true; + lastX = event.getX(); + lastY = event.getY(); + setPressed(true); + onClickCallback(); + invalidate(); + return true; + + case MotionEvent.ACTION_MOVE: + if (movable) { + float deltaX = event.getX() - lastX; + float deltaY = event.getY() - lastY; + //小位移算作点击 + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.2) return true; + + if (layoutComplete) { + layoutComplete = false; + setElementCentralX((int) getX() + getWidth() / 2 + (int) deltaX); + setElementCentralY((int) getY() + getHeight() / 2 + (int) deltaY); + } + updatePage(); + } + return true; + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + setPressed(false); + if (movable) { + if (childPositionAttributeFollow) { + for (Element element : childElementList) { + element.setAlpha(1); + } + } + movable = false; + save(); + } else { + onReleaseCallback(); + } + invalidate(); + return true; + } + break; + + case Edit: + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + resizeXBorder = true; + resizeYBorder = true; + lastX = event.getX(); + lastY = event.getY(); + movable = false; // 重置移动标志 + longPressDetected = false; // 重置长按标志 + editColor = 0xffdc143c; // 红色表示初始状态 + invalidate(); + + // 启动长按检测 + elementController.getHandler().removeCallbacks(longPressRunnable); + elementController.getHandler().postDelayed(longPressRunnable, DRAG_EDIT_LONG_PRESS_TIMEOUT); + return true; + + case MotionEvent.ACTION_MOVE: + float deltaX = event.getX() - lastX; + float deltaY = event.getY() - lastY; + + // 小位移算作点击 + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.2) { + return true; + } + + // 只有检测到长按或关闭长按移动后才允许拖动 + if (!elementController.isDragEditEnabled() | longPressDetected) { + movable = true; + if (layoutComplete) { + layoutComplete = false; + setElementCentralX((int) getX() + getWidth() / 2 + (int) deltaX); + setElementCentralY((int) getY() + getHeight() / 2 + (int) deltaY); + } + updatePage(); + } + return true; + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + // 取消长按检测 + elementController.getHandler().removeCallbacks(longPressRunnable); + + if (movable) { + save(); + movable = false; + } else { + elementController.toggleInfoPage(getInfoPage()); + } + editColor = 0xffdc143c; + invalidate(); + return true; + } + break; + default: + return super.onTouchEvent(event); + } + return true; + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + return true; + } + + @Override + public void save() { + // 默认的 save() 应该是递归的,因为它在大多数情况下(拖动、设置)都需要 + saveHierarchy(); + } + + // 一个只保存自己的方法 + private void saveSelfOnly() { + // 保存当前 GroupButton 自身的状态 --- + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, text); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_INT_CHILD_VISIBILITY, childVisibility); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + contentValues.put(COLUMN_INT_ELEMENT_FLAG1, hidden ? 1 : 0); // hidden remains in its own column + // Save new text properties + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, normalTextColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, pressedTextColor); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, textSizePercent); + + JsonObject extraAttrs = new JsonObject(); + extraAttrs.addProperty("movableInNormalMode", this.movableInNormalMode); + extraAttrs.addProperty("userHasManuallySet", this.userHasManuallySet); + extraAttrs.addProperty("isPermanentlyIndependent", this.isPermanentlyIndependent); + contentValues.put(COLUMN_STRING_EXTRA_ATTRIBUTES, new Gson().toJson(extraAttrs)); + + elementController.updateElement(elementId, contentValues); + } + + // 一个明确的递归保存方法 + private void saveHierarchy() { + // 步骤1:先保存自己 + saveSelfOnly(); + + // 步骤2:然后命令所有子元素也进行保存 + if (childElementList != null) { + for (Element child : childElementList) { + // 直接调用子元素的公共 save() 方法。 + // 如果子元素是另一个 GroupButton,它会触发它自己的递归 saveHierarchy。 + // 如果是普通按钮,就执行它自己的保存逻辑。 + child.save(); + } + } + } + + @Override + protected void updatePage() { + if (groupButtonPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + } + + @Override + protected SuperPageLayout getInfoPage() { + if (groupButtonPage == null) { + groupButtonPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_group_button, null); + centralXNumberSeekbar = groupButtonPage.findViewById(R.id.page_group_button_central_x); + centralYNumberSeekbar = groupButtonPage.findViewById(R.id.page_group_button_central_y); + } + + setupFullInfoPage(groupButtonPage); + + return groupButtonPage; + } + + private void setupFullInfoPage(SuperPageLayout page) { + // Find views + NumberSeekbar widthNumberSeekbar = page.findViewById(R.id.page_group_button_width); + NumberSeekbar heightNumberSeekbar = page.findViewById(R.id.page_group_button_height); + NumberSeekbar radiusNumberSeekbar = page.findViewById(R.id.page_group_button_radius); + CheckBox childPositonAttributeFollowCheckBox = page.findViewById(R.id.page_group_button_child_position_attribute_follow); + CheckBox childOtherAttributeFollowCheckBox = page.findViewById(R.id.page_group_button_child_other_attribute_follow); + CheckBox childVisibleCheckBox = page.findViewById(R.id.page_group_button_child_visible); + ElementEditText textElementEditText = page.findViewById(R.id.page_group_button_text); + NumberSeekbar thickNumberSeekbar = page.findViewById(R.id.page_group_button_thick); + NumberSeekbar layerNumberSeekbar = page.findViewById(R.id.page_group_button_layer); + ElementEditText normalColorElementEditText = page.findViewById(R.id.page_group_button_normal_color); + ElementEditText pressedColorElementEditText = page.findViewById(R.id.page_group_button_pressed_color); + ElementEditText backgroundColorElementEditText = page.findViewById(R.id.page_group_button_background_color); + EditText deleteEditText = page.findViewById(R.id.page_group_button_delete_edittext); + Button deleteButton = page.findViewById(R.id.page_group_button_delete); + NumberSeekbar textSizeNumberSeekbar = page.findViewById(R.id.page_group_button_text_size); + ElementEditText normalTextColorElementEditText = page.findViewById(R.id.page_group_button_normal_text_color); + ElementEditText pressedTextColorElementEditText = page.findViewById(R.id.page_group_button_pressed_text_color); + Switch hiddenSwitch = page.findViewById(R.id.page_group_button_hidden_switch); + Switch movableInNormalSwitch = page.findViewById(R.id.page_group_button_movable_in_normal_switch); + + // Setup listeners + textElementEditText.setTextWithNoTextChangedCallBack(text); + textElementEditText.setOnTextChangedListener(newText -> { + setElementText(newText); + save(); + }); + + childPositonAttributeFollowCheckBox.setChecked(childPositionAttributeFollow); + childPositonAttributeFollowCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + childPositionAttributeFollow = isChecked; + if (!childPositionAttributeFollow) { + centralXMax = initialCentralXMax; + centralXMin = initialCentralXMin; + centralYMax = initialCentralYMax; + centralYMin = initialCentralYMin; + } + }); + + childOtherAttributeFollowCheckBox.setChecked(childOtherAttributeFollow); + childOtherAttributeFollowCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> childOtherAttributeFollow = isChecked); + + childVisibleCheckBox.setChecked(childVisibility == CHILD_VISIBLE); + childVisibleCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + setElementChildVisibility(isChecked ? CHILD_VISIBLE : CHILD_INVISIBLE); + save(); + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (layoutComplete) { + layoutComplete = false; + setElementCentralX(progress); + } + } + + public void onStartTrackingTouch(SeekBar seekBar) { + resizeXBorder = true; + } + + public void onStopTrackingTouch(SeekBar seekBar) { + // 只需调用自身的 save(),因为它现在是递归的,会自动处理所有子孙 + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (layoutComplete) { + layoutComplete = false; + setElementCentralY(progress); + } + } + + public void onStartTrackingTouch(SeekBar seekBar) { + resizeYBorder = true; + } + + public void onStopTrackingTouch(SeekBar seekBar) { + // 只需调用自身的 save(),因为它现在是递归的,会自动处理所有子孙 + save(); + } + }); + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (layoutComplete) { + layoutComplete = false; + setElementWidth(progress); + } + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + + save(); + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (layoutComplete) { + layoutComplete = false; + setElementHeight(progress); + } + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + + save(); + } + }); + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + + save(); + } + }); + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + + save(); + } + }); + + textSizeNumberSeekbar.setProgressMin(10); + textSizeNumberSeekbar.setProgressMax(150); + textSizeNumberSeekbar.setValueWithNoCallBack(textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSizePercent(progress); + } + + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onStopTrackingTouch(SeekBar seekBar) { + + save(); + } + }); + + setupColorPickerButton(normalColorElementEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorElementEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, this::setElementNormalTextColor); + setupColorPickerButton(pressedTextColorElementEditText, () -> this.pressedTextColor, this::setElementPressedTextColor); + + hiddenSwitch.setChecked(hidden); + hiddenSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> setHidden(isChecked)); + + movableInNormalSwitch.setChecked(movableInNormalMode); + movableInNormalSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + movableInNormalMode = isChecked; + save(); + }); + + page.findViewById(R.id.page_group_button_select_child_button).setOnClickListener(v -> { + elementController.changeMode(ElementController.Mode.Select); + selectMode = true; + ElementSelectedCallBack elementSelectedCallBack = element -> { + // 如果点击的元素已经是子元素,则将其移除 + if (childElementList.contains(element)) { + deleteChildElement(element); + element.setEditColor(EDIT_COLOR_SELECT); + } else { + // 准备添加新元素前,检查该元素是否为本组按键自身 + if (element == groupButton) { + // 如果是自身,则提示用户不能添加,并终止操作 + Toast.makeText(context, "不能将组按键添加到自身", Toast.LENGTH_SHORT).show(); + } else { + // 如果不是自身,则正常添加为子元素 + addChildElement(element); + element.setEditColor(EDIT_COLOR_SELECTED); + } + } + element.invalidate(); + }; + for (Element element : childElementList) element.setEditColor(EDIT_COLOR_SELECTED); + for (Element element : elementController.getElements()) + element.setElementSelectedCallBack(elementSelectedCallBack); + + SuperPageLayout pageNull = superPagesController.getPageNull(); + superPagesController.openNewPage(pageNull); + pageNull.setPageReturnListener(() -> { + SuperPageLayout lastPage = pageNull.getLastPage(); + elementController.open(); + superPagesController.openNewPage(lastPage); + elementController.changeMode(ElementController.Mode.Edit); + selectMode = false; + save(); + }); + }); + + Switch permanentIndependentSwitch = page.findViewById(R.id.page_group_button_override_parent_switch); + // 根据新的永久状态变量设置 Switch 的初始值 + permanentIndependentSwitch.setChecked(isPermanentlyIndependent); + permanentIndependentSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + isPermanentlyIndependent = isChecked; // 控制新的永久状态变量 + save(); // 保存更改 + if (isChecked) { + Toast.makeText(context, "此组按键将永久保持独立状态", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(context, "此组按键将重新跟随父级", Toast.LENGTH_SHORT).show(); + } + }); + + deleteButton.setOnClickListener(v -> { + if (deleteEditText.getText().toString().equals("DELETE")) { + List allElement = new ArrayList<>(elementController.getElements()); + for (Element element : childElementList) { + if (allElement.contains(element)) { + elementController.deleteElement(element); + } + } + elementController.toggleInfoPage(groupButtonPage); + elementController.deleteElement(groupButton); + Toast.makeText(context, "删除成功", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void addChildElement(Element newElement) { + if (!childElementList.contains(newElement)) { + childElementList.add(newElement); + updateValueString(); + } + } + + private void deleteChildElement(Element deleteElement) { + if (childElementList.remove(deleteElement)) { + updateValueString(); + } + } + + private void updateValueString() { + StringBuilder newValue = new StringBuilder("-1"); + for (Element element : childElementList) { + newValue.append(",").append(element.elementId); + } + value = newValue.toString(); + } + + public void triggerAction() { + // 1. 标记这个按钮被用户直接点击了 + this.userHasManuallySet = true; + + // 2. 计算出这次点击的目标状态 (显示/隐藏) + int targetVisibility = (this.childVisibility == CHILD_VISIBLE) ? CHILD_INVISIBLE : CHILD_VISIBLE; + + // 3. 直接对当前按键执行状态切换并保存 + // 命令发起者自身必须无条件执行 + setElementChildVisibility(targetVisibility); + saveSelfOnly(); + + // 4. 创建一个 visited 集合,并将自己加入,防止在复杂的嵌套中产生循环 + java.util.Set visited = new java.util.HashSet<>(); + visited.add(this); + + // 5. 开始递归广播,让所有子孙 GroupButton 根据它们自己的状态决定是否执行 + if (childElementList != null) { + for (Element child : childElementList) { + if (child instanceof GroupButton) { + ((GroupButton) child).performTriggerAction(visited, targetVisibility); + } + } + } + } + + private void performTriggerAction(java.util.Set visited, int commandVisibility) { + // 如果此组按键被设置为永久独立,则它会完全忽略来自父级的任何命令。 + if (this.isPermanentlyIndependent) { + return; + } + + if (visited.contains(this)) return; + visited.add(this); + + boolean shouldExecute = false; + + if (commandVisibility == CHILD_INVISIBLE) { + // 命令是“隐藏”:无条件执行,并重置手动设置标志 + shouldExecute = true; + this.userHasManuallySet = false; // 重置状态,使其下次可以跟随父级的“显示”命令 + } else { + // 命令是“显示”:只有在未被手动设置过的情况下才执行 + if (!this.userHasManuallySet) { + shouldExecute = true; + } + } + + if (shouldExecute) { + setElementChildVisibility(commandVisibility); + // 调用只保存自己的方法,避免覆盖子孙状态 + saveSelfOnly(); + + if (childElementList != null) { + for (Element child : childElementList) { + if (child instanceof GroupButton) { + ((GroupButton) child).performTriggerAction(visited, commandVisibility); + } + } + } + } + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + onModeChanged(elementController.getMode()); + save(); + } + + @Override + public void onModeChanged(ElementController.Mode newMode) { + super.onModeChanged(newMode); + + // 首先处理可见性逻辑,确保在不同模式下显示正确 + setVisibility(newMode == ElementController.Mode.Normal && hidden ? INVISIBLE : VISIBLE); + + // 然后处理边框颜色的逻辑 + switch (newMode) { + case Select: + // 当进入“选择”模式时,将此组按键的边框颜色设置为“可选择”状态(橙色)。 + // G1稍后会把它自己的子元素(可能是G2)的颜色覆盖为“已选中”(蓝色)。 + setEditColor(EDIT_COLOR_SELECT); + break; + case Edit: + // 当返回“编辑”模式时,恢复为默认的编辑颜色(红色)。 + setEditColor(EDIT_COLOR_EDIT); + break; + case Normal: + // 在正常模式下,重置状态。 + setEditColor(EDIT_COLOR_EDIT); + break; + } + // 强制重绘以更新边框 + invalidate(); + } + + public String getText() { + return this.text; + } + + + /** + * 获取此组按键包含的所有子元素的ID列表。 + * 这个公共方法是为了让其他组件(如WheelPad)能够查询组的内容以实现预览等功能。 + * + * @return 一个包含所有子元素ID的列表。 + */ + public List getChildIds() { + List childIds = new ArrayList<>(); + if (childElementList != null) { + for (Element child : childElementList) { + if (child != null) childIds.add(child.elementId); + } + } + return childIds; + } + + + protected void setElementText(String text) { + this.text = text; + invalidate(); + } + + @Override + protected void setElementCentralX(int centralX) { + if (childPositionAttributeFollow) { + int previousX = getElementCentralX(); + super.setElementCentralX(centralX); + int deltaX = getElementCentralX() - previousX; + for (Element element : childElementList) { + element.setElementCentralX(element.getElementCentralX() + deltaX); + } + if (resizeXBorder) { + int leftMargin = centralXMax; + int rightMargin = centralXMax; + List allElement = elementController.getElements(); + for (Element element : childElementList) { + if (!allElement.contains(element)) { + continue; + } + int elementCentralX = element.getElementCentralX(); + leftMargin = Math.min(elementCentralX - element.centralXMin, leftMargin); + rightMargin = Math.min(element.centralXMax - elementCentralX, rightMargin); + } + int elementCentralX = getElementCentralX(); + leftMargin = Math.min(elementCentralX - centralXMin, leftMargin); + rightMargin = Math.min(centralXMax - elementCentralX, rightMargin); + + centralXMin = elementCentralX - leftMargin; + centralXMax = elementCentralX + rightMargin; + if (centralXNumberSeekbar != null) { + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + } + resizeXBorder = false; + } + } else { + super.setElementCentralX(centralX); + } + + } + + @Override + protected void setElementCentralY(int centralY) { + if (childPositionAttributeFollow) { + int previousY = getElementCentralY(); + super.setElementCentralY(centralY); + int deltaY = getElementCentralY() - previousY; + for (Element element : childElementList) { + element.setElementCentralY(element.getElementCentralY() + deltaY); + } + if (resizeYBorder) { + int bottomMargin = centralYMax; + int topMargin = centralYMax; + List allElement = elementController.getElements(); + for (Element element : childElementList) { + if (!allElement.contains(element)) { + continue; + } + int elementCentralY = element.getElementCentralY(); + topMargin = Math.min(elementCentralY - element.centralYMin, topMargin); + bottomMargin = Math.min(element.centralYMax - elementCentralY, bottomMargin); + } + int elementCentralY = getElementCentralY(); + topMargin = Math.min(elementCentralY - centralYMin, topMargin); + bottomMargin = Math.min(centralYMax - elementCentralY, bottomMargin); + + centralYMin = elementCentralY - topMargin; + centralYMax = elementCentralY + bottomMargin; + if (centralYNumberSeekbar != null) { + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + } + resizeYBorder = false; + } + } else { + super.setElementCentralY(centralY); + } + + } + + @Override + protected void setElementWidth(int width) { + super.setElementWidth(width); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementWidth(width); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + element.setElementWidth(width); + break; + default: + break; + } + } + } + } + } + + @Override + protected void setElementHeight(int height) { + super.setElementHeight(height); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementHeight(height); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + element.setElementHeight(height); + break; + default: + break; + } + } + } + } + } + + protected void setElementChildVisibility(int childVisibility) { + this.childVisibility = childVisibility; + for (Element element : childElementList) { + element.setVisibility(childVisibility); + } + } + + protected void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementRadius(radius); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementRadius(radius); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementRadius(radius); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementRadius(radius); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementRadius(radius); + break; + default: + break; + } + } + } + } + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementThick(thick); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementThick(thick); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementThick(thick); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementThick(thick); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementThick(thick); + break; + case ELEMENT_TYPE_DIGITAL_PAD: + ((DigitalPad) element).setElementThick(thick); + break; + case ELEMENT_TYPE_ANALOG_STICK: + ((AnalogStick) element).setElementThick(thick); + break; + case ELEMENT_TYPE_DIGITAL_STICK: + ((DigitalStick) element).setElementThick(thick); + break; + case ELEMENT_TYPE_INVISIBLE_ANALOG_STICK: + ((InvisibleAnalogStick) element).setElementThick(thick); + break; + case ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK: + ((InvisibleDigitalStick) element).setElementThick(thick); + break; + default: + break; + } + } + } + } + } + + @Override + protected void setElementLayer(int layer) { + super.setElementLayer(layer); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + // 直接调用子元素的 setElementLayer,因为 GroupButton 自己的这个方法 + // 已经被重写为递归的,所以能自动形成递归链。 + element.setElementLayer(layer); + } + } + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementNormalColor(normalColor); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_DIGITAL_PAD: + ((DigitalPad) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_ANALOG_STICK: + ((AnalogStick) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_DIGITAL_STICK: + ((DigitalStick) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_INVISIBLE_ANALOG_STICK: + ((InvisibleAnalogStick) element).setElementNormalColor(normalColor); + break; + case ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK: + ((InvisibleDigitalStick) element).setElementNormalColor(normalColor); + break; + default: + break; + } + } + } + } + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementPressedColor(pressedColor); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_DIGITAL_PAD: + ((DigitalPad) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_ANALOG_STICK: + ((AnalogStick) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_DIGITAL_STICK: + ((DigitalStick) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_INVISIBLE_ANALOG_STICK: + ((InvisibleAnalogStick) element).setElementPressedColor(pressedColor); + break; + case ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK: + ((InvisibleDigitalStick) element).setElementPressedColor(pressedColor); + break; + default: + break; + } + } + } + } + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementBackgroundColor(backgroundColor); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_DIGITAL_PAD: + ((DigitalPad) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_ANALOG_STICK: + ((AnalogStick) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_DIGITAL_STICK: + ((DigitalStick) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_INVISIBLE_ANALOG_STICK: + ((InvisibleAnalogStick) element).setElementBackgroundColor(backgroundColor); + break; + case ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK: + ((InvisibleDigitalStick) element).setElementBackgroundColor(backgroundColor); + break; + default: + break; + } + } + } + } + } + + protected void setElementNormalTextColor(int normalTextColor) { + this.normalTextColor = normalTextColor; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementNormalTextColor(normalTextColor); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementNormalTextColor(normalTextColor); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementNormalTextColor(normalTextColor); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementNormalTextColor(normalTextColor); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementNormalTextColor(normalTextColor); + break; + } + } + } + } + } + + protected void setElementPressedTextColor(int pressedTextColor) { + this.pressedTextColor = pressedTextColor; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementPressedTextColor(pressedTextColor); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementPressedTextColor(pressedTextColor); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementPressedTextColor(pressedTextColor); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementPressedTextColor(pressedTextColor); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementPressedTextColor(pressedTextColor); + break; + } + } + } + } + } + + protected void setElementTextSizePercent(int textSizePercent) { + this.textSizePercent = textSizePercent; + invalidate(); + if (childOtherAttributeFollow) { + for (Element element : childElementList) { + if (element instanceof GroupButton) { + ((GroupButton) element).setElementTextSizePercent(textSizePercent); + } else { + switch (element.elementType) { + case ELEMENT_TYPE_DIGITAL_COMMON_BUTTON: + ((DigitalCommonButton) element).setElementTextSizePercent(textSizePercent); + break; + case ELEMENT_TYPE_DIGITAL_SWITCH_BUTTON: + ((DigitalSwitchButton) element).setElementTextSizePercent(textSizePercent); + break; + case ELEMENT_TYPE_DIGITAL_COMBINE_BUTTON: + ((DigitalCombineButton) element).setElementTextSizePercent(textSizePercent); + break; + case ELEMENT_TYPE_DIGITAL_MOVABLE_BUTTON: + ((DigitalMovableButton) element).setElementTextSizePercent(textSizePercent); + break; + } + } + } + } + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_GROUP_BUTTON); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, "GROUP"); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "-1"); + contentValues.put(COLUMN_INT_CHILD_VISIBILITY, VISIBLE); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 100); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 0); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); + contentValues.put(COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + contentValues.put(COLUMN_INT_ELEMENT_FLAG1, 0); // hidden flag + + JsonObject extraAttrs = new JsonObject(); + extraAttrs.addProperty("movableInNormalMode", false); // Default value is false + extraAttrs.addProperty("userHasManuallySet", false); + contentValues.put(COLUMN_STRING_EXTRA_ATTRIBUTES, new Gson().toJson(extraAttrs)); + + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + // 步骤1: 调用递归的 set... 方法,更新内存中所有相关元素的状态 + colorUpdater.accept(newColor); + + // 步骤2: 调用一次统一的、递归的 save() 方法 + // 它会把当前按键以及所有子孙按键的最新状态全部保存到数据库 + save(); + + // 步骤3: 更新UI显示 + updateColorDisplay(colorDisplay, newColor); + } + ).show(); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleAnalogStick.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleAnalogStick.java new file mode 100644 index 0000000000..0afb80573f --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleAnalogStick.java @@ -0,0 +1,868 @@ +//隐藏式手柄摇杆 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class InvisibleAnalogStick extends Element { + + private static final String COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS = COLUMN_INT_ELEMENT_SENSE; + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface InvisibleAnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + private float circleCenterX = 0; + private float circleCenterY = 0; + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private InvisibleAnalogStick invisibleAnalogStick; + + private ElementController.SendEventHandler middleValueSendHandler; + private ElementController.SendEventHandler valueSendHandler; + private String middleValue; + private String value; + private int radius; + private int deadZoneRadius; //dead zone radius + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + private SuperPageLayout invisibleAnalogStickPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + private final Paint paintStick = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + private InvisibleAnalogStick.STICK_STATE stick_state = InvisibleAnalogStick.STICK_STATE.NO_MOVEMENT; + private InvisibleAnalogStick.CLICK_STATE click_state = InvisibleAnalogStick.CLICK_STATE.SINGLE; + + private InvisibleAnalogStickListener listener; + private List listeners = new ArrayList<>(); + private long timeLastClick = 0; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? 0 : Math.PI; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public InvisibleAnalogStick(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + // reset stick position + circleCenterX = getWidth() / 2; + circleCenterY = getHeight() / 2; + position_stick_x = circleCenterX; + position_stick_y = circleCenterY; + + + this.pageDeviceController = pageDeviceController; + this.invisibleAnalogStick = this; + + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels; + super.widthMin = 100; + super.heightMax = displayMetrics.heightPixels; + super.heightMin = 100; + + paintBackground.setStyle(Paint.Style.FILL); + paintStick.setStyle(Paint.Style.STROKE); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + deadZoneRadius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + value = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + middleValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_MIDDLE_VALUE); + valueSendHandler = controller.getSendEventHandler(value); + middleValueSendHandler = controller.getSendEventHandler(middleValue); + + listener = new InvisibleAnalogStickListener() { + @Override + public void onMovement(float x, float y) { + valueSendHandler.sendEvent((int) (x * 0x7FFE), (int) (y * 0x7FFE)); + } + + @Override + public void onClick() { + elementController.buttonVibrator(); + } + + @Override + public void onDoubleClick() { + middleValueSendHandler.sendEvent(true); + } + + @Override + public void onRevoke() { + middleValueSendHandler.sendEvent(false); + } + }; + + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + } + + private void notifyOnMovement(float x, float y) { + // notify listeners + listener.onMovement(x, y); + } + + private void notifyOnClick() { + // notify listeners + listener.onClick(); + } + + private void notifyOnDoubleClick() { + // notify listeners + listener.onDoubleClick(); + } + + private void notifyOnRevoke() { + // notify listeners + listener.onRevoke(); + } + + @Override + protected SuperPageLayout getInfoPage() { + if (invisibleAnalogStickPage == null) { + invisibleAnalogStickPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_invisible_analog_stick, null); + centralXNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_central_x); + centralYNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_central_y); + + } + + NumberSeekbar widthNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_width); + NumberSeekbar heightNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_height); + NumberSeekbar radiusNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_radius); + RadioGroup valueRadioGroup = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_value); + TextView middleValueTextView = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_middle_value); + NumberSeekbar senseNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_sense); + NumberSeekbar thickNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_thick); + NumberSeekbar layerNumberSeekbar = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_layer); + ElementEditText normalColorEditText = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_normal_color); + ElementEditText pressedColorEditText = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_pressed_color); + ElementEditText backgroundColorEditText = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_background_color); + Button copyButton = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_copy); + Button deleteButton = invisibleAnalogStickPage.findViewById(R.id.page_invisible_analog_stick_delete); + + + RadioButton radioButton = valueRadioGroup.findViewWithTag(value); + radioButton.setChecked(true); + valueRadioGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + setElementValue(group.findViewById(checkedId).getTag().toString()); + save(); + } + }); + + middleValueTextView.setText(pageDeviceController.getKeyNameByValue(middleValue)); + middleValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementMiddleValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + + senseNumberSeekbar.setValueWithNoCallBack(deadZoneRadius); + senseNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementDeadZoneRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setProgressMin(10); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColorEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_INVISIBLE_ANALOG_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(invisibleAnalogStickPage); + elementController.deleteElement(invisibleAnalogStick); + } + }); + + + return invisibleAnalogStickPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, value); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (invisibleAnalogStickPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + paintBackground.setColor(backgroundColor); + rect.top = 0; + rect.left = 0; + rect.right = getWidth(); + rect.bottom = getHeight(); + canvas.drawRect(rect, paintBackground); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == InvisibleAnalogStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paintStick); + + paintStick.setColor(normalColor); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paintStick); + } + + if (!isPressed()) { + return; + } + + + paintStick.setStyle(Paint.Style.STROKE); + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == InvisibleAnalogStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(circleCenterX, circleCenterY, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(circleCenterX, circleCenterY, radius_dead_zone, paintStick); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paintStick.setColor(normalColor); + canvas.drawCircle(circleCenterX, circleCenterY, radius_analog_stick, paintStick); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paintStick.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paintStick); + break; + } + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = circleCenterX - correlated_x; + position_stick_y = circleCenterY - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == InvisibleAnalogStick.STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + InvisibleAnalogStick.STICK_STATE.MOVED_ACTIVE : InvisibleAnalogStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == InvisibleAnalogStick.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + //点击后扩大view,防止摇杆显示不全 + circleCenterX = event.getX(); + circleCenterY = event.getY(); + invalidate(); + } + // save last click state + InvisibleAnalogStick.CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(circleCenterX - event.getX()); + relative_y = -(circleCenterY - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + + // set to dead zoned, will be corrected in update position if necessary + stick_state = InvisibleAnalogStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == InvisibleAnalogStick.CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = InvisibleAnalogStick.CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = InvisibleAnalogStick.CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = InvisibleAnalogStick.STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } + + public void setElementValue(String value) { + this.value = value; + valueSendHandler = elementController.getSendEventHandler(value); + } + + public void setElementMiddleValue(String middleValue) { + this.middleValue = middleValue; + middleValueSendHandler = elementController.getSendEventHandler(middleValue); + } + + public void setElementRadius(int radius) { + this.radius = radius; + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + invalidate(); + } + + public void setElementDeadZoneRadius(int deadZoneRadius) { + this.deadZoneRadius = deadZoneRadius; + radius_dead_zone = getPercent(radius, deadZoneRadius); + invalidate(); + } + + public void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + public void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + public void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + public void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_INVISIBLE_ANALOG_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "LS"); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, "g64"); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, 30); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 400); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 400); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 45); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 400); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 400); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 100); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleDigitalStick.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleDigitalStick.java new file mode 100644 index 0000000000..54276c04b0 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/InvisibleDigitalStick.java @@ -0,0 +1,988 @@ +//隐藏式按键摇杆 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.InputFilter; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.Map; + +public class InvisibleDigitalStick extends Element { + + private static final String COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS = COLUMN_INT_ELEMENT_SENSE; + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface InvisibleDigitalStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + private float circleCenterX = 0; + private float circleCenterY = 0; + + private SuperConfigDatabaseHelper superConfigDatabaseHelper; + private PageDeviceController pageDeviceController; + private InvisibleDigitalStick invisibleDigitalStick; + + private ElementController.SendEventHandler middleValueSendHandler; + private ElementController.SendEventHandler upValueSendHandler; + private ElementController.SendEventHandler downValueSendHandler; + private ElementController.SendEventHandler leftValueSendHandler; + private ElementController.SendEventHandler rightValueSendHandler; + private String middleValue; + private String upValue; + private String downValue; + private String leftValue; + private String rightValue; + private int radius; + private int deadZoneRadius; //dead zone radius + private int thick; + private int normalColor; + private int pressedColor; + private int backgroundColor; + + private SuperPageLayout invisibleDigitalStickPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + + private final Paint paintStick = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + private InvisibleDigitalStick.STICK_STATE stick_state = InvisibleDigitalStick.STICK_STATE.NO_MOVEMENT; + private InvisibleDigitalStick.CLICK_STATE click_state = InvisibleDigitalStick.CLICK_STATE.SINGLE; + + private InvisibleDigitalStickListener listener; + private long timeLastClick = 0; + private boolean upIsPressed = false; + private boolean downIsPressed = false; + private boolean leftIsPressed = false; + private boolean rightIsPressed = false; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? 0 : Math.PI; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public InvisibleDigitalStick(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + // reset stick position + circleCenterX = getWidth() / 2; + circleCenterY = getHeight() / 2; + position_stick_x = circleCenterX; + position_stick_y = circleCenterY; + + + this.pageDeviceController = pageDeviceController; + this.invisibleDigitalStick = this; + + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels; + super.widthMin = 100; + super.heightMax = displayMetrics.heightPixels; + super.heightMin = 100; + + paintBackground.setStyle(Paint.Style.FILL); + paintStick.setStyle(Paint.Style.STROKE); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + deadZoneRadius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS)).intValue(); + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + middleValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_MIDDLE_VALUE); + upValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_UP_VALUE); + downValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_DOWN_VALUE); + leftValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_LEFT_VALUE); + rightValue = (String) attributesMap.get(COLUMN_STRING_ELEMENT_RIGHT_VALUE); + middleValueSendHandler = controller.getSendEventHandler(middleValue); + upValueSendHandler = controller.getSendEventHandler(upValue); + downValueSendHandler = controller.getSendEventHandler(downValue); + leftValueSendHandler = controller.getSendEventHandler(leftValue); + rightValueSendHandler = controller.getSendEventHandler(rightValue); + + listener = new InvisibleDigitalStickListener() { + @Override + public void onMovement(float x, float y) { + if (x < -deadZoneRadius * 0.01 && !leftIsPressed) { + leftValueSendHandler.sendEvent(true); + leftIsPressed = true; + } else if (x > -deadZoneRadius * 0.01 && leftIsPressed) { + leftValueSendHandler.sendEvent(false); + leftIsPressed = false; + } + if (x > deadZoneRadius * 0.01 && !rightIsPressed) { + rightValueSendHandler.sendEvent(true); + rightIsPressed = true; + } else if (x < deadZoneRadius * 0.01 && rightIsPressed) { + rightValueSendHandler.sendEvent(false); + rightIsPressed = false; + } + if (y < -deadZoneRadius * 0.01 && !downIsPressed) { + downValueSendHandler.sendEvent(true); + downIsPressed = true; + } else if (y > -deadZoneRadius * 0.01 && downIsPressed) { + downValueSendHandler.sendEvent(false); + downIsPressed = false; + } + if (y > deadZoneRadius * 0.01 && !upIsPressed) { + upValueSendHandler.sendEvent(true); + upIsPressed = true; + } else if (y < deadZoneRadius * 0.01 && upIsPressed) { + upValueSendHandler.sendEvent(false); + upIsPressed = false; + } + } + + @Override + public void onClick() { + elementController.buttonVibrator(); + } + + @Override + public void onDoubleClick() { + middleValueSendHandler.sendEvent(true); + } + + @Override + public void onRevoke() { + middleValueSendHandler.sendEvent(false); + } + }; + + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + } + + private void notifyOnMovement(float x, float y) { + // notify listeners + listener.onMovement(x, y); + } + + private void notifyOnClick() { + // notify listeners + listener.onClick(); + } + + private void notifyOnDoubleClick() { + // notify listeners + listener.onDoubleClick(); + } + + private void notifyOnRevoke() { + // notify listeners + listener.onRevoke(); + } + + @Override + protected SuperPageLayout getInfoPage() { + if (invisibleDigitalStickPage == null) { + invisibleDigitalStickPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_invisible_digital_stick, null); + centralXNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_central_x); + centralYNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_central_y); + + } + + NumberSeekbar widthNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_width); + NumberSeekbar heightNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_height); + NumberSeekbar radiusNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_radius); + TextView middleValueTextView = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_middle_value); + TextView upValueTextView = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_up_value); + TextView downValueTextView = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_down_value); + TextView leftValueTextView = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_left_value); + TextView rightValueTextView = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_right_value); + NumberSeekbar senseNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_sense); + NumberSeekbar thickNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_thick); + NumberSeekbar layerNumberSeekbar = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_layer); + ElementEditText normalColorEditText = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_normal_color); + ElementEditText pressedColorEditText = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_pressed_color); + ElementEditText backgroundColorEditText = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_background_color); + Button copyButton = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_copy); + Button deleteButton = invisibleDigitalStickPage.findViewById(R.id.page_invisible_digital_stick_delete); + + + middleValueTextView.setText(pageDeviceController.getKeyNameByValue(middleValue)); + middleValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementMiddleValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + upValueTextView.setText(pageDeviceController.getKeyNameByValue(upValue)); + upValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementUpValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + downValueTextView.setText(pageDeviceController.getKeyNameByValue(downValue)); + downValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + // page页设置值文本 + ((TextView) v).setText(key.getText()); + setElementDownValue(key.getTag().toString()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + leftValueTextView.setText(pageDeviceController.getKeyNameByValue(leftValue)); + leftValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementLeftValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + rightValueTextView.setText(pageDeviceController.getKeyNameByValue(rightValue)); + rightValueTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + setElementRightValue(key.getTag().toString()); + // page页设置值文本 + ((TextView) v).setText(key.getText()); + save(); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + } + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + widthNumberSeekbar.setProgressMax(widthMax); + widthNumberSeekbar.setProgressMin(widthMin); + widthNumberSeekbar.setValueWithNoCallBack(getElementWidth()); + widthNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementWidth(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + + } + }); + + heightNumberSeekbar.setProgressMax(heightMax); + heightNumberSeekbar.setProgressMin(heightMin); + heightNumberSeekbar.setValueWithNoCallBack(getElementHeight()); + heightNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementHeight(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + save(); + } + }); + + + senseNumberSeekbar.setValueWithNoCallBack(deadZoneRadius); + senseNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementDeadZoneRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setProgressMin(10); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + thickNumberSeekbar.setValueWithNoCallBack(thick); + thickNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementThick(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColorEditText, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColorEditText, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColorEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + + copyButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.addElement(contentValues); + } + }); + + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + elementController.toggleInfoPage(invisibleDigitalStickPage); + elementController.deleteElement(invisibleDigitalStick); + } + }); + + + return invisibleDigitalStickPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, upValue); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, downValue); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, leftValue); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, rightValue); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, middleValue); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, deadZoneRadius); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + elementController.updateElement(elementId, contentValues); + + } + + @Override + protected void updatePage() { + if (invisibleDigitalStickPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + paintBackground.setColor(backgroundColor); + rect.top = 0; + rect.left = 0; + rect.right = getWidth(); + rect.bottom = getHeight(); + canvas.drawRect(rect, paintBackground); + + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + // 绘画范围 + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + // 边框 + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + + + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == InvisibleDigitalStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paintStick); + + paintStick.setColor(normalColor); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paintStick); + } + + if (!isPressed()) { + return; + } + + + paintStick.setStyle(Paint.Style.STROKE); + paintStick.setStrokeWidth(thick); + // draw outer circle + if (!isPressed() || click_state == InvisibleDigitalStick.CLICK_STATE.SINGLE) { + paintStick.setColor(normalColor); + } else { + paintStick.setColor(pressedColor); + } + canvas.drawCircle(circleCenterX, circleCenterY, radius_complete, paintStick); + + paintStick.setColor(normalColor); + // draw dead zone + canvas.drawCircle(circleCenterX, circleCenterY, radius_dead_zone, paintStick); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paintStick.setColor(normalColor); + canvas.drawCircle(circleCenterX, circleCenterY, radius_analog_stick, paintStick); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paintStick.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paintStick); + break; + } + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = circleCenterX - correlated_x; + position_stick_y = circleCenterY - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == InvisibleDigitalStick.STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + InvisibleDigitalStick.STICK_STATE.MOVED_ACTIVE : InvisibleDigitalStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == InvisibleDigitalStick.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + //点击后扩大view,防止摇杆显示不全 + circleCenterX = event.getX(); + circleCenterY = event.getY(); + invalidate(); + } + // save last click state + InvisibleDigitalStick.CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(circleCenterX - event.getX()); + relative_y = -(circleCenterY - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + + // set to dead zoned, will be corrected in update position if necessary + stick_state = InvisibleDigitalStick.STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == InvisibleDigitalStick.CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = InvisibleDigitalStick.CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = InvisibleDigitalStick.CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = InvisibleDigitalStick.STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } + + public void setElementMiddleValue(String middleValue) { + this.middleValue = middleValue; + middleValueSendHandler = elementController.getSendEventHandler(middleValue); + } + + public void setElementUpValue(String upValue) { + this.upValue = upValue; + upValueSendHandler = elementController.getSendEventHandler(upValue); + } + + public void setElementDownValue(String downValue) { + this.downValue = downValue; + downValueSendHandler = elementController.getSendEventHandler(downValue); + } + + public void setElementLeftValue(String leftValue) { + this.leftValue = leftValue; + leftValueSendHandler = elementController.getSendEventHandler(leftValue); + } + + public void setElementRightValue(String rightValue) { + this.rightValue = rightValue; + rightValueSendHandler = elementController.getSendEventHandler(rightValue); + } + + public void setElementRadius(int radius) { + this.radius = radius; + radius_complete = getPercent(radius, 100) - 2 * thick; + radius_dead_zone = getPercent(radius, deadZoneRadius); + radius_analog_stick = getPercent(radius, 20); + invalidate(); + } + + public void setElementDeadZoneRadius(int deadZoneRadius) { + this.deadZoneRadius = deadZoneRadius; + radius_dead_zone = getPercent(radius, deadZoneRadius); + invalidate(); + } + + public void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + public void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + public void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + public void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_INVISIBLE_DIGITAL_STICK); + contentValues.put(COLUMN_STRING_ELEMENT_UP_VALUE, "k51"); + contentValues.put(COLUMN_STRING_ELEMENT_DOWN_VALUE, "k47"); + contentValues.put(COLUMN_STRING_ELEMENT_LEFT_VALUE, "k29"); + contentValues.put(COLUMN_STRING_ELEMENT_RIGHT_VALUE, "k32"); + contentValues.put(COLUMN_STRING_ELEMENT_MIDDLE_VALUE, "k59"); + contentValues.put(COLUMN_INT_ELEMENT_DEAD_ZONE_RADIUS, 30); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 400); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 400); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 45); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 400); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 400); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 100); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0x00FFFFFF); + return contentValues; + + + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); // <-- 主要变化:在最后调用 .show() + }); + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/SimplifyPerformance.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/SimplifyPerformance.java new file mode 100644 index 0000000000..3aea598c15 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/SimplifyPerformance.java @@ -0,0 +1,593 @@ +//串流信息 +package com.limelight.binding.input.advance_setting.element; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.Button; +import android.widget.EditText; +import android.widget.SeekBar; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SimplifyPerformance extends Element { + //总延迟 ≈ (网络延迟 + 排队延迟) + 解码延迟 + 渲染延迟 + private static final String SIMPLIFY_PERFORMANCE_TEXT_DEFAULT = " 带宽: ##带宽## 主机/网络/解码: ##主机延时## / ##网络延时## / ##解码时间## 帧率: ##帧率## 丢帧: ##丢帧率## 渲染:##渲染延迟## 时间:HH:MM:SS"; + private static final String COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_SIZE = COLUMN_INT_ELEMENT_THICK; + private static final String COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_COLOR = COLUMN_INT_ELEMENT_NORMAL_COLOR; + private static final String COLUMN_INT_SIMPLIFY_PERFORMANCE_PRE_PARSE_TEXT = COLUMN_STRING_ELEMENT_TEXT; + + private final SimplifyPerformance simplifyPerformance; + private final Pattern pattern = Pattern.compile("##(.*?)##"); + private final DisplayMetrics displayMetrics = new DisplayMetrics(); + + private String afterParseText = "null"; + + private String preParseText; + private int radius; + private int verticalPadding = 10; // 给一个默认的垂直内边距,使文本看起来更舒适 + private int horizontalPadding = 10; // 增加一个水平内边距 + private int textColor; + private int textSize; + private int layer; + private int backgroundColor; + + // 添加时钟相关字段 + private boolean showClock = false; + private SimpleDateFormat hourFormat = new SimpleDateFormat("HH", Locale.getDefault()); + private SimpleDateFormat minuteFormat = new SimpleDateFormat("mm", Locale.getDefault()); + private SimpleDateFormat secondFormat = new SimpleDateFormat("ss", Locale.getDefault()); + + private SuperPageLayout simplifyPerformancePage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + private NumberSeekbar radiusNumberSeekbar; + + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final RectF rect = new RectF(); + + // --- 自驱动心跳机制 --- + private final Handler heartbeatHandler = new Handler(Looper.getMainLooper()); + private Runnable heartbeatRunnable; + private boolean isHeartbeatActive = false; + private static final long HEARTBEAT_INTERVAL_MS = 1000; // 1秒心跳间隔 + private final Game.PerformanceInfoDisplay performanceInfoDisplayListener; + + public SimplifyPerformance(Map attributesMap, + ElementController controller, + Context context) { + super(attributesMap, controller, context); + this.simplifyPerformance = this; + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = 100000; + super.widthMin = 0; + super.heightMax = 100000; + super.heightMin = 0; + + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintText.setTextAlign(Paint.Align.LEFT); + paintBackground.setStyle(Paint.Style.FILL); + + + preParseText = (String) attributesMap.get(COLUMN_INT_SIMPLIFY_PERFORMANCE_PRE_PARSE_TEXT); + radius = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_RADIUS)).intValue(); + layer = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_LAYER)).intValue(); + textSize = ((Long) attributesMap.get(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_SIZE)).intValue(); + textColor = ((Long) attributesMap.get(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + + // 检查是否包含时钟占位符 + checkForClockPlaceholder(); + + // 1. 创建监听器并持有其引用 + performanceInfoDisplayListener = performanceAttrs -> { + // 先复制原始文本 + String tempText = preParseText; + + // 处理性能数据占位符 + Matcher matcher = pattern.matcher(tempText); + StringBuffer sb = new StringBuffer(); + try { + while (matcher.find()) { + String key = matcher.group(1); + String replacement = performanceAttrs.getOrDefault(key, "N/A"); + matcher.appendReplacement(sb, replacement); + } + matcher.appendTail(sb); + tempText = sb.toString(); + } catch (Exception e) { + // 如果处理出错,保留原始文本 + tempText = preParseText; + } + + // 处理时钟占位符 + if (showClock) { + Calendar calendar = Calendar.getInstance(); + String hour = hourFormat.format(calendar.getTime()); + String minute = minuteFormat.format(calendar.getTime()); + String second = secondFormat.format(calendar.getTime()); + + tempText = tempText.replace("HH", hour); + tempText = tempText.replace("MM", minute); + tempText = tempText.replace("SS", second); + } + + afterParseText = tempText; + + // 数据更新后,立即重算尺寸 + changeSize(); + }; + ((Game) context).addPerformanceInfoDisplay(performanceInfoDisplayListener); + + // 2. 初始化并启动心跳 + initializeHeartbeat(); + startHeartbeat(); + } + + // --- 心跳和生命周期管理 --- + + private void initializeHeartbeat() { + heartbeatRunnable = () -> { + if (isHeartbeatActive) {// 如果显示时钟,则每秒更新一次 + if (showClock) { + // 更新时间显示 + if (!"null".equals(afterParseText) && !afterParseText.isEmpty()) { + Calendar calendar = Calendar.getInstance(); + String hour = hourFormat.format(calendar.getTime()); + String minute = minuteFormat.format(calendar.getTime()); + String second = secondFormat.format(calendar.getTime()); + + // 先复制当前文本 + String tempText = afterParseText; + + // 更新时间占位符 + tempText = tempText.replace("HH", hour); + tempText = tempText.replace("MM", minute); + tempText = tempText.replace("SS", second); + + afterParseText = tempText; + } + invalidate(); + } else { + invalidate(); // 周期性地请求重绘 + } + heartbeatHandler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL_MS); + } + }; + } + + private void startHeartbeat() { + if (!isHeartbeatActive) { + isHeartbeatActive = true; + heartbeatHandler.post(heartbeatRunnable); + } + } + + private void stopHeartbeat() { + isHeartbeatActive = false; + heartbeatHandler.removeCallbacks(heartbeatRunnable); + } + + /** + * 元素销毁时调用的清理方法 + */ + public void destroy() { + stopHeartbeat(); + if (getContext() instanceof Game && performanceInfoDisplayListener != null) { + ((Game) getContext()).removePerformanceInfoDisplay(performanceInfoDisplayListener); + } + } + + // --- 尺寸和绘制 --- + + private void changeSize() { + paintText.setTextSize(textSize); + + // 按换行符分割文本 + String[] lines = afterParseText.split("\n"); + if (lines.length == 0) { // 处理空文本的情况 + setElementWidth(horizontalPadding * 2); + setElementHeight(verticalPadding * 2); + return; + } + + // 计算最长一行的宽度 + float maxWidth = 0; + for (String line : lines) { + float lineWidth = paintText.measureText(line); + if (lineWidth > maxWidth) { + maxWidth = lineWidth; + } + } + + // 计算总高度 + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float singleLineHeight = fontMetrics.bottom - fontMetrics.top; + int totalTextHeight = (int) (singleLineHeight * lines.length); + + int width = (int) maxWidth + horizontalPadding * 2; + int height = totalTextHeight + verticalPadding * 2; + setElementWidth(width); + setElementHeight(height); + + // 更新半径滑块的最大值 + if (radiusNumberSeekbar != null) { + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + } + invalidate(); + } + + + @Override + protected void onElementDraw(Canvas canvas) { + // 设置画笔颜色 + paintText.setColor(textColor); + paintBackground.setColor(backgroundColor); + + // 定义背景绘制范围 + rect.left = rect.top = 0; + rect.right = getElementWidth(); + rect.bottom = getElementHeight(); + + // 绘制圆角背景 + canvas.drawRoundRect(rect, radius, radius, paintBackground); + + // [修改] 逐行绘制文本以支持换行 + String[] lines = afterParseText.split("\n"); + Paint.FontMetrics fontMetrics = paintText.getFontMetrics(); + float lineHeight = fontMetrics.bottom - fontMetrics.top; + + // 计算第一行文本的基线 (baseline) Y坐标 + // y坐标在 drawText 中是基线的位置,而不是文本的顶部。 + // -fontMetrics.top 可以得到从顶部到基线的距离 + float startY = verticalPadding - fontMetrics.top; + + for (int i = 0; i < lines.length; i++) { + // 计算当前行的Y坐标 + float currentY = startY + (i * lineHeight); + // 从水平内边距开始绘制 + canvas.drawText(lines[i], horizontalPadding, currentY, paintText); + } + + // 绘制编辑模式下的边框 + ElementController.Mode mode = elementController.getMode(); + if (mode == ElementController.Mode.Edit || mode == ElementController.Mode.Select) { + rect.left = rect.top = 2; + rect.right = getElementWidth() - 2; + rect.bottom = getElementHeight() - 2; + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + } + } + + // --- UI 和页面逻辑 --- + + @Override + protected SuperPageLayout getInfoPage() { + if (simplifyPerformancePage == null) { + simplifyPerformancePage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_simplify_performance, null); + centralXNumberSeekbar = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_central_x); + centralYNumberSeekbar = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_central_y); + + } + + + radiusNumberSeekbar = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_radius); + EditText textEditText = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_text); + Button textEnsureButton = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_text_ensure); + Button textResetButton = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_text_reset); + NumberSeekbar textSizeNumberSeekbar = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_text_size); + NumberSeekbar layerNumberSeekbar = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_layer); + ElementEditText textColorElementEditText = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_text_color); + ElementEditText backgroundColorElementEditText = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_background_color); + Button copyButton = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_copy); + Button deleteButton = simplifyPerformancePage.findViewById(R.id.page_simplify_performance_delete); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralX(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementCentralY(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + radiusNumberSeekbar.setProgressMax(Math.min(getElementWidth(), getElementHeight()) / 2); + radiusNumberSeekbar.setValueWithNoCallBack(radius); + radiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementRadius(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // 使 EditText 支持多行输入 + textEditText.setSingleLine(false); + textEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + textEditText.setGravity(Gravity.TOP | Gravity.START); + textEditText.setText(preParseText.replace("\\n", "\n")); + textEnsureButton.setOnClickListener(v -> { + setElementPreParseText(textEditText.getText().toString()); + save(); + }); + + textResetButton.setOnClickListener(v -> { + setElementPreParseText(SIMPLIFY_PERFORMANCE_TEXT_DEFAULT); + textEditText.setText(SIMPLIFY_PERFORMANCE_TEXT_DEFAULT); + save(); + }); + + textSizeNumberSeekbar.setProgressMin(10); + textSizeNumberSeekbar.setProgressMax(50); + textSizeNumberSeekbar.setValueWithNoCallBack(textSize); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setElementTextSize(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + layerNumberSeekbar.setValueWithNoCallBack(layer); + layerNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + setElementLayer(seekBar.getProgress()); + save(); + } + }); + + setupColorPickerButton(textColorElementEditText, () -> this.textColor, this::setElementTextColor); + setupColorPickerButton(backgroundColorElementEditText, () -> this.backgroundColor, this::setElementBackgroundColor); + + copyButton.setOnClickListener(v -> { + ContentValues contentValues = getSaveContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth(), centralXMax), centralXMin)); + elementController.addElement(contentValues); + }); + + deleteButton.setOnClickListener(v -> { + destroy(); // 在删除前清理资源 + elementController.toggleInfoPage(simplifyPerformancePage); + elementController.deleteElement(simplifyPerformance); + }); + + return simplifyPerformancePage; + } + + // --- 数据持久化和状态更新 --- + + private ContentValues getSaveContentValues() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_SIMPLIFY_PERFORMANCE); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_PRE_PARSE_TEXT, preParseText); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, getElementWidth()); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, getElementHeight()); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, radius); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_SIZE, textSize); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_COLOR, textColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + return contentValues; + } + + @Override + public void save() { + elementController.updateElement(elementId, getSaveContentValues()); + } + + @Override + protected void updatePage() { + if (simplifyPerformancePage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + } + + // --- Setters --- + + public void setElementPreParseText(String preParseText) { + this.preParseText = preParseText.isEmpty() ? " " : preParseText; + checkForClockPlaceholder(); // 检查是否包含时钟占位符 + } + + public void setElementRadius(int radius) { + this.radius = radius; + invalidate(); + } + + + + public void setElementTextColor(int textColor) { + this.textColor = textColor; + invalidate(); + } + + public void setElementTextSize(int textSize) { + this.textSize = textSize; + changeSize(); + } + + public void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + // --- 其他 --- + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + return false; + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_SIMPLIFY_PERFORMANCE); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_PRE_PARSE_TEXT, SIMPLIFY_PERFORMANCE_TEXT_DEFAULT); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 100); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 20); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 50); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 100); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 100); + contentValues.put(COLUMN_INT_ELEMENT_RADIUS, 19); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_SIZE, 30); + contentValues.put(COLUMN_INT_SIMPLIFY_PERFORMANCE_TEXT_COLOR, 0xB3FFFFFF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0xF0555555); + return contentValues; + + + } + + // 添加检查时钟占位符的方法 + private void checkForClockPlaceholder() { + showClock = preParseText.contains("HH") || preParseText.contains("MM") || preParseText.contains("SS"); + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + /** + * 更新颜色显示按钮的外观(文本、背景色、文本颜色)。 + */ + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + // 显示十六进制颜色码 + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + // 将背景设置为当前颜色 + colorDisplay.setBackgroundColor(color); + + // 根据背景色的亮度自动设置文本颜色为黑色或白色,以确保可读性 + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + /** + * 配置一个 ElementEditText 控件,使其作为颜色选择器按钮使用。 + * + * @param colorDisplay 用于作为按钮的 ElementEditText 视图。 + * @param initialColorFetcher 一个用于获取当前颜色值的 Lambda 表达式。 + * @param colorUpdater 一个用于设置新颜色值的 Lambda 表达式。 + */ + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + // 禁输入,让 EditText 表现得像一个按钮 + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + + // 使用传入的 Lambda 获取初始颜色并设置外观 + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + + // 设置点击监听器,打开颜色选择器 + colorDisplay.setOnClickListener(v -> { + // 再次获取当前颜色,确保打开时颜色是最新的 + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, // true 表示显示 Alpha 透明度滑块 + newColor -> { + colorUpdater.accept(newColor); // 使用传入的 Lambda 更新颜色属性 + save(); // 保存更改 + updateColorDisplay(colorDisplay, newColor); // 更新UI显示 + } + ).show(); + }); + } +} + + diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/element/WheelPad.java b/app/src/main/java/com/limelight/binding/input/advance_setting/element/WheelPad.java new file mode 100644 index 0000000000..fb37bf1841 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/element/WheelPad.java @@ -0,0 +1,1528 @@ +//轮盘按键 +package com.limelight.binding.input.advance_setting.element; + +import android.app.AlertDialog; +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.widget.SwitchCompat; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.advance_setting.PageDeviceController; +import com.limelight.binding.input.advance_setting.superpage.ElementEditText; +import com.limelight.binding.input.advance_setting.superpage.NumberSeekbar; +import com.limelight.binding.input.advance_setting.superpage.SuperPageLayout; +import com.limelight.utils.ColorPickerDialog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class WheelPad extends Element { + + private final PageDeviceController pageDeviceController; + private final WheelPad wheelPad; + + private int diameter; + private int segmentCount; + private int innerRadiusPercent; + private boolean isPopupMode; + private String centerText; + private List segmentValues; + private List segmentNames; + private int normalColor; + private int pressedColor; + private int backgroundColor; + private int thick; + private int normalTextColor; + private int pressedTextColor; + private int centerTextColor; // 中心文字颜色 + private int textSizePercent; // 分区文字大小百分比 + private int centerTextSizePercent; // 选择预览文字大小百分比 + + private int triggerTextSizePercent; // 触发器文字大小百分比 + + + private final int screenWidth; + private final int screenHeight; + + private final Paint paintBorder = new Paint(); + private final Paint paintSegmentFill = new Paint(); + private final Paint paintSegmentFillPressed = new Paint(); + private final Paint paintBackground = new Paint(); + private final Paint paintText = new Paint(); + private final Paint paintEdit = new Paint(); + private final Paint paintCenterText = new Paint(); + private final Paint paintGlow = new Paint(); // 用于激活分区的发光效果 + private final RectF rect = new RectF(); + private final Path textPath = new Path(); + + private int activeIndex = -1; + private int lastActiveIndex = -1; + private boolean isWheelActive = false; + private boolean popupAtScreenCenter; + private boolean previewGroupChildren; + // 用于追踪当前悬停的组按键,以实现子按键预览 + private GroupButton hoveredGroupButton = null; + + private SuperPageLayout wheelPadPage; + private NumberSeekbar centralXNumberSeekbar; + private NumberSeekbar centralYNumberSeekbar; + private LinearLayout valuesContainer; + + public WheelPad(Map attributesMap, + ElementController controller, + PageDeviceController pageDeviceController, Context context) { + super(attributesMap, controller, context); + this.pageDeviceController = pageDeviceController; + this.wheelPad = this; + + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Game) context).getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics); + this.screenWidth = displayMetrics.widthPixels; + this.screenHeight = displayMetrics.heightPixels; + + super.centralXMax = displayMetrics.widthPixels; + super.centralXMin = 0; + super.centralYMax = displayMetrics.heightPixels; + super.centralYMin = 0; + super.widthMax = displayMetrics.widthPixels; + super.widthMin = 150; + super.heightMax = displayMetrics.heightPixels; + super.heightMin = 150; + + paintBorder.setStyle(Paint.Style.STROKE); + paintBorder.setAntiAlias(true); + paintSegmentFill.setStyle(Paint.Style.FILL); + paintSegmentFill.setAntiAlias(true); + paintSegmentFillPressed.setStyle(Paint.Style.FILL); + paintSegmentFillPressed.setAntiAlias(true); + paintBackground.setStyle(Paint.Style.FILL); + paintBackground.setAntiAlias(true); + paintText.setColor(0xFFFFFFFF); + paintText.setTextSize(30); + paintText.setTextAlign(Paint.Align.CENTER); + paintText.setAntiAlias(true); + paintEdit.setStyle(Paint.Style.STROKE); + paintEdit.setStrokeWidth(4); + paintEdit.setPathEffect(new DashPathEffect(new float[]{10, 20}, 0)); + paintCenterText.setColor(0xFFFFFFFF); + paintCenterText.setTextSize(60); + paintCenterText.setTextAlign(Paint.Align.CENTER); + paintCenterText.setAntiAlias(true); + paintCenterText.setFakeBoldText(true); + + // 初始化发光效果画笔 + paintGlow.setStyle(Paint.Style.FILL); + paintGlow.setAntiAlias(true); + + this.diameter = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_WIDTH)).intValue(); + + Object textObj = attributesMap.get(COLUMN_STRING_ELEMENT_TEXT); + this.centerText = (textObj != null) ? (String) textObj : ""; + this.isPopupMode = !this.centerText.isEmpty(); + + thick = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_THICK)).intValue(); + normalColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_NORMAL_COLOR)).intValue(); + pressedColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_PRESSED_COLOR)).intValue(); + backgroundColor = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_BACKGROUND_COLOR)).intValue(); + segmentCount = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_MODE)).intValue(); + innerRadiusPercent = ((Long) attributesMap.get(COLUMN_INT_ELEMENT_SENSE)).intValue(); + + // Load the popup behavior flag. Default to true (new behavior) for old configs. + Object popupFlagObj = attributesMap.get(COLUMN_INT_ELEMENT_FLAG1); + this.popupAtScreenCenter = (popupFlagObj == null) || ((Long) popupFlagObj).intValue() == 1; + + if (attributesMap.containsKey("extra_attributes")) { + String extraAttrsJsonString = (String) attributesMap.get("extra_attributes"); + if (extraAttrsJsonString != null && !extraAttrsJsonString.isEmpty()) { + try { + JsonObject extraAttrs = new Gson().fromJson(extraAttrsJsonString, JsonObject.class); + + // 从 extraAttrs 中安全地读取值 + if (extraAttrs.has("normalTextColor")) { + this.normalTextColor = extraAttrs.get("normalTextColor").getAsInt(); + } else { + this.normalTextColor = 0xFFFFFFFF; + } + + if (extraAttrs.has("centerTextColor")) { + this.centerTextColor = extraAttrs.get("centerTextColor").getAsInt(); + } else { + this.centerTextColor = 0xFFFFFFFF; + } + + if (extraAttrs.has("pressedTextColor")) { + this.pressedTextColor = extraAttrs.get("pressedTextColor").getAsInt(); + } else { + this.pressedTextColor = 0xFFFFFFFF; + } + + if (extraAttrs.has("textSizePercent")) { + this.textSizePercent = extraAttrs.get("textSizePercent").getAsInt(); + } else { + this.textSizePercent = 35; + } + + if (extraAttrs.has("centerTextSizePercent")) { + this.centerTextSizePercent = extraAttrs.get("centerTextSizePercent").getAsInt(); + } else { + this.centerTextSizePercent = 60; + } + if (extraAttrs.has("triggerTextSizePercent")) { + this.triggerTextSizePercent = extraAttrs.get("triggerTextSizePercent").getAsInt(); + } else { + // 提供一个合理的默认值,通常应该比中心预览文字小 + this.triggerTextSizePercent = 40; + } + if (extraAttrs.has("previewGroupChildren")) { + this.previewGroupChildren = extraAttrs.get("previewGroupChildren").getAsBoolean(); + } else { + this.previewGroupChildren = true; // 默认为开启 + } + + } catch (Exception e) { + // JSON 解析失败,使用默认值 + initializeDefaultFontAttributes(); + } + } else { + // 字段存在但为空,使用默认值 + initializeDefaultFontAttributes(); + } + } else { + // 字段不存在(旧数据),使用默认值 + initializeDefaultFontAttributes(); + } + + String valuesString = (String) attributesMap.get(COLUMN_STRING_ELEMENT_VALUE); + segmentValues = new ArrayList<>(); + segmentNames = new ArrayList<>(); + String[] segments = valuesString.split(","); + for (String segment : segments) { + if (segment.isEmpty()) continue; + String[] parts = segment.split("\\|", 2); + segmentValues.add(parts[0]); + if (parts.length > 1) { + segmentNames.add(parts[1]); + } else { + segmentNames.add(""); + } + } + } + + private void initializeDefaultFontAttributes() { + this.normalTextColor = 0xFFFFFFFF; + this.pressedTextColor = 0xFFFFFFFF; + this.centerTextColor = 0xFFFFFFFF; + this.textSizePercent = 35; + this.centerTextSizePercent = 60; + this.triggerTextSizePercent = 40; + this.previewGroupChildren = true; + } + + public boolean isBeingEdited() { + if (elementController != null) { + SuperPageLayout currentPage = elementController.getCurrentEditingPage(); + return this.wheelPadPage != null && currentPage == this.wheelPadPage; + } + return false; + } + + private List getHandlersForValueNow(String value) { + List handlers = new ArrayList<>(); + if (value == null || value.isEmpty() || value.equals("null")) { + return handlers; + } + + String[] keyValues = value.split("\\+"); + for (String singleKeyValue : keyValues) { + ElementController.SendEventHandler handler = elementController.getSendEventHandler(singleKeyValue.trim()); + if (handler != null) { + handlers.add(handler); + } + } + return handlers; + } + + @Override + protected void onElementDraw(Canvas canvas) { + ElementController.Mode currentMode = elementController.getMode(); + boolean isTheOneBeingEdited = isBeingEdited(); + // 屏幕中心预览 + if (isPopupMode && popupAtScreenCenter) { + drawInactivePopupCenter(canvas); + boolean shouldDrawCentralPreview = + (currentMode == ElementController.Mode.Normal && isWheelActive) || + (currentMode == ElementController.Mode.Edit && isTheOneBeingEdited); + + if (shouldDrawCentralPreview) { + canvas.save(); + float translateX = (screenWidth / 2.0f) - getElementCentralX(); + float translateY = (screenHeight / 2.0f) - getElementCentralY(); + canvas.translate(translateX, translateY); + drawFullWheel(canvas); + + // 在绘制完轮盘后,如果悬停在组按键上,则绘制其子按键预览 + // 如果开启了预览功能,则绘制子按键 + if (previewGroupChildren) { + drawHoveredGroupButtonChildren(canvas, translateX, translateY); + } + + canvas.restore(); + } + + } else { + boolean shouldDrawTriggerInsteadOfFullWheel = isPopupMode && !popupAtScreenCenter && + currentMode == ElementController.Mode.Normal && !isWheelActive; + if (shouldDrawTriggerInsteadOfFullWheel) { + drawInactivePopupCenter(canvas); + } else { + // 原地预览 + drawFullWheel(canvas); + // 检查是否为激活的 "原地弹出模式",如果是,则添加组按键预览 + boolean isActiveOnSitePopup = isPopupMode && !popupAtScreenCenter && + currentMode == ElementController.Mode.Normal && isWheelActive; + + if (isActiveOnSitePopup) { + // 因为是原地绘制,没有对画布进行平移,所以平移量为0 + // 如果开启了预览功能,并且是激活的原地弹出模式,则绘制 + if (previewGroupChildren && isActiveOnSitePopup) { + drawHoveredGroupButtonChildren(canvas, 0, 0); + } + } + } + } + + if (currentMode == ElementController.Mode.Edit || currentMode == ElementController.Mode.Select) { + rect.left = rect.top = 2; + rect.right = getWidth() - 2; + rect.bottom = getHeight() - 2; + paintEdit.setColor(editColor); + canvas.drawRect(rect, paintEdit); + } + } + + /** + * 在轮盘预览模式下,绘制当前悬停的组按键的子按键。 + * @param canvas 画布。对于屏幕中心模式,其坐标系已平移;对于原地模式,坐标系为WheelPad的视图坐标系。 + * @param wheelTranslateX 轮盘绘制时在X轴上的平移量 (原地弹出时为0)。 + * @param wheelTranslateY 轮盘绘制时在Y轴上的平移量 (原地弹出时为0)。 + */ + private void drawHoveredGroupButtonChildren(Canvas canvas, float wheelTranslateX, float wheelTranslateY) { + if (hoveredGroupButton == null) { + return; + } + + List childIds = hoveredGroupButton.getChildIds(); + if (childIds == null || childIds.isEmpty()) { + return; + } + + for (Long childId : childIds) { + Element child = elementController.findElementById(childId); + if (child != null) { + canvas.save(); + + // 计算子元素相对于当前画布坐标系原点的位置。 + // 1. child.getLeft() 是子元素在屏幕上的绝对X坐标。 + // 2. this.getLeft() 是WheelPad视图在屏幕上的绝对X坐标。 + // 3. wheelTranslateX 是为了将轮盘居中而额外平移的量。 + // 最终,(this.getLeft() + wheelTranslateX) 就是当前画布原点在屏幕上的绝对X坐标。 + // 两者相减,得到子元素应该在当前画布的哪个相对位置绘制。 + float childDrawX = child.getLeft() - (this.getLeft() + wheelTranslateX); + float childDrawY = child.getTop() - (this.getTop() + wheelTranslateY); + + // 将画布平移到子元素应该被绘制的相对位置。 + canvas.translate(childDrawX, childDrawY); + + // 调用子元素的绘制方法。 + // onElementDraw 期望画布原点已经位于元素的左上角,我们刚刚通过translate实现了这一点。 + child.onElementDraw(canvas); + + canvas.restore(); + } + } + } + + private void drawInactivePopupCenter(Canvas canvas) { + float centerX = getWidth() / 2.0f; + float centerY = getHeight() / 2.0f; + float fillOuterRadius = (getWidth() / 2.0f) - thick; + if (fillOuterRadius < 0) fillOuterRadius = 0; + float fillInnerRadius = fillOuterRadius * (innerRadiusPercent / 100.0f); + float borderInnerDrawRadius = fillInnerRadius + (thick / 2.0f); + + paintBorder.setColor(normalColor); + paintBorder.setStrokeWidth(thick); + // 柔和内圈高亮轮廓 + paintBorder.setShadowLayer(thick * 0.6f, 0, 0, (normalColor & 0x00FFFFFF) | 0x33000000); + + paintSegmentFill.setColor(0x80000000); + canvas.drawCircle(centerX, centerY, fillInnerRadius, paintSegmentFill); + + rect.set(centerX - borderInnerDrawRadius, centerY - borderInnerDrawRadius, centerX + borderInnerDrawRadius, centerY + borderInnerDrawRadius); + canvas.drawOval(rect, paintBorder); + // 清除阴影以免影响后续绘制 + paintBorder.setShadowLayer(0, 0, 0, 0); + + // 应用中心文字的颜色和大小 --- + paintCenterText.setColor(this.centerTextColor); + // 触发器文字大小改为基于内圈直径计算,随中心分区大小联动 + float triggerTextSize = (fillInnerRadius * 2) * (this.triggerTextSizePercent / 100.0f); + paintCenterText.setTextSize(triggerTextSize); + paintCenterText.setShadowLayer(Math.max(2, thick * 0.3f), 0, Math.max(1, thick * 0.2f), 0x88000000); + + float textY = centerY - ((paintCenterText.descent() + paintCenterText.ascent()) / 2); + canvas.drawText(centerText, centerX, textY, paintCenterText); + } + + private void drawFullWheel(Canvas canvas) { + ElementController.Mode currentMode = elementController.getMode(); + float centerX = getWidth() / 2.0f; + float centerY = getHeight() / 2.0f; + + float fillOuterRadius = (getWidth() / 2.0f) - thick; + if (fillOuterRadius < 0) fillOuterRadius = 0; + float fillInnerRadius = fillOuterRadius * (innerRadiusPercent / 100.0f); + float borderOuterDrawRadius = (getWidth() / 2.0f) - (thick / 2.0f); + if (borderOuterDrawRadius < 0) borderOuterDrawRadius = 0; + float borderInnerDrawRadius = fillInnerRadius + (thick / 2.0f); + + paintBorder.setColor(normalColor); + paintBorder.setStrokeWidth(thick); + paintBackground.setColor(0xFF000000); + + paintSegmentFill.setColor(backgroundColor); + paintSegmentFillPressed.setColor(pressedColor); + + paintText.setColor(this.normalTextColor); + // 字体大小基于内外半径的差值(环带厚度)来计算,这样更直观 + float ringThickness = fillOuterRadius - fillInnerRadius; + float segmentTextSize = ringThickness * (this.textSizePercent / 100.0f); + paintText.setTextSize(segmentTextSize); + + float sweepAngle = 360.0f / segmentCount; + + rect.set(centerX - fillOuterRadius, centerY - fillOuterRadius, centerX + fillOuterRadius, centerY + fillOuterRadius); + for (int i = 0; i < segmentCount; i++) { + float startAngle = (i * sweepAngle) - (sweepAngle / 2) - 90; + Paint currentFillPaint = (i == activeIndex) ? paintSegmentFillPressed : paintSegmentFill; + canvas.drawArc(rect, startAngle, sweepAngle, true, currentFillPaint); + + // 为激活分区添加发光效果 + if (i == activeIndex) { + paintGlow.setColor(Color.argb(60, Color.red(pressedColor), Color.green(pressedColor), Color.blue(pressedColor))); + paintGlow.setShadowLayer(fillOuterRadius * 0.15f, 0, 0, pressedColor); + canvas.drawArc(rect, startAngle, sweepAngle, true, paintGlow); + paintGlow.setShadowLayer(0, 0, 0, 0); // 清除阴影 + } + } + + canvas.drawCircle(centerX, centerY, fillInnerRadius, paintBackground); + + float textRadius = fillInnerRadius + (fillOuterRadius - fillInnerRadius) / 2.0f; + for (int i = 0; i < segmentCount; i++) { + float startAngle = (i * sweepAngle) - (sweepAngle / 2) - 90; + float textAngle = startAngle + sweepAngle / 2.0f; // 分区中心角度(以上方为-90度起点) + + if (i < segmentValues.size()) { + String displayName; + if (i < segmentNames.size() && segmentNames.get(i) != null && !segmentNames.get(i).isEmpty()) { + displayName = segmentNames.get(i); + } else { + displayName = getDisplayStringForValue(segmentValues.get(i)); + } + + // 根据激活状态切换文字颜色与粗体 + if (i == activeIndex) { + paintText.setColor(this.pressedTextColor); + paintText.setFakeBoldText(true); + } else { + paintText.setColor(this.normalTextColor); + paintText.setFakeBoldText(false); + } + + // 计算文字中心点坐标(保持文字水平,不旋转) + double textRad = Math.toRadians(textAngle); + float textCenterX = centerX + (float) (textRadius * Math.cos(textRad)); + float textCenterY = centerY + (float) (textRadius * Math.sin(textRad)); + + // 将中心点转换为基线位置,使用字体度量保证垂直居中 + Paint.FontMetrics fm = paintText.getFontMetrics(); + float baselineY = textCenterY - (fm.ascent + fm.descent) / 2.0f; + + canvas.drawText(displayName, textCenterX, baselineY, paintText); + } + } + + rect.set(centerX - borderOuterDrawRadius, centerY - borderOuterDrawRadius, centerX + borderOuterDrawRadius, centerY + borderOuterDrawRadius); + canvas.drawOval(rect, paintBorder); + rect.set(centerX - borderInnerDrawRadius, centerY - borderInnerDrawRadius, centerX + borderInnerDrawRadius, centerY + borderInnerDrawRadius); + canvas.drawOval(rect, paintBorder); + + for (int i = 0; i < segmentCount; i++) { + float angle = (i * sweepAngle) - (sweepAngle / 2) - 90; + double angleRad = Math.toRadians(angle); + float startX = centerX + (float) (fillInnerRadius * Math.cos(angleRad)); + float startY = centerY + (float) (fillInnerRadius * Math.sin(angleRad)); + float endX = centerX + (float) (fillOuterRadius * Math.cos(angleRad)); + float endY = centerY + (float) (fillOuterRadius * Math.sin(angleRad)); + canvas.drawLine(startX, startY, endX, endY, paintBorder); + } + + // 在中心模式时,为外圈描边添加柔和阴影以增强层次 + if (isPopupMode && popupAtScreenCenter) { + paintBorder.setShadowLayer(Math.max(2, thick * 0.6f), 0, 0, 0x55000000); + } + + boolean showCenterTextInNormalMode = isPopupMode && activeIndex != -1 && currentMode == ElementController.Mode.Normal; + boolean showCenterTextInEditMode = currentMode == ElementController.Mode.Edit && isBeingEdited(); + + if (showCenterTextInNormalMode || showCenterTextInEditMode) { + String displayName; + + if (showCenterTextInNormalMode) { + // 正常模式下,显示当前选中的分区文本 + if (activeIndex < segmentValues.size()) { + if (activeIndex < segmentNames.size() && segmentNames.get(activeIndex) != null && !segmentNames.get(activeIndex).isEmpty()) { + displayName = segmentNames.get(activeIndex); + } else { + displayName = getDisplayStringForValue(segmentValues.get(activeIndex)); + } + } else { + displayName = "预览"; // 备用 + } + } else { + // 编辑模式下,显示固定的示例文本 + displayName = "预览"; + } + + // 应用中心文字的颜色和大小 (这部分逻辑不变) + paintCenterText.setColor(this.centerTextColor); + float centerTextSize = fillInnerRadius * 2 * (this.centerTextSizePercent / 100.0f); + paintCenterText.setTextSize(centerTextSize); + paintCenterText.setShadowLayer(Math.max(2, centerTextSize * 0.06f), 0, Math.max(1, centerTextSize * 0.04f), 0x88000000); + + float textY = centerY - ((paintCenterText.descent() + paintCenterText.ascent()) / 2); + canvas.drawText(displayName, centerX, textY, paintCenterText); + } + + // 清除描边阴影,避免影响外部绘制 + if (isPopupMode && popupAtScreenCenter) { + paintBorder.setShadowLayer(0, 0, 0, 0); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + if (elementController.getMode() != ElementController.Mode.Normal) { + return true; + } + + if (!isPopupMode) { + handleDirectModeTouchEvent(event); + return true; + } + + if (isWheelActive) { + handlePopupModeTouchEvent(event); + return true; + } + + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + float x = event.getX(); + float y = event.getY(); + float centerX = getWidth() / 2.0f; + float centerY = getHeight() / 2.0f; + float outerRadius = (getWidth() / 2.0f) - thick; + if (outerRadius < 0) outerRadius = 0; + float innerRadius = outerRadius * (innerRadiusPercent / 100.0f); + float dx = x - centerX; + float dy = y - centerY; + double distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= innerRadius) { + handlePopupModeTouchEvent(event); + return true; + } + } + return false; + } + + private void handleDirectModeTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) { + if (action == MotionEvent.ACTION_DOWN) { + elementController.buttonVibrator(); + } + float x = event.getX(); + float y = event.getY(); + float centerX = getWidth() / 2.0f; + float centerY = getHeight() / 2.0f; + float outerRadius = (getWidth() / 2.0f) - thick; + if (outerRadius < 0) outerRadius = 0; + float innerRadius = outerRadius * (innerRadiusPercent / 100.0f); + float dx = x - centerX; + float dy = y - centerY; + double distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > innerRadius && distance < outerRadius) { + double angle = Math.toDegrees(Math.atan2(dy, dx)) + 90; + if (angle < 0) angle += 360; + float sweepAngle = 360.0f / segmentCount; + activeIndex = (int) ((angle + sweepAngle / 2) % 360 / sweepAngle); + } else { + activeIndex = -1; + } + } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + activeIndex = -1; + } + updateSendingState(); + invalidate(); + } + + private void handlePopupModeTouchEvent(MotionEvent event) { + float x = event.getX(); + float y = event.getY(); + float centerX = getWidth() / 2.0f; + float centerY = getHeight() / 2.0f; + + float outerRadius = (getWidth() / 2.0f) - thick; + if (outerRadius < 0) outerRadius = 0; + float innerRadius = outerRadius * (innerRadiusPercent / 100.0f); + + float dx = x - centerX; + float dy = y - centerY; + double distance = Math.sqrt(dx * dx + dy * dy); + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + if (distance <= innerRadius) { + isWheelActive = true; + elementController.buttonVibrator(); + invalidate(); + } + break; + case MotionEvent.ACTION_MOVE: + if (isWheelActive) { + // 清除上一帧的悬停状态 + hoveredGroupButton = null; + + if (distance > innerRadius) { + double angle = Math.toDegrees(Math.atan2(dy, dx)) + 90; + if (angle < 0) angle += 360; + float sweepAngle = 360.0f / segmentCount; + activeIndex = (int) ((angle + sweepAngle / 2) % 360 / sweepAngle); + + // 检查当前分区是否为组按键,如果是,则获取其实例用于预览 + if (activeIndex != -1 && activeIndex < segmentValues.size()) { + String value = segmentValues.get(activeIndex); + if (value != null && value.startsWith("gb")) { + try { + long groupId = Long.parseLong(value.substring(2)); + Element element = elementController.findElementById(groupId); + if (element instanceof GroupButton) { + hoveredGroupButton = (GroupButton) element; + } + } catch (Exception e) { + // 如果ID解析失败或找不到元素,确保悬停状态被清除 + hoveredGroupButton = null; + } + } + } + } else { + activeIndex = -1; + } + invalidate(); + } + break; + case MotionEvent.ACTION_UP: + if (isWheelActive) { + if (activeIndex != -1 && activeIndex < segmentValues.size()) { + String value = segmentValues.get(activeIndex); + // 即时获取 handlers + List handlers = getHandlersForValueNow(value); + + // 按下 + for (ElementController.SendEventHandler handler : handlers) { + handler.sendEvent(true); + } + // 释放 + for (int i = handlers.size() - 1; i >= 0; i--) { + handlers.get(i).sendEvent(false); + } + } + isWheelActive = false; + activeIndex = -1; + // 手指抬起,清除组按键预览 + hoveredGroupButton = null; + invalidate(); + } + break; + } + } + + private void updateSendingState() { + if (activeIndex != lastActiveIndex) { + // --- 释放上一个 --- + if (lastActiveIndex != -1 && lastActiveIndex < segmentValues.size()) { + String lastValue = segmentValues.get(lastActiveIndex); + // 即时获取 handlers + List lastHandlers = getHandlersForValueNow(lastValue); + for (int i = lastHandlers.size() - 1; i >= 0; i--) { + lastHandlers.get(i).sendEvent(false); + } + } + + // --- 按下新的 --- + if (activeIndex != -1 && activeIndex < segmentValues.size()) { + String activeValue = segmentValues.get(activeIndex); + // 即时获取 handlers + List activeHandlers = getHandlersForValueNow(activeValue); + for (ElementController.SendEventHandler handler : activeHandlers) { + handler.sendEvent(true); + } + } + lastActiveIndex = activeIndex; + } + } + + @Override + protected SuperPageLayout getInfoPage() { + if (wheelPadPage == null) { + wheelPadPage = (SuperPageLayout) LayoutInflater.from(getContext()).inflate(R.layout.page_wheel_pad, null); + centralXNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_central_x); + centralYNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_central_y); + valuesContainer = wheelPadPage.findViewById(R.id.page_wheel_pad_values_container); + } + + // Find all views + ElementEditText centerTextInput = wheelPadPage.findViewById(R.id.page_wheel_pad_center_text); + final Switch popupSwitch = wheelPadPage.findViewById(R.id.page_wheel_pad_popup_at_center_switch); + + final Switch previewGroupSwitch = wheelPadPage.findViewById(R.id.page_wheel_pad_preview_group_switch); + + final LinearLayout popupOptionsContainer = wheelPadPage.findViewById(R.id.page_wheel_pad_popup_options_container); + + NumberSeekbar sizeNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_size); + NumberSeekbar thickNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_thick); + NumberSeekbar layerNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_layer); + ElementEditText normalColorElementEditText = wheelPadPage.findViewById(R.id.page_wheel_pad_normal_color); + ElementEditText pressedColorElementEditText = wheelPadPage.findViewById(R.id.page_wheel_pad_pressed_color); + ElementEditText backgroundColorElementEditText = wheelPadPage.findViewById(R.id.page_wheel_pad_background_color); + Button copyButton = wheelPadPage.findViewById(R.id.page_wheel_pad_copy); + Button deleteButton = wheelPadPage.findViewById(R.id.page_wheel_pad_delete); + NumberSeekbar segmentCountNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_segment_count); + NumberSeekbar innerRadiusNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_inner_radius); + + NumberSeekbar textSizeNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_text_size); + ElementEditText normalTextColorElementEditText = wheelPadPage.findViewById(R.id.page_wheel_pad_normal_text_color); + NumberSeekbar centerTextSizeNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_center_text_size); + ElementEditText centerTextColorElementEditText = wheelPadPage.findViewById(R.id.page_wheel_pad_center_text_color); + NumberSeekbar triggerTextSizeNumberSeekbar = wheelPadPage.findViewById(R.id.page_wheel_pad_trigger_text_size); + + setupCommonControls(centerTextInput, sizeNumberSeekbar, thickNumberSeekbar, layerNumberSeekbar, + normalColorElementEditText, pressedColorElementEditText, backgroundColorElementEditText, + copyButton, deleteButton); + + // Setup the switch + popupSwitch.setChecked(this.popupAtScreenCenter); + popupSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + this.popupAtScreenCenter = isChecked; + invalidate(); + save(); + }); + + // 设置预览开关 + previewGroupSwitch.setChecked(this.previewGroupChildren); + previewGroupSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + this.previewGroupChildren = isChecked; + // 预览开关不需要实时 invalidate(),因为它只在轮盘激活时生效 + save(); + }); + + // Set the initial visibility based on the current mode + boolean isPopup = !this.centerText.isEmpty(); + popupOptionsContainer.setVisibility(isPopup ? View.VISIBLE : View.GONE); + + // Override the listener to control visibility of the container + centerTextInput.setOnTextChangedListener(text -> { + setCenterText(text); + boolean isPopupNow = !text.isEmpty(); + // Show/Hide the entire container based on popup mode + popupOptionsContainer.setVisibility(isPopupNow ? View.VISIBLE : View.GONE); + invalidate(); + save(); + }); + + segmentCountNumberSeekbar.setValueWithNoCallBack(segmentCount); + segmentCountNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setSegmentCount(progress); + updateValuesContainerUI(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + innerRadiusNumberSeekbar.setValueWithNoCallBack(innerRadiusPercent); + innerRadiusNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setInnerRadiusPercent(progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // 分区文字大小 + textSizeNumberSeekbar.setProgressMin(10); // 10% + textSizeNumberSeekbar.setProgressMax(100); // 100% + textSizeNumberSeekbar.setValueWithNoCallBack(this.textSizePercent); + textSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + textSizePercent = progress; + invalidate(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + // 中心文字大小 + centerTextSizeNumberSeekbar.setProgressMin(10); // 10% + centerTextSizeNumberSeekbar.setProgressMax(150); // 150% + centerTextSizeNumberSeekbar.setValueWithNoCallBack(this.centerTextSizePercent); + centerTextSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + centerTextSizePercent = progress; + invalidate(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); + } + }); + + setupColorPickerButton(normalTextColorElementEditText, () -> this.normalTextColor, color -> { + this.normalTextColor = color; + invalidate(); + }); + setupColorPickerButton(centerTextColorElementEditText, () -> this.centerTextColor, color -> { + this.centerTextColor = color; + invalidate(); + }); + // --- 设置触发器文字大小的 seekbar --- + triggerTextSizeNumberSeekbar.setProgressMin(5); + triggerTextSizeNumberSeekbar.setProgressMax(150); + triggerTextSizeNumberSeekbar.setValueWithNoCallBack(this.triggerTextSizePercent); + triggerTextSizeNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + triggerTextSizePercent = progress; + invalidate(); // 实时预览 + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + save(); // 保存设置 + } + }); + + updateValuesContainerUI(); + return wheelPadPage; + } + + @Override + public void save() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, this.diameter); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, this.diameter); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, this.centerText); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, layer); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, getElementCentralX()); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + contentValues.put(COLUMN_INT_ELEMENT_THICK, thick); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, normalColor); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, pressedColor); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, backgroundColor); + contentValues.put(COLUMN_INT_ELEMENT_MODE, segmentCount); + contentValues.put(COLUMN_INT_ELEMENT_SENSE, innerRadiusPercent); + contentValues.put(COLUMN_INT_ELEMENT_FLAG1, popupAtScreenCenter ? 1 : 0); + + JsonObject extraAttrs = new JsonObject(); + extraAttrs.addProperty("normalTextColor", this.normalTextColor); + extraAttrs.addProperty("centerTextColor", this.centerTextColor); + extraAttrs.addProperty("textSizePercent", this.textSizePercent); + extraAttrs.addProperty("centerTextSizePercent", this.centerTextSizePercent); + extraAttrs.addProperty("triggerTextSizePercent", this.triggerTextSizePercent); + extraAttrs.addProperty("previewGroupChildren", this.previewGroupChildren); + contentValues.put("extra_attributes", new Gson().toJson(extraAttrs)); + + List combinedSegments = new ArrayList<>(); + for (int i = 0; i < segmentValues.size(); i++) { + String value = segmentValues.get(i); + String name = (i < segmentNames.size()) ? segmentNames.get(i) : ""; + if (name != null && !name.isEmpty()) { + combinedSegments.add(value + "|" + name); + } else { + combinedSegments.add(value); + } + } + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, String.join(",", combinedSegments)); + elementController.updateElement(elementId, contentValues); + } + + public static ContentValues getInitialInfo() { + ContentValues contentValues = new ContentValues(); + contentValues.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_WHEEL_PAD); + contentValues.put(COLUMN_STRING_ELEMENT_VALUE, "k51,k32,k47,k29,k45,k33,k46,k31"); + contentValues.put(COLUMN_INT_ELEMENT_WIDTH, 400); + contentValues.put(COLUMN_INT_ELEMENT_HEIGHT, 400); + contentValues.put(COLUMN_STRING_ELEMENT_TEXT, ""); + contentValues.put(COLUMN_INT_ELEMENT_LAYER, 40); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_X, 250); + contentValues.put(COLUMN_INT_ELEMENT_CENTRAL_Y, 250); + contentValues.put(COLUMN_INT_ELEMENT_THICK, 5); + contentValues.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, 0xF0888888); + contentValues.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, 0xF00000FF); + contentValues.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, 0xAA444444); + contentValues.put(COLUMN_INT_ELEMENT_MODE, 8); + contentValues.put(COLUMN_INT_ELEMENT_SENSE, 30); + contentValues.put(COLUMN_INT_ELEMENT_FLAG1, 1); + + JsonObject extraAttrs = new JsonObject(); + // **为所有新的字体属性设置默认值** + extraAttrs.addProperty("normalTextColor", 0xFFFFFFFF); // White + extraAttrs.addProperty("pressedTextColor", 0xFFFFFFFF); // White + extraAttrs.addProperty("centerTextColor", 0xFFFFFFFF); // White + extraAttrs.addProperty("textSizePercent", 35); // 35% + extraAttrs.addProperty("centerTextSizePercent", 60); // 60% + extraAttrs.addProperty("triggerTextSizePercent", 40); + extraAttrs.addProperty("previewGroupChildren", true); + // 将 JsonObject 转换为字符串,并存入通用的 "extra_attributes" 列 + contentValues.put("extra_attributes", new Gson().toJson(extraAttrs)); + + return contentValues; + } + + private void setupCommonControls(ElementEditText centerTextInput, NumberSeekbar size, NumberSeekbar thick, NumberSeekbar layer, + ElementEditText normalColor, ElementEditText pressedColor, ElementEditText backgroundColor, + Button copy, Button delete) { + + centerTextInput.setTextWithNoTextChangedCallBack(this.centerText); + // NOTE: The listener is set/overridden in getInfoPage() to handle the switch logic + centerTextInput.setOnTextChangedListener(text -> { + setCenterText(text); + save(); + }); + + centralXNumberSeekbar.setProgressMin(centralXMin); + centralXNumberSeekbar.setProgressMax(centralXMax); + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralXNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean f) { + setElementCentralX(p); + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + save(); + } + }); + centralYNumberSeekbar.setProgressMin(centralYMin); + centralYNumberSeekbar.setProgressMax(centralYMax); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + centralYNumberSeekbar.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean f) { + setElementCentralY(p); + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + save(); + } + }); + size.setProgressMax(Math.min(widthMax, heightMax)); + size.setProgressMin(widthMin); + size.setValueWithNoCallBack(this.diameter); + size.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean f) { + setDiameter(p); + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + save(); + } + }); + thick.setValueWithNoCallBack(this.thick); + thick.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean f) { + setElementThick(p); + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + save(); + } + }); + layer.setValueWithNoCallBack(this.layer); + layer.setOnNumberSeekbarChangeListener(new NumberSeekbar.OnNumberSeekbarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean f) { + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + setElementLayer(s.getProgress()); + save(); + } + }); + + setupColorPickerButton(normalColor, () -> this.normalColor, this::setElementNormalColor); + setupColorPickerButton(pressedColor, () -> this.pressedColor, this::setElementPressedColor); + setupColorPickerButton(backgroundColor, () -> this.backgroundColor, this::setElementBackgroundColor); + + copy.setOnClickListener(v -> { + ContentValues cv = new ContentValues(); + cv.put(COLUMN_INT_ELEMENT_TYPE, ELEMENT_TYPE_WHEEL_PAD); + cv.put(COLUMN_INT_ELEMENT_WIDTH, this.diameter); + cv.put(COLUMN_INT_ELEMENT_HEIGHT, this.diameter); + cv.put(COLUMN_STRING_ELEMENT_TEXT, this.centerText); + cv.put(COLUMN_INT_ELEMENT_LAYER, this.layer); + cv.put(COLUMN_INT_ELEMENT_CENTRAL_X, Math.max(Math.min(getElementCentralX() + getElementWidth() / 2, centralXMax), centralXMin)); + cv.put(COLUMN_INT_ELEMENT_CENTRAL_Y, getElementCentralY()); + cv.put(COLUMN_INT_ELEMENT_THICK, this.thick); + cv.put(COLUMN_INT_ELEMENT_NORMAL_COLOR, this.normalColor); + cv.put(COLUMN_INT_ELEMENT_PRESSED_COLOR, this.pressedColor); + cv.put(COLUMN_INT_ELEMENT_BACKGROUND_COLOR, this.backgroundColor); + cv.put(COLUMN_INT_ELEMENT_MODE, this.segmentCount); + cv.put(COLUMN_INT_ELEMENT_SENSE, this.innerRadiusPercent); + cv.put(COLUMN_INT_ELEMENT_FLAG1, this.popupAtScreenCenter ? 1 : 0); + + // 创建一个 JsonObject 来存储所有没有独立数据库列的属性 + JsonObject extraAttrs = new JsonObject(); + // **放入所有新的字体属性** + extraAttrs.addProperty("normalTextColor", this.normalTextColor); + extraAttrs.addProperty("pressedTextColor", this.pressedTextColor); + extraAttrs.addProperty("centerTextColor", this.centerTextColor); + extraAttrs.addProperty("textSizePercent", this.textSizePercent); + extraAttrs.addProperty("centerTextSizePercent", this.centerTextSizePercent); + extraAttrs.addProperty("triggerTextSizePercent", this.triggerTextSizePercent); + extraAttrs.addProperty("previewGroupChildren", this.previewGroupChildren); + + // 将 JsonObject 转换为字符串,并存入通用的 "extra_attributes" 列 + cv.put("extra_attributes", new Gson().toJson(extraAttrs)); + + List combinedSegments = new ArrayList<>(); + for (int i = 0; i < segmentValues.size(); i++) { + String value = segmentValues.get(i); + String name = (i < segmentNames.size()) ? segmentNames.get(i) : ""; + if (name != null && !name.isEmpty()) { + combinedSegments.add(value + "|" + name); + } else { + combinedSegments.add(value); + } + } + cv.put(COLUMN_STRING_ELEMENT_VALUE, String.join(",", combinedSegments)); + elementController.addElement(cv); + }); + delete.setOnClickListener(v -> { + elementController.toggleInfoPage(wheelPadPage); + elementController.deleteElement(wheelPad); + }); + } + + protected void setDiameter(int newDiameter) { + this.diameter = newDiameter; + setElementWidth(newDiameter); + setElementHeight(newDiameter); + invalidate(); + } + + protected void setSegmentCount(int count) { + if (count < 2) count = 2; + this.segmentCount = count; + while (segmentValues.size() < count) { + segmentValues.add("null"); + segmentNames.add(""); + } + while (segmentValues.size() > count) { + segmentValues.remove(segmentValues.size() - 1); + segmentNames.remove(segmentNames.size() - 1); + } + invalidate(); + } + + protected void setSegmentValue(int index, String value) { + if (index >= 0 && index < segmentValues.size()) { + segmentValues.set(index, value); + invalidate(); + } + } + + protected void setInnerRadiusPercent(int percent) { + this.innerRadiusPercent = percent; + invalidate(); + } + + protected void setElementThick(int thick) { + this.thick = thick; + invalidate(); + } + + protected void setElementNormalColor(int normalColor) { + this.normalColor = normalColor; + invalidate(); + } + + protected void setElementPressedColor(int pressedColor) { + this.pressedColor = pressedColor; + invalidate(); + } + + protected void setElementBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + invalidate(); + } + + protected void setCenterText(String text) { + this.centerText = text; + this.isPopupMode = !text.isEmpty(); + invalidate(); + } + + @Override + protected void updatePage() { + if (wheelPadPage != null) { + centralXNumberSeekbar.setValueWithNoCallBack(getElementCentralX()); + centralYNumberSeekbar.setValueWithNoCallBack(getElementCentralY()); + } + } + + private String getDisplayStringForValue(String value) { + if (value == null || value.isEmpty() || value.equals("null")) { + return "空"; + } + + if (value.startsWith("gb")) { + try { + long elementId = Long.parseLong(value.substring(2)); + Element element = elementController.findElementById(elementId); + if (element instanceof GroupButton) { + return "[组] " + ((GroupButton) element).getText(); + } else { + return "[无效的组]"; + } + } catch (NumberFormatException e) { + return "[格式错误的组ID]"; + } + } + + String[] singleKeyValues = value.split("\\+"); + List keyNames = new ArrayList<>(); + for (String singleKeyValue : singleKeyValues) { + keyNames.add(pageDeviceController.getKeyNameByValue(singleKeyValue)); + } + return String.join(" + ", keyNames); + } + + private void updateKeyCombinationDisplay(LinearLayout keysContainer, List currentKeys, Button addButton, Button addGroupButton, final AlertDialog dialog) { + keysContainer.removeAllViews(); + Context context = keysContainer.getContext(); + boolean isGroup = !currentKeys.isEmpty() && currentKeys.get(0).startsWith("gb"); + + // 根据是否已选择组按键,更新按钮的可见性 + if (isGroup) { + addButton.setVisibility(View.GONE); + addGroupButton.setVisibility(View.GONE); + } else { + addButton.setVisibility(View.VISIBLE); + addGroupButton.setVisibility(View.VISIBLE); + } + + if (currentKeys.isEmpty()) { + TextView emptyText = new TextView(context); + emptyText.setText("当前无按键,请点击下方按钮添加"); + emptyText.setPadding(0, 10, 0, 10); + keysContainer.addView(emptyText); + } else if (isGroup) { + // --- 显示组按键 --- + try { + long elementId = Long.parseLong(currentKeys.get(0).substring(2)); + Element element = elementController.findElementById(elementId); + if (element instanceof GroupButton) { + final GroupButton groupButton = (GroupButton) element; + final SuperPageLayout groupButtonSettingsPage = groupButton.getInfoPage(); + Button groupBtnDisplay = new Button(context); + groupBtnDisplay.setText("[组] " + groupButton.getText() + " (单击设置/长按删除)"); + groupBtnDisplay.setAllCaps(false); + + // 单击打开组按键设置页 + groupBtnDisplay.setOnClickListener(v -> { + if (dialog != null) { + dialog.dismiss(); + } + final SuperPageLayout wheelPadSettingsPage = wheelPad.getInfoPage(); + groupButtonSettingsPage.setLastPage(wheelPadSettingsPage); + groupButtonSettingsPage.setPageReturnListener(new SuperPageLayout.ReturnListener() { + @Override + public void returnCallBack() { + elementController.getSuperPagesController().openNewPage(wheelPadSettingsPage); + } + }); + elementController.getSuperPagesController().openNewPage(groupButtonSettingsPage); + }); + + // 长按删除 + groupBtnDisplay.setOnLongClickListener(v -> { + // 清空当前分区的按键设置 + currentKeys.clear(); + + // 更新UI以反映移除 + updateKeyCombinationDisplay(keysContainer, currentKeys, addButton, addGroupButton, dialog); + + Toast.makeText(context, "已移除组按键,点击确定保存设置", Toast.LENGTH_SHORT).show(); + + return true; // 返回true表示事件已被消费 + }); + keysContainer.addView(groupBtnDisplay); + } else { + // 如果找到了元素但类型不对,也显示错误 + currentKeys.clear(); + TextView errorText = new TextView(context); + errorText.setText("错误:ID " + elementId + " 不是一个组按键。"); + keysContainer.addView(errorText); + updateKeyCombinationDisplay(keysContainer, currentKeys, addButton, addGroupButton, dialog); + } + } catch (Exception e) { + // 如果解析或查找失败,显示错误信息并允许重新选择 + currentKeys.clear(); + TextView errorText = new TextView(context); + errorText.setText("错误:关联的组按键已不存在,请重新设置。"); + keysContainer.addView(errorText); + } + } else { + // --- 显示普通按键列表 --- + for (int i = 0; i < currentKeys.size(); i++) { + final int keyIndex = i; + TextView keyView = new TextView(context); + keyView.setText(pageDeviceController.getKeyNameByValue(currentKeys.get(keyIndex)) + " (点击移除)"); + keyView.setTextSize(16); + keyView.setBackgroundResource(R.drawable.enabled_square); + keyView.setPadding(20, 15, 20, 15); + keyView.setGravity(Gravity.CENTER); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, 5, 0, 5); + keyView.setLayoutParams(params); + + keyView.setOnClickListener(v -> { + currentKeys.remove(keyIndex); + updateKeyCombinationDisplay(keysContainer, currentKeys, addButton, addGroupButton, dialog); + }); + keysContainer.addView(keyView); + } + } + } + + private void showKeyCombinationDialog(Context context, final int index, final TextView valueText) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle("编辑按键(可组合) (分区 " + (index + 1) + ")"); + + LinearLayout dialogLayout = new LinearLayout(context); + dialogLayout.setOrientation(LinearLayout.VERTICAL); + dialogLayout.setPadding(30, 20, 30, 20); + + LinearLayout keysContainer = new LinearLayout(context); + keysContainer.setOrientation(LinearLayout.VERTICAL); + + ScrollView scrollView = new ScrollView(context); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1.0f + ); + scrollParams.setMargins(0, 0, 0, 20); + scrollView.setLayoutParams(scrollParams); + scrollView.addView(keysContainer); + + String currentValue = segmentValues.get(index); + final List currentKeys = new ArrayList<>(); + if (currentValue != null && !currentValue.isEmpty() && !currentValue.equals("null")) { + // 组按键只有一个值 "gb", 普通按键用 "+" 分隔 + if (currentValue.startsWith("gb")) { + currentKeys.add(currentValue); + } else { + currentKeys.addAll(Arrays.asList(currentValue.split("\\+"))); + } + } + + // --- 新增按钮布局和按钮 --- + LinearLayout buttonContainer = new LinearLayout(context); + buttonContainer.setOrientation(LinearLayout.HORIZONTAL); + buttonContainer.setGravity(Gravity.CENTER); + + Button addButton = new Button(context); + addButton.setText("添加按键"); + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f); + buttonParams.setMarginEnd(10); + addButton.setLayoutParams(buttonParams); + + Button addGroupButton = new Button(context); + addGroupButton.setText("添加组按键"); + LinearLayout.LayoutParams groupButtonParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f); + groupButtonParams.setMarginStart(10); + addGroupButton.setLayoutParams(groupButtonParams); + + buttonContainer.addView(addButton); + buttonContainer.addView(addGroupButton); + + dialogLayout.addView(scrollView); + dialogLayout.addView(buttonContainer); + + final AlertDialog dialog = builder.create(); + + // 初始状态更新 + updateKeyCombinationDisplay(keysContainer, currentKeys, addButton, addGroupButton, dialog); + dialog.setView(dialogLayout); + + dialog.setButton(AlertDialog.BUTTON_POSITIVE, "确定", (d, which) -> { + String finalValue; + if (currentKeys.isEmpty()) { + finalValue = "null"; + } else if (currentKeys.get(0).startsWith("gb")) { + finalValue = currentKeys.get(0); + } else { + finalValue = String.join("+", currentKeys); + } + setSegmentValue(index, finalValue); + valueText.setText(getDisplayStringForValue(finalValue)); + save(); + }); + dialog.setButton(AlertDialog.BUTTON_NEGATIVE, "取消", (d, which) -> d.dismiss()); + + // "添加组按键" 的点击逻辑 + addGroupButton.setOnClickListener(v -> { + // 1. 查找所有现有的 GroupButton + List groupButtons = new ArrayList<>(); + for (Element el : elementController.getElements()) { + if (el instanceof GroupButton) { + groupButtons.add((GroupButton) el); + } + } + + // 2. 创建选项列表 + final List options = new ArrayList<>(); + for (GroupButton gb : groupButtons) { + options.add(gb.getText() + " (ID: " + gb.elementId + ")"); + } + options.add("创建新的组按键..."); + + // 3. 显示选择对话框 + new AlertDialog.Builder(context) + .setTitle("选择一个组按键") + .setItems(options.toArray(new String[0]), (selectionDialog, which) -> { + + String selectedValue = null; + Element newElementToEdit = null; + + if (which == options.size() - 1) { + // --- 用户选择 "创建新的" --- + ContentValues cv = GroupButton.getInitialInfo(); + Element newElement = elementController.addElement(cv); + if (newElement != null) { + selectedValue = "gb" + newElement.elementId; + newElementToEdit = newElement; // 记录下来,以便后续打开其设置页 + } + } else { + // --- 用户选择一个现有的 --- + GroupButton selectedGb = groupButtons.get(which); + selectedValue = "gb" + selectedGb.elementId; + } + + if (selectedValue != null) { + // 立即设置值并保存 + setSegmentValue(index, selectedValue); + save(); + + // 更新主设置页面上该分区的显示文本 + valueText.setText(getDisplayStringForValue(selectedValue)); + + // 关闭“编辑按键”这个主弹窗 + dialog.dismiss(); + + // 如果是新创建的,提示用户去配置 + if (newElementToEdit != null) { + Toast.makeText(context, "新组按键已创建并关联,请进行配置", Toast.LENGTH_LONG).show(); + elementController.toggleInfoPage(newElementToEdit.getInfoPage()); + } + } + }) + .show(); + }); + + addButton.setOnClickListener(v -> { + dialog.hide(); + PageDeviceController.DeviceCallBack deviceCallBack = new PageDeviceController.DeviceCallBack() { + @Override + public void OnKeyClick(TextView key) { + currentKeys.add(key.getTag().toString()); + dialog.show(); + updateKeyCombinationDisplay(keysContainer, currentKeys, addButton, addGroupButton, dialog); + } + }; + pageDeviceController.open(deviceCallBack, View.VISIBLE, View.VISIBLE, View.VISIBLE); + }); + + dialog.show(); + } + + private void updateValuesContainerUI() { + if (valuesContainer == null) return; + valuesContainer.removeAllViews(); + for (int i = 0; i < segmentCount; i++) { + if (i >= segmentValues.size()) continue; + View valueView = LayoutInflater.from(getContext()).inflate(R.layout.item_key_value, valuesContainer, false); + TextView title = valueView.findViewById(R.id.item_key_value_title); + ElementEditText nameInput = valueView.findViewById(R.id.item_key_value_name); + TextView valueText = valueView.findViewById(R.id.item_key_value_value); + + title.setText("分区 " + (i + 1)); + final int index = i; + + if (index < segmentNames.size()) { + nameInput.setTextWithNoTextChangedCallBack(segmentNames.get(index)); + } + nameInput.setOnTextChangedListener(text -> { + if (index < segmentNames.size()) { + segmentNames.set(index, text); + invalidate(); + save(); + } + }); + + valueText.setText(getDisplayStringForValue(segmentValues.get(i))); + + valueText.setOnClickListener(v -> { + showKeyCombinationDialog(getContext(), index, valueText); + }); + valuesContainer.addView(valueView); + } + } + + private interface IntSupplier { + int get(); + } + + private interface IntConsumer { + void accept(int value); + } + + private void updateColorDisplay(ElementEditText colorDisplay, int color) { + colorDisplay.setTextWithNoTextChangedCallBack(String.format("%08X", color)); + colorDisplay.setBackgroundColor(color); + double luminance = (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255; + colorDisplay.setTextColor(luminance > 0.5 ? Color.BLACK : Color.WHITE); + colorDisplay.setGravity(Gravity.CENTER); + } + + private void setupColorPickerButton(ElementEditText colorDisplay, IntSupplier initialColorFetcher, IntConsumer colorUpdater) { + colorDisplay.setFocusable(false); + colorDisplay.setCursorVisible(false); + colorDisplay.setKeyListener(null); + updateColorDisplay(colorDisplay, initialColorFetcher.get()); + colorDisplay.setOnClickListener(v -> { + new ColorPickerDialog( + getContext(), + initialColorFetcher.get(), + true, + newColor -> { + colorUpdater.accept(newColor); + save(); + updateColorDisplay(colorDisplay, newColor); + } + ).show(); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/sqlite/SuperConfigDatabaseHelper.java b/app/src/main/java/com/limelight/binding/input/advance_setting/sqlite/SuperConfigDatabaseHelper.java new file mode 100644 index 0000000000..dabcde343f --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/sqlite/SuperConfigDatabaseHelper.java @@ -0,0 +1,938 @@ +package com.limelight.binding.input.advance_setting.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.os.Vibrator; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.limelight.binding.input.advance_setting.config.PageConfigController; +import com.limelight.binding.input.advance_setting.element.DigitalSwitchButton; +import com.limelight.binding.input.advance_setting.element.Element; +import com.limelight.utils.MathUtils; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SuperConfigDatabaseHelper extends SQLiteOpenHelper { + private class ExportFile { + private int version; + private String settings; + private String elements; + private String md5; + + public ExportFile(int version, String settings, String elements) { + this.version = version; + this.settings = settings; + this.elements = elements; + this.md5 = MathUtils.computeMD5(version + settings + elements); + } + + public int getVersion() { + return version; + } + + public void setVersion(int version) { + this.version = version; + } + + public String getSettings() { + return settings; + } + + public void setSettings(String settings) { + this.settings = settings; + } + + public String getElements() { + return elements; + } + + public void setElements(String elements) { + this.elements = elements; + } + + public String getMd5() { + return md5; + } + + public void setMd5(String md5) { + this.md5 = md5; + } + } + + public class ContentValuesSerializer implements JsonSerializer, JsonDeserializer { + + @Override + public JsonElement serialize(ContentValues src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject jsonObject = new JsonObject(); + for (Map.Entry entry : src.valueSet()) { + Object value = entry.getValue(); + if (value instanceof Integer) { + jsonObject.addProperty(entry.getKey(), (Integer) value); + } else if (value instanceof Long) { + jsonObject.addProperty(entry.getKey(), (Long) value); + } else if (value instanceof Double) { + jsonObject.addProperty(entry.getKey(), (Double) value); + } else if (value instanceof String) { + jsonObject.addProperty(entry.getKey(), (String) value); + } else if (value instanceof byte[]) { + // Serialize Blob as Base64 encoded string + String base64Blob = context.serialize(value).getAsString(); + jsonObject.addProperty(entry.getKey(), base64Blob); + } + // Handle other types as needed + } + return jsonObject; + } + + @Override + public ContentValues deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + ContentValues contentValues = new ContentValues(); + JsonObject jsonObject = json.getAsJsonObject(); + for (Map.Entry entry : jsonObject.entrySet()) { + JsonElement jsonElement = entry.getValue(); + if (jsonElement.isJsonPrimitive()) { + JsonPrimitive jsonPrimitive = jsonElement.getAsJsonPrimitive(); + if (jsonPrimitive.isNumber()) { + // Determine if it's a Long or Double based on the value + if (jsonPrimitive.getAsString().contains(".")) { + contentValues.put(entry.getKey(), jsonPrimitive.getAsDouble()); + } else { + contentValues.put(entry.getKey(), jsonPrimitive.getAsLong()); + } + } else if (jsonPrimitive.isString()) { + contentValues.put(entry.getKey(), jsonPrimitive.getAsString()); + } + } else if (jsonElement.isJsonArray()) { + // Deserialize Blob from Base64 encoded string + byte[] blob = context.deserialize(jsonElement, byte[].class); + contentValues.put(entry.getKey(), blob); + } + // Handle other types as needed + } + return contentValues; + } + } + + + private static final String DATABASE_NAME = "super_config.db"; + private static final int DATABASE_OLD_VERSION_1 = 1; + private static final int DATABASE_OLD_VERSION_2 = 2; + private static final int DATABASE_OLD_VERSION_3 = 3; + private static final int DATABASE_OLD_VERSION_4 = 4; + private static final int DATABASE_OLD_VERSION_5 = 5; + private static final int DATABASE_OLD_VERSION_6 = 6; + private static final int DATABASE_VERSION = 8; + private SQLiteDatabase writableDataBase; + private SQLiteDatabase readableDataBase; + + public SuperConfigDatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + writableDataBase = getWritableDatabase(); + readableDataBase = getReadableDatabase(); + } + + @Override + public void onCreate(SQLiteDatabase db) { + // 更新 onCreate,添加新字段 + // 创建表格的SQL语句 + String createElementTable = "CREATE TABLE IF NOT EXISTS element (" + + "_id INTEGER PRIMARY KEY, " + + "element_id INTEGER," + + "config_id INTEGER," + + "element_type INTEGER," + + "element_value TEXT," + + "element_middle_value TEXT," + + "element_up_value TEXT," + + "element_down_value TEXT," + + "element_left_value TEXT," + + "element_right_value TEXT," + + "element_layer INTEGER," + + "element_mode INTEGER," + + "element_sense INTEGER," + + "element_central_x INTEGER," + + "element_central_y INTEGER," + + "element_width INTEGER," + + "element_height INTEGER," + + "element_area_width INTEGER," + + "element_area_height INTEGER," + + "element_text TEXT," + + "element_click_text TEXT," + + "element_background_icon TEXT," + + "element_click_background_icon TEXT," + + "element_radius INTEGER," + + "element_opacity INTEGER," + + "element_thick INTEGER," + + "element_background_color INTEGER," + + "element_color INTEGER," + + "element_pressed_color INTEGER," + + Element.COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR + " INTEGER," + + Element.COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR + " INTEGER," + + Element.COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT + " INTEGER," + + "element_create_time INTEGER," + + Element.COLUMN_INT_ELEMENT_FLAG1 + " INTEGER DEFAULT 1," + + "extra_attributes TEXT" + // 添加一个名为 extra_attributes 的文本列 + ")"; + // 执行SQL语句 + db.execSQL(createElementTable); + + String createConfigTable = "CREATE TABLE IF NOT EXISTS config (" + + "_id INTEGER PRIMARY KEY, " + + "config_id INTEGER," + + "config_name TEXT," + + "touch_enable TEXT," + + "touch_mode TEXT," + + "touch_sense INTEGER," + + "game_vibrator TEXT," + + "button_vibrator TEXT," + + "mouse_wheel_speed INTEGER," + + PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH + " TEXT DEFAULT 'false'" + + ")"; + + db.execSQL(createConfigTable); + } + + public void deleteTable(String tableName) { + SQLiteDatabase db = this.getWritableDatabase(); + db.execSQL("DROP TABLE IF EXISTS " + tableName); + } + + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // 更新 onUpgrade,为老用户添加新字段 + // 升级数据库时执行的操作 + System.out.println("SuperConfigDatabaseHelper.onUpgrade from " + oldVersion + " to " + newVersion); + // 采用更健壮的 fall-through 结构 + if (oldVersion < 3) { + db.execSQL("ALTER TABLE config ADD COLUMN game_vibrator TEXT DEFAULT 'false';"); + db.execSQL("ALTER TABLE config ADD COLUMN button_vibrator TEXT DEFAULT 'false';"); + } + if (oldVersion < 4) { + db.execSQL("ALTER TABLE config ADD COLUMN mouse_wheel_speed INTEGER DEFAULT 20;"); + } + if (oldVersion < 5) { + String alterTableSQL = "ALTER TABLE config" + " ADD COLUMN " + PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH + " TEXT DEFAULT 'false'"; + db.execSQL(alterTableSQL); + } + if (oldVersion < 6) { + db.execSQL("ALTER TABLE element ADD COLUMN " + Element.COLUMN_INT_ELEMENT_FLAG1 + " INTEGER DEFAULT 1;"); + } + // 新增的升级逻辑 + if (oldVersion < 7) { + // 为 element 表添加字体颜色和大小的列,并设置默认值 + db.execSQL("ALTER TABLE element ADD COLUMN " + DigitalSwitchButton.COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR + " INTEGER DEFAULT -1;"); // 0xFFFFFFFF + db.execSQL("ALTER TABLE element ADD COLUMN " + DigitalSwitchButton.COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR + " INTEGER DEFAULT -3355444;"); // 0xFFCCCCCC + db.execSQL("ALTER TABLE element ADD COLUMN " + DigitalSwitchButton.COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT + " INTEGER DEFAULT 25;"); + } + if (oldVersion < 8) { + db.execSQL("ALTER TABLE element ADD COLUMN extra_attributes TEXT;"); + } + } + + /** + * 辅助方法,用于升级导入导出的配置文件JSON数据 + * + * @param exportFile 从文件中解析出的对象 + * @param gson 用于JSON操作的实例 + * @return 如果成功升级则返回true,否则返回false + */ + private boolean upgradeExportedConfig(ExportFile exportFile, Gson gson) { + int version = exportFile.getVersion(); + String settings = exportFile.getSettings(); + String elements = exportFile.getElements(); + + // 如果版本已经是最新,则无需操作 + if (version == DATABASE_VERSION) { + return true; + } + + // 如果版本比已知的最老兼容版本还老,则拒绝 + if (version < DATABASE_OLD_VERSION_1) { + return false; + } + + // 使用 fall-through (无break) 的 switch 结构模拟 onUpgrade 升级过程 + // 关键:将JSON字符串转换为可操作的JsonObject和JsonArray + JsonObject settingsJson = gson.fromJson(settings, JsonObject.class); + JsonElement elementsJsonElement = gson.fromJson(elements, JsonElement.class); + + switch (version) { + case DATABASE_OLD_VERSION_1: + // 版本1 -> 2: 特殊的正则表达式替换 + String regex = "(\"element_type\":)\\s*51"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(elements); + if (matcher.find()) { + elements = matcher.replaceAll("$13"); + // 重新解析被修改过的 elements 字符串 + elementsJsonElement = gson.fromJson(elements, JsonElement.class); + } + // Fall-through to next case + case DATABASE_OLD_VERSION_2: + // 版本2 -> 3: 在 config 表中添加 game_vibrator 和 button_vibrator + settingsJson.addProperty("game_vibrator", "false"); + settingsJson.addProperty("button_vibrator", "false"); + // Fall-through to next case + case DATABASE_OLD_VERSION_3: + // 版本3 -> 4: 在 config 表中添加 mouse_wheel_speed + settingsJson.addProperty("mouse_wheel_speed", 20); + // Fall-through to next case + case DATABASE_OLD_VERSION_4: + // 版本4 -> 5: 在 config 表中添加 enhanced_touch + settingsJson.addProperty(PageConfigController.COLUMN_BOOLEAN_ENHANCED_TOUCH, "false"); + // Fall-through to next case + case DATABASE_OLD_VERSION_5: + // 版本5 -> 6: 在 element 表中添加 flag1 + if (elementsJsonElement.isJsonArray()) { + for (JsonElement element : elementsJsonElement.getAsJsonArray()) { + element.getAsJsonObject().addProperty(Element.COLUMN_INT_ELEMENT_FLAG1, 1); + } + } + // 更新 import/export 升级逻辑 + case DATABASE_OLD_VERSION_6: + // 版本6 -> 7: 在 element 中添加字体颜色和大小 + if (elementsJsonElement.isJsonArray()) { + for (JsonElement element : elementsJsonElement.getAsJsonArray()) { + JsonObject elementObject = element.getAsJsonObject(); + // 添加新属性并设置合理的默认值 + elementObject.addProperty(DigitalSwitchButton.COLUMN_INT_ELEMENT_NORMAL_TEXT_COLOR, 0xFFFFFFFF); + elementObject.addProperty(DigitalSwitchButton.COLUMN_INT_ELEMENT_PRESSED_TEXT_COLOR, 0xFFCCCCCC); + elementObject.addProperty(DigitalSwitchButton.COLUMN_INT_ELEMENT_TEXT_SIZE_PERCENT, 25); + } + } + // Fall-through to final version + case 7: + if (elementsJsonElement.isJsonArray()) { + for (JsonElement element : elementsJsonElement.getAsJsonArray()) { + // 对于旧配置,这个字段可以是 null 或空json对象 + element.getAsJsonObject().addProperty("extra_attributes", "{}"); + } + } + case DATABASE_VERSION: + break; // 到达最新版本,停止 + default: + // 未知版本,无法升级 + return false; + } + + // 将修改后的Json对象转换回字符串,并更新到exportFile中 + exportFile.setSettings(gson.toJson(settingsJson)); + exportFile.setElements(gson.toJson(elementsJsonElement)); + exportFile.setVersion(DATABASE_VERSION); // 版本号也更新为最新的 + + return true; + } + + public void insertElement(ContentValues values) { + writableDataBase.insert("element", null, values); + } + + public void deleteElement(long configId, long elementId) { + + // 定义 WHERE 子句 + String selection = "config_id = ? AND element_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId), String.valueOf(elementId)}; + + // 执行删除操作 + writableDataBase.delete("element", selection, selectionArgs); + } + + public void updateElement(long configId, long elementId, ContentValues values) { + + // 定义 WHERE 子句 + String selection = "config_id = ? AND element_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId), String.valueOf(elementId)}; + + writableDataBase.update( + "element", // 要更新的表 + values, // 新值 + selection, // WHERE 子句 + selectionArgs // WHERE 子句中的占位符值 + ); + } + + public List queryAllElementIds(long configId) { + + // 定义要查询的列 + String[] projection = {"element_id", "element_layer"}; + + // 定义 WHERE 子句 + String selection = "config_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId)}; + // 排序方式,增序 + String orderBy = "element_id + (element_layer * 281474976710656) ASC"; + + // 执行查询 + Cursor cursor = readableDataBase.query( + "element", // 表名 + projection, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + orderBy // 增序排序 + ); + + List elementIds = new ArrayList<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + int elementIdIndex = cursor.getColumnIndexOrThrow("element_id"); + long elementId = cursor.getLong(elementIdIndex); + elementIds.add(elementId); + } + cursor.close(); + } + System.out.println("elementIds = " + elementIds); + return elementIds; + } + + public Object queryElementAttribute(long configId, long elementId, String elementAttribute) { + + // 定义要查询的列 + String[] projection = {elementAttribute}; + + // 定义 WHERE 子句 + String selection = "config_id = ? AND element_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId), String.valueOf(elementId)}; + + // 执行查询 + Cursor cursor = readableDataBase.query( + "element", // 表名 + projection, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + null // 不排序 + ); + + Object o = null; + if (cursor != null) { + while (cursor.moveToNext()) { + int columnIndex = cursor.getColumnIndexOrThrow(elementAttribute); + switch (cursor.getType(columnIndex)) { + case Cursor.FIELD_TYPE_INTEGER: + o = cursor.getLong(columnIndex); + break; + case Cursor.FIELD_TYPE_FLOAT: + o = cursor.getDouble(columnIndex); + break; + case Cursor.FIELD_TYPE_STRING: + o = cursor.getString(columnIndex); + break; + case Cursor.FIELD_TYPE_BLOB: + o = cursor.getBlob(columnIndex); + break; + case Cursor.FIELD_TYPE_NULL: + break; + } + + } + cursor.close(); + } + + return o; + } + + public Map queryAllElementAttributes(long configId, long elementId) { + Map resultMap = new HashMap<>(); + // 定义 WHERE 子句 + String selection = "config_id = ? AND element_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId), String.valueOf(elementId)}; + + // 执行查询 + Cursor cursor = readableDataBase.query( + "element", // 表名 + null, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + null // 不排序 + ); + if (cursor.moveToFirst()) { + int columnCount = cursor.getColumnCount(); + for (int i = 0; i < columnCount; i++) { + String columnName = cursor.getColumnName(i); + int columnType = cursor.getType(i); + + switch (columnType) { + case Cursor.FIELD_TYPE_INTEGER: + resultMap.put(columnName, cursor.getLong(i)); + break; + case Cursor.FIELD_TYPE_STRING: + resultMap.put(columnName, cursor.getString(i)); + break; + case Cursor.FIELD_TYPE_FLOAT: + resultMap.put(columnName, cursor.getDouble(i)); + break; + case Cursor.FIELD_TYPE_BLOB: + resultMap.put(columnName, cursor.getBlob(i)); + break; + case Cursor.FIELD_TYPE_NULL: + break; + } + } + } + cursor.close(); + return resultMap; + } + + public void insertConfig(ContentValues values) { + + writableDataBase.insert("config", null, values); + + } + + public void deleteConfig(long configId) { + + // 定义 WHERE 子句 + String selection = "config_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId)}; + + // 执行删除操作 + writableDataBase.delete("config", selection, selectionArgs); + + //删除element表中所有的config_id的element + writableDataBase.delete("element", selection, selectionArgs); + + } + + public void updateConfig(long configId, ContentValues values) { + + // SQL WHERE 子句 + String selection = "config_id = ?"; + // selectionArgs 数组提供了 WHERE 子句中占位符 ? 的实际值 + String[] selectionArgs = {String.valueOf(configId)}; + + writableDataBase.update( + "config", // 要更新的表 + values, // 新值 + selection, // WHERE 子句 + selectionArgs // WHERE 子句中的占位符值 + ); + + + } + + public List queryAllConfigIds() { + + // 定义要查询的列 + String[] projection = {"config_id"}; + // 排序方式,增序 + String orderBy = "config_id ASC"; + // 执行查询 + Cursor cursor = readableDataBase.query( + "config", // 表名 + projection, // 要查询的列 + null, // WHERE 子句 + null, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + orderBy // 增序 + ); + + List configIds = new ArrayList<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + int configIdIndex = cursor.getColumnIndexOrThrow("config_id"); + long configId = cursor.getLong(configIdIndex); + configIds.add(configId); + } + cursor.close(); + } + + System.out.println("configIds = " + configIds); + return configIds; + } + + public Object queryConfigAttribute(long configId, String configAttribute, Object defaultValue) { + + // 定义要查询的列 + String[] projection = {configAttribute}; + + // 定义 WHERE 子句 + String selection = "config_id = ?"; + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId)}; + + // 执行查询 + Cursor cursor = readableDataBase.query( + "config", // 表名 + projection, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + null // 不排序 + ); + + Object o = null; + if (cursor != null) { + while (cursor.moveToNext()) { + int columnIndex = cursor.getColumnIndexOrThrow(configAttribute); + switch (cursor.getType(columnIndex)) { + case Cursor.FIELD_TYPE_INTEGER: + o = cursor.getLong(columnIndex); + break; + case Cursor.FIELD_TYPE_FLOAT: + o = cursor.getDouble(columnIndex); + break; + case Cursor.FIELD_TYPE_STRING: + o = cursor.getString(columnIndex); + break; + case Cursor.FIELD_TYPE_BLOB: + o = cursor.getBlob(columnIndex); + break; + case Cursor.FIELD_TYPE_NULL: + break; + } + + } + cursor.close(); + } + if (o == null) { + return defaultValue; + } + return o; + } + + public String exportConfig(Long configId) { + List elementsValueList = new ArrayList<>(); + ContentValues settingValues = new ContentValues(); + + // 定义 WHERE 子句 + String selection = "config_id = ?"; + + // 定义 WHERE 子句中的参数 + String[] selectionArgs = {String.valueOf(configId)}; + + Cursor cursor = readableDataBase.query( + "element", // 表名 + null, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + null // 不排序 + ); + + // 遍历查询结果 + if (cursor.moveToFirst()) { + do { + ContentValues contentValues = new ContentValues(); + + // 将当前行的所有数据存入 ContentValues + for (int i = 0; i < cursor.getColumnCount(); i++) { + String columnName = cursor.getColumnName(i); + if (columnName.equals("_id")) { + continue; + } + int type = cursor.getType(i); + + // 根据列的数据类型将其添加到 ContentValues + switch (type) { + case Cursor.FIELD_TYPE_INTEGER: + contentValues.put(columnName, cursor.getLong(i)); + break; + case Cursor.FIELD_TYPE_FLOAT: + contentValues.put(columnName, cursor.getDouble(i)); + break; + case Cursor.FIELD_TYPE_STRING: + contentValues.put(columnName, cursor.getString(i)); + break; + case Cursor.FIELD_TYPE_BLOB: + contentValues.put(columnName, cursor.getBlob(i)); + break; + case Cursor.FIELD_TYPE_NULL: + break; + } + } + + // 将 ContentValues 对象添加到结果列表中 + elementsValueList.add(contentValues); + + } while (cursor.moveToNext()); + } + + + cursor = readableDataBase.query( + "config", // 表名 + null, // 要查询的列 + selection, // WHERE 子句 + selectionArgs, // WHERE 子句中的参数 + null, // 不分组 + null, // 不过滤 + null // 不排序 + ); + + if (cursor.moveToFirst()) { + // 将当前行的所有数据存入 ContentValues + for (int i = 0; i < cursor.getColumnCount(); i++) { + String columnName = cursor.getColumnName(i); + if (columnName.equals("_id")) { + continue; + } + int type = cursor.getType(i); + + // 根据列的数据类型将其添加到 ContentValues + switch (type) { + case Cursor.FIELD_TYPE_INTEGER: + settingValues.put(columnName, cursor.getLong(i)); + break; + case Cursor.FIELD_TYPE_FLOAT: + settingValues.put(columnName, cursor.getDouble(i)); + break; + case Cursor.FIELD_TYPE_STRING: + settingValues.put(columnName, cursor.getString(i)); + break; + case Cursor.FIELD_TYPE_BLOB: + settingValues.put(columnName, cursor.getBlob(i)); + break; + case Cursor.FIELD_TYPE_NULL: + break; + } + } + } + + // 关闭 Cursor + cursor.close(); + + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.registerTypeAdapter(ContentValues.class, new ContentValuesSerializer()); + Gson gson = gsonBuilder.create(); + ContentValues[] elementsValues = elementsValueList.toArray(new ContentValues[0]); + + + String settingString = gson.toJson(settingValues); + String elementsString = gson.toJson(elementsValues); + + + return gson.toJson(new ExportFile(DATABASE_VERSION, settingString, elementsString)); + + + } + + /** + * 从 JSON 字符串导入一个完整的配置,包括设置和所有元素。 + * 此方法会为所有项创建新的ID,并智能地修复元素之间的引用关系(如GroupButton的子元素和WheelPad的组按键)。 + * + * @param configString 包含配置信息的JSON字符串。 + * @return 0表示成功,负数表示不同的错误代码。 + */ + public int importConfig(String configString) { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.registerTypeAdapter(ContentValues.class, new ContentValuesSerializer()); + Gson gson = gsonBuilder.create(); + ExportFile exportFile; + + try { + exportFile = gson.fromJson(configString, ExportFile.class); + } catch (Exception e) { + return -1; // -1: 文件格式错误 + } + + // MD5校验 (原始数据校验) + if (!exportFile.getMd5().equals(MathUtils.computeMD5(exportFile.getVersion() + exportFile.getSettings() + exportFile.getElements()))) { + return -2; // -2: 文件被篡改或损坏 + } + + // 调用升级逻辑以兼容旧版本配置 + if (!upgradeExportedConfig(exportFile, gson)) { + return -3; // -3: 版本不匹配且无法升级 + } + + // 从可能已升级的 exportFile 获取最新的数据 + String settingString = exportFile.getSettings(); + String elementsString = exportFile.getElements(); + + ContentValues settingValues = gson.fromJson(settingString, ContentValues.class); + ContentValues[] elements = gson.fromJson(elementsString, ContentValues[].class); + + // --- 预处理,建立从旧ID到内存中ContentValues对象的映射 --- + Map oldIdToObjectMap = new HashMap<>(); + for (ContentValues element : elements) { + if (element.containsKey(Element.COLUMN_LONG_ELEMENT_ID)) { + oldIdToObjectMap.put(element.getAsLong(Element.COLUMN_LONG_ELEMENT_ID), element); + } + } + + // --- 插入新的配置 setting,并获取新的 configId --- + Long newConfigId = System.currentTimeMillis(); + settingValues.put(PageConfigController.COLUMN_LONG_CONFIG_ID, newConfigId); + insertConfig(settingValues); + + // --- 插入所有元素,并为它们分配新的ID --- + // 这个过程会直接修改内存中的 ContentValues 对象,为后续的引用修复做准备。 + long elementIdCounter = System.currentTimeMillis(); + for (ContentValues element : elements) { + element.put(Element.COLUMN_LONG_ELEMENT_ID, elementIdCounter++); + element.put(Element.COLUMN_LONG_CONFIG_ID, newConfigId); + insertElement(element); + } + + // --- 统一修复所有元素的引用关系 --- + // 遍历所有内存中的元素对象,检查它们是否需要修复引用字段。 + for (ContentValues element : elements) { + + // --- GroupButton 的子元素引用 --- + if (element.containsKey(Element.COLUMN_INT_ELEMENT_TYPE) && element.getAsLong(Element.COLUMN_INT_ELEMENT_TYPE) == Element.ELEMENT_TYPE_GROUP_BUTTON) { + StringBuilder newValue = new StringBuilder("-1"); + String[] oldChildIds = element.getAsString(Element.COLUMN_STRING_ELEMENT_VALUE).split(","); + for (String oldChildIdStr : oldChildIds) { + if (oldChildIdStr.equals("-1") || oldChildIdStr.isEmpty()) continue; + try { + long oldChildId = Long.parseLong(oldChildIdStr); + ContentValues childObject = oldIdToObjectMap.get(oldChildId); + if (childObject != null) { + // 从子元素对象中获取它刚刚被分配的新ID + newValue.append(",").append(childObject.getAsLong(Element.COLUMN_LONG_ELEMENT_ID)); + } + } catch (NumberFormatException e) { /* 忽略格式错误的ID */ } + } + element.put(Element.COLUMN_STRING_ELEMENT_VALUE, newValue.toString()); + updateElement(element.getAsLong(Element.COLUMN_LONG_CONFIG_ID), element.getAsLong(Element.COLUMN_LONG_ELEMENT_ID), element); + } + + // --- WheelPad 的组按键引用 --- + if (element.containsKey(Element.COLUMN_INT_ELEMENT_TYPE) && element.getAsLong(Element.COLUMN_INT_ELEMENT_TYPE) == Element.ELEMENT_TYPE_WHEEL_PAD) { + String oldSegmentValues = element.getAsString(Element.COLUMN_STRING_ELEMENT_VALUE); + if (oldSegmentValues == null || oldSegmentValues.isEmpty()) continue; + + String[] segments = oldSegmentValues.split(","); + StringBuilder newSegmentValues = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + String segment = segments[i]; + String valuePart = segment.split("\\|")[0]; + String namePart = segment.contains("|") ? segment.substring(segment.indexOf("|")) : ""; + + if (valuePart.startsWith("gb")) { + try { + long oldGroupId = Long.parseLong(valuePart.substring(2)); + ContentValues groupObject = oldIdToObjectMap.get(oldGroupId); + if (groupObject != null) { + // 从 GroupButton 对象中获取它刚刚被分配的新ID + newSegmentValues.append("gb").append(groupObject.getAsLong(Element.COLUMN_LONG_ELEMENT_ID)).append(namePart); + } else { + // 如果找不到对应的组按键,保留原始值或设为null + newSegmentValues.append(segment); + } + } catch (NumberFormatException e) { + newSegmentValues.append(segment); // 解析失败则保留原始值 + } + } else { + newSegmentValues.append(segment); // 不是组按键引用,直接附加 + } + + if (i < segments.length - 1) { + newSegmentValues.append(","); + } + } + element.put(Element.COLUMN_STRING_ELEMENT_VALUE, newSegmentValues.toString()); + updateElement(element.getAsLong(Element.COLUMN_LONG_CONFIG_ID), element.getAsLong(Element.COLUMN_LONG_ELEMENT_ID), element); + } + } + + return 0; // 成功 + } + + public int mergeConfig(String configString, Long existConfigId) { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.registerTypeAdapter(ContentValues.class, new ContentValuesSerializer()); + Gson gson = gsonBuilder.create(); + ExportFile exportFile; + + try { + exportFile = gson.fromJson(configString, ExportFile.class); + } catch (Exception e) { + return -1; // -1: 文件格式错误 + } + + // MD5校验 + if (!exportFile.getMd5().equals(MathUtils.computeMD5(exportFile.getVersion() + exportFile.getSettings() + exportFile.getElements()))) { + return -2; // -2: 文件被篡改或损坏 + } + + // 调用升级逻辑 + if (!upgradeExportedConfig(exportFile, gson)) { + return -3; // -3: 版本不匹配且无法升级 + } + + // 从升级后的 exportFile 获取最新的数据 (mergeConfig不需要settings) + String elementsString = exportFile.getElements(); + + ContentValues[] elements = gson.fromJson(elementsString, ContentValues[].class); + + // 将组按键及其子按键存储在MAP中 + Map> groupButtonMaps = new HashMap<>(); + for (ContentValues groupButtonElement : elements) { + if (groupButtonElement.containsKey(Element.COLUMN_INT_ELEMENT_TYPE) && (long) groupButtonElement.get(Element.COLUMN_INT_ELEMENT_TYPE) == Element.ELEMENT_TYPE_GROUP_BUTTON) { + List childElements = new ArrayList<>(); + + String[] childElementStringIds = ((String) groupButtonElement.get(Element.COLUMN_STRING_ELEMENT_VALUE)).split(","); + // 按键组的值,子按键们的ID + for (String childElementStringId : childElementStringIds) { + long childElementId = Long.parseLong(childElementStringId); + for (ContentValues element : elements) { + if (element.containsKey(Element.COLUMN_LONG_ELEMENT_ID) && (long) element.get(Element.COLUMN_LONG_ELEMENT_ID) == childElementId) { + childElements.add(element); + break; + } + } + } + groupButtonMaps.put(groupButtonElement, childElements); + + } + } + + // 更新所有按键的ID + long elementId = System.currentTimeMillis(); + for (ContentValues contentValues : elements) { + contentValues.put(Element.COLUMN_LONG_ELEMENT_ID, elementId++); + contentValues.put(Element.COLUMN_LONG_CONFIG_ID, existConfigId); + insertElement(contentValues); + } + + // 更新组按键的值 + for (Map.Entry> groupButtonMap : groupButtonMaps.entrySet()) { + String newValue = "-1"; + for (ContentValues childElement : groupButtonMap.getValue()) { + newValue = newValue + "," + childElement.get(Element.COLUMN_LONG_ELEMENT_ID); + } + ContentValues groupButton = groupButtonMap.getKey(); + groupButton.put(Element.COLUMN_STRING_ELEMENT_VALUE, newValue); + updateElement((Long) groupButton.get(Element.COLUMN_LONG_CONFIG_ID), + (Long) groupButton.get(Element.COLUMN_LONG_ELEMENT_ID), + groupButton); + } + + return 0; + } + + +} + diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/ElementEditText.java b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/ElementEditText.java new file mode 100644 index 0000000000..9278472f0e --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/ElementEditText.java @@ -0,0 +1,75 @@ +package com.limelight.binding.input.advance_setting.superpage; + +import android.content.Context; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +public class ElementEditText extends EditText { + + public interface OnTextChangedListener{ + void textChanged(String text); + } + + private OnTextChangedListener onTextChangedListener = new OnTextChangedListener() { + @Override + public void textChanged(String text) { + + } + }; + private TextWatcher textWatcher; + + public ElementEditText(Context context) { + super(context); + init(); + } + + public ElementEditText(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ElementEditText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ElementEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init(){ + textWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + onTextChangedListener.textChanged(String.valueOf(s)); + } + + @Override + public void afterTextChanged(Editable s) { + + } + }; + addTextChangedListener(textWatcher); + } + + public void setTextWithNoTextChangedCallBack(String text){ + removeTextChangedListener(textWatcher); + setText(text); + addTextChangedListener(textWatcher); + } + + public void setOnTextChangedListener(OnTextChangedListener onTextChangedListener){ + this.onTextChangedListener = onTextChangedListener; + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/NumberSeekbar.java b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/NumberSeekbar.java new file mode 100644 index 0000000000..d9ad8f6992 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/NumberSeekbar.java @@ -0,0 +1,168 @@ +package com.limelight.binding.input.advance_setting.superpage; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.limelight.R; + +public class NumberSeekbar extends LinearLayout { + + private TextView numberSeekbarTitle; + private View numberSeekbarMinus; + private TextView numberSeekbarNumber; + private View numberSeekbarAdd; + private SeekBar numberSeekbarSeekbar; + private OnNumberSeekbarChangeListener onNumberSeekbarChangeListener; + + public interface OnNumberSeekbarChangeListener{ + void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser); + void onStartTrackingTouch(SeekBar seekBar); + void onStopTrackingTouch(SeekBar seekBar); + } + + + + public NumberSeekbar(Context context) { + super(context); + init(context,null); + } + + public NumberSeekbar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context,attrs); + } + + public NumberSeekbar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context,attrs); + } + + public NumberSeekbar(Context context, AttributeSet attrs) { + super(context, attrs); + init(context,attrs); + } + + private void init(Context context,AttributeSet attrs) { + LayoutInflater.from(context).inflate(R.layout.number_seekbar, this, true); + + numberSeekbarTitle = findViewById(R.id.number_seekbar_title); + numberSeekbarMinus = findViewById(R.id.number_seekbar_minus); + numberSeekbarNumber = findViewById(R.id.number_seekbar_number); + numberSeekbarAdd = findViewById(R.id.number_seekbar_add); + numberSeekbarSeekbar = findViewById(R.id.number_seekbar_seekbar); + numberSeekbarNumber.setText(String.valueOf(numberSeekbarSeekbar.getProgress())); + + if (attrs != null) { + TypedArray a = context.getTheme().obtainStyledAttributes( + attrs, + R.styleable.NumberSeekbar, + 0, 0); + + try { + int maxValue = a.getInt(R.styleable.NumberSeekbar_max, 100); + int minValue = a.getInt(R.styleable.NumberSeekbar_min, 0); + int progressValue = a.getInt(R.styleable.NumberSeekbar_progress, 0); + numberSeekbarSeekbar.setMax(maxValue); + numberSeekbarSeekbar.setMin(minValue); + numberSeekbarSeekbar.setProgress(progressValue); + numberSeekbarNumber.setText(String.valueOf(progressValue)); + + + String title = a.getString(R.styleable.NumberSeekbar_text); + numberSeekbarTitle.setText(title); + } finally { + a.recycle(); + } + } + + numberSeekbarSeekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + numberSeekbarNumber.setText(String.valueOf(progress)); + if (onNumberSeekbarChangeListener != null){ + onNumberSeekbarChangeListener.onProgressChanged(seekBar, progress, fromUser); + } + + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + if (onNumberSeekbarChangeListener != null){ + onNumberSeekbarChangeListener.onStartTrackingTouch(seekBar); + } + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (onNumberSeekbarChangeListener != null){ + onNumberSeekbarChangeListener.onStopTrackingTouch(seekBar); + } + } + }); + + numberSeekbarMinus.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + int progress = numberSeekbarSeekbar.getProgress(); + if (progress > numberSeekbarSeekbar.getMin()) { + if (onNumberSeekbarChangeListener != null){ + onNumberSeekbarChangeListener.onStartTrackingTouch(numberSeekbarSeekbar); + numberSeekbarSeekbar.setProgress(progress - 1); + onNumberSeekbarChangeListener.onStopTrackingTouch(numberSeekbarSeekbar); + } + + } + } + }); + + numberSeekbarAdd.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + int progress = numberSeekbarSeekbar.getProgress(); + if (progress < numberSeekbarSeekbar.getMax()) { + numberSeekbarSeekbar.setProgress(progress + 1); + if (onNumberSeekbarChangeListener != null){ + onNumberSeekbarChangeListener.onStopTrackingTouch(numberSeekbarSeekbar); + } + } + } + }); + } + + public int getValue() { + return numberSeekbarSeekbar.getProgress(); + } + + public void setValueWithNoCallBack(int value) { + OnNumberSeekbarChangeListener onNumberSeekbarChangeListenerTemp = onNumberSeekbarChangeListener; + onNumberSeekbarChangeListener = null; + numberSeekbarSeekbar.setProgress(value); + numberSeekbarNumber.setText(String.valueOf(value)); + onNumberSeekbarChangeListener = onNumberSeekbarChangeListenerTemp; + } + + public void setTitle(String title){ + numberSeekbarTitle.setText(title); + } + + public void setProgressMax(int max){ + numberSeekbarSeekbar.setMax(max); + } + + public void setProgressMin(int min){ + numberSeekbarSeekbar.setMin(min); + } + + public void setOnNumberSeekbarChangeListener(OnNumberSeekbarChangeListener onNumberSeekbarChangeListener){ + this.onNumberSeekbarChangeListener = onNumberSeekbarChangeListener; + } + + +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPageLayout.java b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPageLayout.java new file mode 100644 index 0000000000..363a885278 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPageLayout.java @@ -0,0 +1,164 @@ +package com.limelight.binding.input.advance_setting.superpage; + + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.FrameLayout; + +public class SuperPageLayout extends FrameLayout { + + public interface DoubleFingerSwipeListener{ + void onRightSwipe(); + void onLeftSwipe(); + } + + public interface ReturnListener { + void returnCallBack(); + } + + private static final int SWIPE_THRESHOLD = 70; + private float startX; + private boolean isTwoFingerSwipe = false; + private boolean isSwipeActionDone = false; + private DoubleFingerSwipeListener doubleFingerSwipeListener; + private ReturnListener returnListener; + private boolean disableTouch = false; + private ObjectAnimator animator; + private SuperPageLayout lastPage; + + + public SuperPageLayout(Context context) { + super(context); + } + + public SuperPageLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SuperPageLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public SuperPageLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (disableTouch){ + return true; + } + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + startX = ev.getX(); + isTwoFingerSwipe = false; + break; + case MotionEvent.ACTION_POINTER_DOWN: + if (ev.getPointerCount() == 2) { + isTwoFingerSwipe = true; + startX = ev.getX(1); + } + break; + case MotionEvent.ACTION_MOVE: + if (isTwoFingerSwipe && ev.getPointerCount() == 2) { + float diffX = ev.getX(1) - startX; + if (Math.abs(diffX) > SWIPE_THRESHOLD) { + // 拦截双指滑动事件 + return true; + } + } + break; + case MotionEvent.ACTION_POINTER_UP: + if (ev.getPointerCount() == 2) { + isTwoFingerSwipe = false; + } + break; + } + return super.onInterceptTouchEvent(ev); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (isTwoFingerSwipe && doubleFingerSwipeListener != null) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_MOVE: + float diffX = event.getX(1) - startX; + if (Math.abs(diffX) > SWIPE_THRESHOLD && !isSwipeActionDone) { + if (diffX > 0) { + // 处理右滑操作 + doubleFingerSwipeListener.onRightSwipe(); + } else { + // 处理左滑操作 + doubleFingerSwipeListener.onLeftSwipe(); + } + isSwipeActionDone = true; + } + return true; // 处理双指滑动事件 + case MotionEvent.ACTION_POINTER_UP: + isTwoFingerSwipe = false; + isSwipeActionDone = false; + break; + } + } else { + // 单指操作传递给子控件 + return super.onTouchEvent(event); + } + return true; + } + + + public void setDoubleFingerSwipeListener(DoubleFingerSwipeListener doubleFingerSwipeListener){ + this.doubleFingerSwipeListener = doubleFingerSwipeListener; + } + + protected void pageReturn(){ + if (returnListener != null){ + returnListener.returnCallBack(); + } + + } + + public void setPageReturnListener(ReturnListener returnListener){ + this.returnListener = returnListener; + } + + public void startAnimator(float startX, float endX, AnimatorListenerAdapter animatorListenerAdapter){ + animator = ObjectAnimator.ofFloat(this, "translationX", startX, endX); + animator.setDuration(300); // 设置动画持续时间为1秒 + animator.setInterpolator(new AccelerateDecelerateInterpolator()); // 设置动画插值器 + animator.addListener(animatorListenerAdapter); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + disableTouch = false; + } + }); + disableTouch = true; + animator.start(); + } + + public void endAnimator(){ + if (animator != null){ + animator.end(); + } + animator = null; + } + + public void setLastPage(SuperPageLayout lastPage){ + this.lastPage = lastPage; + } + + public SuperPageLayout getLastPage() { + return lastPage; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPagesController.java b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPagesController.java new file mode 100644 index 0000000000..e0d9e65c6a --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/superpage/SuperPagesController.java @@ -0,0 +1,165 @@ +package com.limelight.binding.input.advance_setting.superpage; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.limelight.R; + +public class SuperPagesController { + + public enum BoxPosition{ + Right, + Left + } + + private BoxPosition boxPosition = BoxPosition.Right; + private SuperPageLayout.DoubleFingerSwipeListener rightListener; + private SuperPageLayout.DoubleFingerSwipeListener leftListener; + private SuperPageLayout pageNull; + private SuperPageLayout pageNow; + + private FrameLayout superPagesBox; + private Context context; + + private SuperPageLayout openingPage; + private SuperPageLayout closingPage; + + public SuperPagesController(FrameLayout superPagesBox, Context context) { + this.superPagesBox = superPagesBox; + this.context = context; + rightListener = new SuperPageLayout.DoubleFingerSwipeListener() { + @Override + public void onRightSwipe() { + } + + @Override + public void onLeftSwipe() { + setPosition(BoxPosition.Left); + } + }; + leftListener = new SuperPageLayout.DoubleFingerSwipeListener() { + @Override + public void onRightSwipe() { + setPosition(BoxPosition.Right); + } + + @Override + public void onLeftSwipe() { + } + }; + pageNull = (SuperPageLayout) LayoutInflater.from(context).inflate(R.layout.page_null,null); + pageNow = pageNull; + } + + public SuperPageLayout getPageNull(){ + return pageNull; + } + + public SuperPageLayout getPageNow(){return pageNow;} + + + public void setPosition(BoxPosition position){ + + if (openingPage != null){ + openingPage.endAnimator(); + } + if (closingPage != null){ + closingPage.endAnimator(); + } + + float previousPosition = getVisiblePosition(pageNow); + boxPosition = position; + float nextPosition = getVisiblePosition(pageNow); + openingPage = pageNow; + pageNow.startAnimator(previousPosition,nextPosition,new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // 动画结束后将视图设置到最终位置 + pageNow.setX(nextPosition); + SuperPageLayout.DoubleFingerSwipeListener doubleFingerSwipeListener = boxPosition == BoxPosition.Right ? rightListener : leftListener; + pageNow.setDoubleFingerSwipeListener(doubleFingerSwipeListener); + openingPage = null; + } + }); + } + + + + public void openNewPage(SuperPageLayout pageNew){ + if (pageNew == pageNow) return; + + if (closingPage != null){ + closingPage.endAnimator(); + } + if (openingPage != null){ + openingPage.endAnimator(); + } + + closingPage = pageNow; + float closingPagePreviousPosition = getVisiblePosition(closingPage); + float closingPageNextPosition = getHidePosition(closingPage); + closingPage.startAnimator(closingPagePreviousPosition, closingPageNextPosition,new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // 动画结束后将视图设置到最终位置 + closingPage.setX(closingPageNextPosition); + superPagesBox.removeView(closingPage); + + closingPage = null; + } + }); + + openingPage = pageNew; + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(dpToPx(Integer.parseInt(openingPage.getTag().toString())), ViewGroup.LayoutParams.MATCH_PARENT); + layoutParams.topMargin = dpToPx(20); + layoutParams.bottomMargin = dpToPx(20); + superPagesBox.addView(openingPage,layoutParams); + openingPage.setDoubleFingerSwipeListener(boxPosition == BoxPosition.Right ? rightListener : leftListener); + float previousPosition = getHidePosition(openingPage); + float nextPosition = getVisiblePosition(openingPage); + openingPage.setX(previousPosition); + + openingPage.startAnimator(previousPosition,nextPosition,new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // 动画结束后将视图设置到最终位置 + openingPage.setX(nextPosition); + openingPage = null; + } + }); + + + + pageNew.setLastPage(pageNow); + pageNow = pageNew; + } + + public void returnOperation(){ + pageNow.pageReturn(); + } + + + private int getHidePosition(SuperPageLayout page){ + if (boxPosition == BoxPosition.Right){ + return superPagesBox.getWidth(); + } else { + return - dpToPx(Integer.parseInt(page.getTag().toString())); + } + } + + private int getVisiblePosition(SuperPageLayout page){ + if (boxPosition == BoxPosition.Right){ + return superPagesBox.getWidth() - dpToPx(20) - dpToPx(Integer.parseInt(page.getTag().toString())); + } else { + return dpToPx(20); + } + } + + private int dpToPx(int dp){ + return (int) (dp * context.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java index 9067b1c762..027920fcf2 100644 --- a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java +++ b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java @@ -35,4 +35,21 @@ else if (AndroidPointerIconCaptureProvider.isCaptureProviderSupported()) { return new NullCaptureProvider(); } } + + /** + * 获取支持外接显示器的输入捕获提供者 + * 外接显示器模式下,使用更兼容的捕获方式 + */ + public static InputCaptureProvider getInputCaptureProviderForExternalDisplay(Activity activity, EvdevListener rootListener) { + // 外接显示器模式下,优先使用Evdev捕获,因为它对多显示器支持更好 + if (EvdevCaptureProviderShim.isCaptureProviderSupported()) { + LimeLog.info("Using Evdev mouse capture for external display"); + return EvdevCaptureProviderShim.createEvdevCaptureProvider(activity, rootListener); + } + // 如果Evdev不可用,回退到标准方式 + else { + LimeLog.info("Falling back to standard capture provider for external display"); + return getInputCaptureProvider(activity, rootListener); + } + } } diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java index fe31ca679c..9461d1d610 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java @@ -74,4 +74,10 @@ protected void notifyDeviceRemoved() { protected void notifyDeviceAdded() { listener.deviceAdded(this); } + + protected void notifyControllerMotion(byte motionType, float x, float y, float z) { + if (listener != null) { + listener.reportControllerMotion(deviceId, motionType, x, y, z); + } + } } diff --git a/app/src/main/java/com/limelight/binding/input/driver/SwitchProController.java b/app/src/main/java/com/limelight/binding/input/driver/SwitchProController.java new file mode 100644 index 0000000000..33dd993259 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/driver/SwitchProController.java @@ -0,0 +1,219 @@ +package com.limelight.binding.input.driver; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.os.SystemClock; + +import com.limelight.LimeLog; +import com.limelight.nvstream.input.ControllerPacket; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Minimal USB wired Nintendo Switch Pro Controller driver (input only). + * + * Parses standard input report 0x30: buttons and 12-bit stick values. + * Rumble/IMU are not implemented in this version. + */ +public class SwitchProController extends AbstractController { + + private static final int NINTENDO_VID = 0x057e; + private static final int PRO_PID = 0x2009; + + private final UsbDevice device; + private final UsbDeviceConnection connection; + + private UsbEndpoint inEndpt; + private UsbEndpoint outEndpt; + + private Thread inputThread; + private boolean stopped; + + public static boolean canClaimDevice(UsbDevice device) { + if (device.getVendorId() != NINTENDO_VID || device.getProductId() != PRO_PID) { + return false; + } + if (device.getInterfaceCount() < 1) { + return false; + } + UsbInterface iface = device.getInterface(0); + return iface.getInterfaceClass() == UsbConstants.USB_CLASS_HID; + } + + public SwitchProController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + super(deviceId, listener, device.getVendorId(), device.getProductId()); + this.device = device; + this.connection = connection; + + // Supported buttons bitmask (align with Xbox-style flags for host mapping) + this.buttonFlags = + ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | + ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | + ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | + ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | + ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG; + this.supportedButtonFlags = this.buttonFlags; + // No analog triggers on Switch Pro controller + // capabilities left at default (0) for now + } + + @Override + public boolean start() { + // Claim HID interface 0 + UsbInterface iface = device.getInterface(0); + if (!connection.claimInterface(iface, true)) { + LimeLog.warning("Failed to claim HID interface for Switch Pro"); + return false; + } + + // Find endpoints + for (int i = 0; i < iface.getEndpointCount(); i++) { + UsbEndpoint endpt = iface.getEndpoint(i); + if (endpt.getDirection() == UsbConstants.USB_DIR_IN && inEndpt == null) { + inEndpt = endpt; + } + else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT && outEndpt == null) { + outEndpt = endpt; + } + } + + if (inEndpt == null) { + LimeLog.warning("Missing IN endpoint for Switch Pro"); + return false; + } + + // Start reading input + inputThread = new Thread(new Runnable() { + @Override + public void run() { + try { + // allow previous InputDevice to settle + Thread.sleep(500); + } catch (InterruptedException ignored) {} + + notifyDeviceAdded(); + + byte[] buffer = new byte[64]; + while (!Thread.currentThread().isInterrupted() && !stopped) { + long startMs = SystemClock.uptimeMillis(); + int res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000); + if (res == 0) { + res = -1; + } + if (res == -1 && SystemClock.uptimeMillis() - startMs < 1000) { + LimeLog.warning("Switch Pro I/O error; stopping"); + stop(); + break; + } + if (res <= 0 || stopped) { + continue; + } + + if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) { + reportInput(); + } + } + } + }); + inputThread.start(); + return true; + } + + @Override + public void stop() { + if (stopped) { + return; + } + stopped = true; + if (inputThread != null) { + inputThread.interrupt(); + inputThread = null; + } + try { + connection.close(); + } catch (Exception ignored) {} + notifyDeviceRemoved(); + } + + @Override + public void rumble(short lowFreqMotor, short highFreqMotor) { + // Not implemented for now + } + + @Override + public void rumbleTriggers(short leftTrigger, short rightTrigger) { + // Not present on Switch Pro + } + + private boolean handleRead(ByteBuffer buf) { + if (buf.remaining() < 12) { + return false; + } + int reportId = buf.get(0) & 0xFF; + if (reportId != 0x30) { + // Only handle standard input report for now + return false; + } + + // buttons + int b3 = buf.get(3) & 0xFF; + int b4 = buf.get(4) & 0xFF; + int b5 = buf.get(5) & 0xFF; + + // ABXY (bits on = pressed) + setButtonFlag(ControllerPacket.A_FLAG, b3 & (1 << 3)); + setButtonFlag(ControllerPacket.B_FLAG, b3 & (1 << 2)); + setButtonFlag(ControllerPacket.X_FLAG, b3 & (1 << 1)); + setButtonFlag(ControllerPacket.Y_FLAG, b3 & (1 << 0)); + + // Shoulder buttons + setButtonFlag(ControllerPacket.RB_FLAG, b3 & (1 << 6)); // R + setButtonFlag(ControllerPacket.LB_FLAG, b5 & (1 << 6)); // L + + // Stick clicks + setButtonFlag(ControllerPacket.RS_CLK_FLAG, b4 & (1 << 2)); + setButtonFlag(ControllerPacket.LS_CLK_FLAG, b4 & (1 << 3)); + + // Start/Back (Plus/Minus) + setButtonFlag(ControllerPacket.PLAY_FLAG, b4 & (1 << 1)); // Plus + setButtonFlag(ControllerPacket.BACK_FLAG, b4 & (1 << 0)); // Minus + + // Special/Home + setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b4 & (1 << 4)); // Home + + // D-pad + setButtonFlag(ControllerPacket.DOWN_FLAG, b5 & (1 << 0)); + setButtonFlag(ControllerPacket.UP_FLAG, b5 & (1 << 1)); + setButtonFlag(ControllerPacket.RIGHT_FLAG, b5 & (1 << 2)); + setButtonFlag(ControllerPacket.LEFT_FLAG, b5 & (1 << 3)); + + // Triggers (digital ZL/ZR -> 0/1) + leftTrigger = ((b5 & (1 << 7)) != 0) ? 1.0f : 0.0f; // ZL + rightTrigger = ((b3 & (1 << 7)) != 0) ? 1.0f : 0.0f; // ZR + + // Sticks: 12-bit per axis packed + int lx = (buf.get(6) & 0xFF) | ((buf.get(7) & 0x0F) << 8); + int ly = ((buf.get(7) & 0xF0) >> 4) | ((buf.get(8) & 0xFF) << 4); + int rx = (buf.get(9) & 0xFF) | ((buf.get(10) & 0x0F) << 8); + int ry = ((buf.get(10) & 0xF0) >> 4) | ((buf.get(11) & 0xFF) << 4); + + // Normalize 0..4095 -> -1..1, invert Y to match expected up-positive + leftStickX = normalizeStick(lx); + leftStickY = -normalizeStick(ly); + rightStickX = normalizeStick(rx); + rightStickY = -normalizeStick(ry); + + return true; + } + + private static float normalizeStick(int v12) { + // center 2048, range ~2047 + return Math.max(-1f, Math.min(1f, (v12 - 2048) / 2047.0f)); + } +} + + diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java index c812122299..1cc0344792 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java @@ -8,4 +8,7 @@ void reportControllerState(int controllerId, int buttonFlags, void deviceRemoved(AbstractController controller); void deviceAdded(AbstractController controller); + + // Report motion data sourced from the USB controller itself + void reportControllerMotion(int controllerId, byte motionType, float x, float y, float z); } diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java index f81221a548..1ef4e7c411 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java @@ -51,6 +51,13 @@ public void reportControllerState(int controllerId, int buttonFlags, float leftS } } + @Override + public void reportControllerMotion(int controllerId, byte motionType, float x, float y, float z) { + if (listener != null) { + listener.reportControllerMotion(controllerId, motionType, x, y, z); + } + } + @Override public void deviceRemoved(AbstractController controller) { // Remove the the controller from our list (if not removed already) @@ -194,6 +201,9 @@ else if (Xbox360Controller.canClaimDevice(device)) { else if (Xbox360WirelessDongle.canClaimDevice(device)) { controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this); } + else if (SwitchProController.canClaimDevice(device)) { + controller = new SwitchProController(device, connection, nextDeviceId++, this); + } else { // Unreachable return; @@ -278,7 +288,9 @@ public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvaila return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) || ((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) || // We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle - ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device)); + ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device)) || + // Switch Pro: 只在 claimAllAvailable 或系统未识别为输入设备时接管,避免冲突 + ((!isRecognizedInputDevice(device) || claimAllAvailable) && SwitchProController.canClaimDevice(device)); } @SuppressLint("UnspecifiedRegisterReceiverFlag") diff --git a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java index b84f2cc749..ed0a6c2dfc 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java @@ -25,10 +25,12 @@ public class XboxOneController extends AbstractXboxController { 0x20d6, // PowerA 0x24c6, // PowerA 0x2e24, // Hyperkin + 0x2dc8, // 8BitDo }; private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00}; private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06}; + private static final byte[] SERIES_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06}; private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a, 0x00, 0x00, 0x00, (byte)0x80, 0x00}; private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14}; @@ -44,6 +46,9 @@ public class XboxOneController extends AbstractXboxController { new InitPacket(0x0000, 0x0000, FW2015_INIT), new InitPacket(0x045e, 0x02ea, ONE_S_INIT), new InitPacket(0x045e, 0x0b00, ONE_S_INIT), + new InitPacket(0x045e, 0x0b05, SERIES_S_INIT), + new InitPacket(0x045e, 0x0b12, SERIES_S_INIT), + new InitPacket(0x045e, 0x0b13, SERIES_S_INIT), new InitPacket(0x0e6f, 0x0000, PDP_INIT1), new InitPacket(0x0e6f, 0x0000, PDP_INIT2), new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1), @@ -223,4 +228,4 @@ private static class InitPacket { this.data = data; } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java index d5fb4708b1..7c2e8a281c 100644 --- a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java +++ b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java @@ -3,7 +3,6 @@ import android.os.Handler; import android.os.Looper; import android.view.View; - import com.limelight.nvstream.NvConnection; import com.limelight.nvstream.input.MouseButtonPacket; @@ -195,13 +194,11 @@ public boolean touchMoveEvent(int eventX, int eventY, long eventTime) if (cancelled) { return true; } - if (actionIndex == 0) { if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) { // Moved too far since touch down. Cancel the long press timer. cancelLongPressTimer(); } - // Ignore motion within the deadzone period after touch down if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) { tapConfirmed(); diff --git a/app/src/main/java/com/limelight/binding/input/touch/LocalCursorRenderer.java b/app/src/main/java/com/limelight/binding/input/touch/LocalCursorRenderer.java new file mode 100644 index 0000000000..85b65805a3 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/touch/LocalCursorRenderer.java @@ -0,0 +1,82 @@ +package com.limelight.binding.input.touch; + +import android.os.Handler; +import android.os.Looper; +import com.limelight.ui.CursorView; + +public class LocalCursorRenderer { + + private CursorView cursorView; + private int viewWidth = 1; + private int viewHeight = 1; + + // 本地光标位置 + private float cursorX = 0; + private float cursorY = 0; + + private final Handler uiHandler = new Handler(Looper.getMainLooper()); + + public LocalCursorRenderer(CursorView cursorView, int viewWidth, int viewHeight) { + this.cursorView = cursorView; + this.viewWidth = Math.max(1, viewWidth); + this.viewHeight = Math.max(1, viewHeight); + + // 初始化位置在中心 + this.cursorX = this.viewWidth / 2.0f; + this.cursorY = this.viewHeight / 2.0f; + + // 立即同步初始位置给 View,否则 View 会绘制在屏幕外 + uiHandler.post(() -> { + if (this.cursorView != null) { + this.cursorView.updateCursorPosition(cursorX, cursorY); + } + }); + } + + public void updateCursorPosition(float deltaX, float deltaY) { + // 更新逻辑坐标 + this.cursorX = Math.max(0, Math.min(cursorX + deltaX, viewWidth - 1)); + this.cursorY = Math.max(0, Math.min(cursorY + deltaY, viewHeight - 1)); + + // 在 UI 线程更新 View + uiHandler.post(() -> { + if (cursorView != null) { + cursorView.updateCursorPosition(cursorX, cursorY); + } + }); + } + + public void setViewDimensions(int width, int height) { + this.viewWidth = Math.max(1, width); + this.viewHeight = Math.max(1, height); + // 确保坐标不越界 + this.cursorX = Math.min(cursorX, viewWidth - 1); + this.cursorY = Math.min(cursorY, viewHeight - 1); + } + + public void show() { + uiHandler.post(() -> { + if (cursorView != null) { + cursorView.show(); + // 显示时强制更新一次位置,确保立刻可见 + cursorView.updateCursorPosition(cursorX, cursorY); + } + }); + } + + public void hide() { + uiHandler.post(() -> { + if (cursorView != null) cursorView.hide(); + }); + } + + public void destroy() { + hide(); + cursorView = null; + } + + // Getter methods required by context + public float[] getCursorAbsolutePosition() { + return new float[]{cursorX, cursorY}; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/touch/NativeTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/NativeTouchContext.java new file mode 100644 index 0000000000..9681b9c578 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/touch/NativeTouchContext.java @@ -0,0 +1,325 @@ +package com.limelight.binding.input.touch; + +import android.view.MotionEvent; +import android.util.Log; +import android.content.res.Resources; +import android.util.DisplayMetrics; + +import java.util.ArrayList; +import java.util.Iterator; + +class ScreenUtils { + public static float getScreenWidth() { + DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); + return displayMetrics.widthPixels; + } + + public static float getScreenHeight() { + DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); + return displayMetrics.heightPixels; + } +} + + +/** + * Pointer oriented class + * + * + * to store additional pointer info updated from Android MotionEvent object + * (stored in NativeTouchContext.Pointer instance). + * Provides some methods to manipulate pointer coordinates (enhanced touch) before sending to Sunshine server, + */ +public class NativeTouchContext { + /** + * Defines a (2*INTIAL_ZONE_PIXELS)^2 square flat region for long press jitter elimination. + * Config read from prefConfig in Game class + */ + public static float INTIAL_ZONE_PIXELS = 0f; + + /** + * Set true to send relative(manipulated) coords to Sunshine server. + * Config read from prefConfig in Game class + */ + public static boolean ENABLE_ENHANCED_TOUCH = true; + + /** + * 1 means enhanced-touch zone on the right side, -1 on the left side. + * Config read from prefConfig in Game class + */ + public static int ENHANCED_TOUCH_ON_RIGHT = 1; + + /** + * Defines where to divide native-touch & enchanced-touch zones, + * < 0.5f means divide from a point on the left, >0.5f means right. + * Config read from prefConfig in Game class + */ + public static float ENHANCED_TOUCH_ZONE_DIVIDER = 0.5f; + + /** + * Factor to scale pointer velocity within enhanced touch zone, + * Config read from prefConfig in Game class + */ + public static float POINTER_VELOCITY_FACTOR = 1.0f; + + /** + * Fixed horizontal velocity (in pixels) within enhanced touch zone + * Config read from prefConfig in Game class + */ + // public static float POINTER_FIXED_X_VELOCITY = 8f; + + /** + * Object to store, update info & manipulate coordinates for each pointer. + * An ArrayList of NativeTouchContext.Pointer instances is created in Game Class for all active pointers. + */ + public static class Pointer{ + /** + * poinerId, not pointerIndex. + * Use pointerId because it's consistent during the whole pointer lifecycle. + */ + private int pointerId; + + /** + * Use MotionEvent.PointerCoords from Android SDK to store coordinates. + */ + private MotionEvent.PointerCoords initialCoords = new MotionEvent.PointerCoords(); //First contact coords of a pointer. + private MotionEvent.PointerCoords latestCoords = new MotionEvent.PointerCoords(); //Latest coords of a pointer updated from MotionEvent provided by Android System. + private MotionEvent.PointerCoords previousCoords = new MotionEvent.PointerCoords(); // previous Coords of a pointer. will be updated in updatePointerCoords(). + private MotionEvent.PointerCoords latestRelativeCoords = new MotionEvent.PointerCoords(); // Coords to replace native ones from Android MotionEvents, for manipulating touch control. + private MotionEvent.PointerCoords previousRelativeCoords = new MotionEvent.PointerCoords(); // Coords to replace native ones from Android MotionEvents, for manipulating touch control. + + /** + * DeltaX & DeltaY between to 2 onTouch() callbacks. + */ + private float velocityX; + private float velocityY; + + + /** + * Flipped to true when pointer moves out of (2*INTIAL_ZONE_PIXELS)^2 square flat region. + */ + private boolean pointerLeftInitialZone = false; + + /** + * Constructor for NativeTouchContext.Pointer. + * Pointer class instantiated in ACTION_DOWN, ACTION_POINTER_DOWN Condition. + */ + public Pointer(MotionEvent event) { + int pointerIndex = event.getActionIndex(); //get the pointerIndex triggers DOWN event. + this.pointerId = event.getPointerId(pointerIndex); // get pointerId + event.getPointerCoords(pointerIndex, this.initialCoords);// Fill in initial coords. + event.getPointerCoords(pointerIndex, this.latestCoords);// Fill in latest coords. + //if(POINTER_VELOCITY_FACTOR != 1.0f){ + this.latestRelativeCoords.x = this.latestCoords.x; + this.latestRelativeCoords.y = this.latestCoords.y; + } + public int getPointerId(){ + return this.pointerId; + } + + /** + * Update native coordinates, relative coordinates & velocity for Pointer instance + */ + public void updatePointerCoords(MotionEvent event, int pointerIndex){ + this.previousCoords.x = this.latestCoords.x; // assign x, y coords to this.previousCoords only. Other attributes can be ignored. + this.previousCoords.y = this.latestCoords.y; + event.getPointerCoords(pointerIndex, this.latestCoords); // update latestCoords from MotionEvent. + + if(POINTER_VELOCITY_FACTOR == 1.0f) { + this.latestRelativeCoords.x = this.latestCoords.x; + this.latestRelativeCoords.y = this.latestCoords.y; + } + else this.updateRelativeCoords(); + + // 固定X速率模式,该模式下仍可用POINTER_VELOCITY_FACTOR调整Y的速率 + // if(POINTER_FIXED_X_VELOCITY != 0f) this.updateRelativeCoordsFixedXVelocity(); + + if(INTIAL_ZONE_PIXELS > 0f) this.flattenLongPressJitter(); + // Log.d("INTIAL_ZONE_PIXELS", ""+INTIAL_ZONE_PIXELS); + } + + /** + * Update relative coordinates with velocity scaled by POINTER_VELOCITY_FACTOR + */ + private void updateRelativeCoords(){ + this.velocityX = this.latestCoords.x - this.previousCoords.x; + this.velocityY = this.latestCoords.y - this.previousCoords.y; + this.previousRelativeCoords.x = this.latestRelativeCoords.x; + this.previousRelativeCoords.y = this.latestRelativeCoords.y; + this.latestRelativeCoords.x = this.previousRelativeCoords.x + this.velocityX * POINTER_VELOCITY_FACTOR; + this.latestRelativeCoords.y = this.previousRelativeCoords.y + this.velocityY * POINTER_VELOCITY_FACTOR; + } + + /** + * Update relative coordinates with fixed X velocity + */ + /* private void updateRelativeCoordsFixedXVelocity(){ + this.velocityX = this.latestCoords.x - this.previousCoords.x; + this.velocityY = this.latestCoords.y - this.previousCoords.y; + this.previousRelativeCoords.x = this.latestRelativeCoords.x; + this.previousRelativeCoords.y = this.latestRelativeCoords.y; + this.latestRelativeCoords.x = this.previousRelativeCoords.x + Math.signum(this.velocityX) * POINTER_FIXED_X_VELOCITY; + this.latestRelativeCoords.y = this.previousRelativeCoords.y + this.velocityY * POINTER_VELOCITY_FACTOR; + } */ + + /** + * Judge whether this pointer leaves (2*INTIAL_ZONE_PIXELS)^2 square flat region + */ + private void checkIfPointerLeaveInitialZone() { + if (!this.pointerLeftInitialZone) { + if (Math.abs(this.latestCoords.x - this.initialCoords.x) > INTIAL_ZONE_PIXELS || Math.abs(this.latestCoords.y - this.initialCoords.y) > INTIAL_ZONE_PIXELS) { + this.pointerLeftInitialZone = true; //Flips pointerLeftInitialZone to true when pointer moves out of flat region. + } + } + } + + /** + * Resets latest coords (both native & relative) to initial coords if pointer doesn't leave flat region. + */ + private void flattenLongPressJitter(){ + this.checkIfPointerLeaveInitialZone(); + // Log.d("INTIAL_ZONE_PIXELS", ""+INTIAL_ZONE_PIXELS); + if(!this.pointerLeftInitialZone){ + this.latestCoords.x = this.initialCoords.x; + this.latestCoords.y = this.initialCoords.y; + this.latestRelativeCoords.x = this.initialCoords.x; + this.latestRelativeCoords.y = this.initialCoords.y; + // Log.d("pointerLeftInitialZone", ""+pointerLeftInitialZone); + } + } + + /** + * Judge whether pointer's coords should be manipulated based on its initial coords (first contact location) + * Only Supports horizontal split (left and right) for now. + */ + private boolean withinEnhancedTouchZone () + { + // float[] normalizedCoords = new float[] {pointerIntialCoords[0]/ScreenUtils.getScreenWidth(), pointerIntialCoords[1]/ScreenUtils.getScreenHeight()}; + float normalizedX = this.initialCoords.x/ScreenUtils.getScreenWidth(); + return normalizedX * ENHANCED_TOUCH_ON_RIGHT > ENHANCED_TOUCH_ZONE_DIVIDER * ENHANCED_TOUCH_ON_RIGHT; + } + + public float[] XYCoordSelector(){ + //to judge whether pointer located on enhanced touch zone by its initial coords. + if (this.withinEnhancedTouchZone()) return new float[] {this.latestRelativeCoords.x,this.latestRelativeCoords.y}; + else return new float[] {this.latestCoords.x,this.latestCoords.y}; + } + + public float getInitialX(){ + return this.initialCoords.x; + } + + public float getPointerNormalizedInitialX(){ + return this.initialCoords.x / ScreenUtils.getScreenWidth(); + } + + public float getInitialY(){ + return this.initialCoords.y; + } + + public float getPointerNormalizedInitialY(){ + return this.initialCoords.y / ScreenUtils.getScreenHeight(); + } + + public float getLatestX(){ + return this.latestCoords.x; + } + + + public float getLatestY(){ + return this.latestCoords.y; + } + public float getLatestRelativeX(){ + return this.latestRelativeCoords.x; + } + + public float getLatestRelativeY(){ + return this.latestRelativeCoords.y; + } + + + public float getPointerNormalizedLatestX(){ + return this.latestCoords.x / ScreenUtils.getScreenWidth(); + } + + public float getPointerNormalizedLatestY(){ + return this.latestCoords.y / ScreenUtils.getScreenHeight(); + } + + public void printPointerInitialCoords(){ + Log.d("Initial Coords","Pointer " + this.pointerId + " Coords: X " + this.getInitialX() + " Y " +this.getInitialY()); + } + public void printPointerLatestCoords(){ + Log.d("Latest Coords","Pointer " + this.pointerId + " Coords: X " + this.getLatestX() + " Y " +this.getLatestY()); + } + public void printPointerCoordSnapshot(){ + Log.d("Pointer " + this.pointerId, " InitialCoords:" + "[" + this.getInitialX() + ", " + this.getInitialY() + "]" + " LatestCoords:" + "[" + this.getLatestX() + ", " + this.getLatestY() + "]"); + } + + + } + + + + + /** + * The Game class defines an ArrayList for all instances all active pointers. + * While iterating pointer info in Game class with pointerIndex in ACTION_MOVE condition, + * this method is called to access additional pointer info from the list by finding a pointerId match, + * and decides whether the pointer's coords should be manipulated. + */ + /* public static float[] selectCoordsForPointer(MotionEvent event, int pointerIndex, ArrayList nativeTouchPointers){ + float selectedX = 0f; + float selectedY = 0f; + for (NativeTouchContext.Pointer pointer : nativeTouchPointers) { + if (event.getPointerId(pointerIndex) == pointer.getPointerId()) { + if(ENABLE_ENHANCED_TOUCH) { + //to judge whether pointer located on enhanced touch zone by its initial coords. + if (isEnhancedTouchZone(new float[] {pointer.getInitialX(), pointer.getInitialY()})) { + selectedX = pointer.getLatestRelativeX(); + selectedY = pointer.getLatestRelativeY(); + } + else{ + selectedX = pointer.getLatestX(); + selectedY = pointer.getLatestY(); + } + } + else{ + selectedX = pointer.getLatestX(); + selectedY = pointer.getLatestY(); + } + break; + } + } + return new float[] {selectedX, selectedY}; + } */ + + + /** + * Safely remove Pointer instance from a List in ACTION_POINTER_UP or ACTION_UP condition + */ + /* public static void safelyRemovePointerFromList(MotionEvent event, ArrayList nativeTouchPointers){ + Iterator iterator = nativeTouchPointers.iterator(); //safely remove pointer handler by iterator. + while (iterator.hasNext()){ + NativeTouchContext.Pointer pointer = iterator.next(); + if (event.getPointerId(event.getActionIndex()) == pointer.getPointerId()) { + iterator.remove(); // immediately break when we get a pointerId match + break; + } + } + } */ + + /** + * Update 1 specific Pointer instance in a List in ACTION_MOVE. + */ + /* public static void updatePointerInList(MotionEvent event,int pointerIndex, ArrayList nativeTouchPointers) { + for (NativeTouchContext.Pointer pointer : nativeTouchPointers) { + if (pointer.getPointerId() == event.getPointerId(pointerIndex)) { + pointer.updatePointerCoords(event, pointerIndex); + // handler.doesPointerLeaveInitialZone(); + // Log.d("NativeTouchCoordHandler", "Pointer Left Initial Zone: " + handler.doesPointerLeaveInitialZone()); + // pointer.printPointerCoordSnapshot(); + break; // immediately break when we get a pointerId match (this method update 1 pointer in the list) + } + } + } */ +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java index 7ed8f09674..fba89736a6 100644 --- a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java +++ b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java @@ -3,10 +3,13 @@ import android.os.Handler; import android.os.Looper; import android.view.View; +import android.view.SurfaceHolder; +import com.limelight.Game; import com.limelight.nvstream.NvConnection; import com.limelight.nvstream.input.MouseButtonPacket; import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.CursorView; public class RelativeTouchContext implements TouchContext { private int lastTouchX = 0; @@ -19,18 +22,40 @@ public class RelativeTouchContext implements TouchContext { private boolean confirmedDrag; private boolean confirmedScroll; private double distanceMoved; - private double xFactor, yFactor; + private double xFactor = 0.6; + private double yFactor = 0.6; + private double sense = 1; private int pointerCount; private int maxPointerCountInGesture; + private long lastTapUpTime = 0; + /** 记录上一次成功单击的结束位置X */ + private int lastTapUpX = 0; + /** 记录上一次成功单击的结束位置Y */ + private int lastTapUpY = 0; + /** 标志位,表示当前是否处于“双击并按住”触发的拖拽模式 */ + private boolean isDoubleClickDrag = false; + /** 标志位,表示当前手势可能是双击的第二次点击,处于“待定”状态 */ + private boolean isPotentialDoubleClick = false; + private final NvConnection conn; private final int actionIndex; - private final int referenceWidth; - private final int referenceHeight; private final View targetView; private final PreferenceConfiguration prefConfig; private final Handler handler; + private final Runnable[] buttonUpRunnables; + + // 用于延迟发送单击事件的Runnable + private Runnable singleTapRunnable; + // 用于处理“双击并按住”的计时器 + private Runnable doubleTapHoldRunnable; + + // 本地光标渲染器 - 用于显示虚拟鼠标光标 + private LocalCursorRenderer localCursorRenderer; + // 是否启用本地光标渲染 + private boolean enableLocalCursorRendering = true; + private final Runnable dragTimerRunnable = new Runnable() { @Override public void run() { @@ -50,72 +75,81 @@ public void run() { } }; - // Indexed by MouseButtonPacket.BUTTON_XXX - 1 - private final Runnable[] buttonUpRunnables = new Runnable[] { - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); - } - } - }; - - private static final int TAP_MOVEMENT_THRESHOLD = 20; - private static final int TAP_DISTANCE_THRESHOLD = 25; + private static final int TAP_MOVEMENT_THRESHOLD = 40; + private static final int TAP_DISTANCE_THRESHOLD = 50; private static final int TAP_TIME_THRESHOLD = 250; private static final int DRAG_TIME_THRESHOLD = 650; + private static final int DRAG_START_THRESHOLD = 10; + // 定义2次点击的间隔小于多久才为双击按住 + private static final int DOUBLE_TAP_TIME_THRESHOLD = 100; + // 定义双击后按住多久确认为拖拽 + private static final int DOUBLE_TAP_HOLD_TO_DRAG_THRESHOLD = 200; + /** 定义双击时,两次点击位置的最大允许偏差 */ + private static final int DOUBLE_TAP_MOVEMENT_THRESHOLD = 40; private static final int SCROLL_SPEED_FACTOR = 5; public RelativeTouchContext(NvConnection conn, int actionIndex, - int referenceWidth, int referenceHeight, View view, PreferenceConfiguration prefConfig) { this.conn = conn; this.actionIndex = actionIndex; - this.referenceWidth = referenceWidth; - this.referenceHeight = referenceHeight; this.targetView = view; this.prefConfig = prefConfig; this.handler = new Handler(Looper.getMainLooper()); + this.buttonUpRunnables = new Runnable[] { + () -> conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT), + () -> conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE), + () -> conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT), + () -> conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1), + () -> conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2) + }; + } - @Override - public int getActionIndex() - { - return actionIndex; + /** + * 初始化本地光标渲染器 + */ + public void initializeLocalCursorRenderer(CursorView cursorOverlay, int width, int height) { + if (localCursorRenderer != null) { + localCursorRenderer.destroy(); + } + localCursorRenderer = new LocalCursorRenderer(cursorOverlay, width, height); } + /** + * 销毁本地光标渲染器 + */ + public void destroyLocalCursorRenderer() { + if (localCursorRenderer != null) { + localCursorRenderer.hide(); + localCursorRenderer.destroy(); + localCursorRenderer = null; + } + } + + /** + * 设置是否启用本地光标渲染 + */ + public void setEnableLocalCursorRendering(boolean enable) { + this.enableLocalCursorRendering = enable; + if (localCursorRenderer != null) { + if (enable) { + localCursorRenderer.show(); + } else { + localCursorRenderer.hide(); + } + } + } + + @Override + public int getActionIndex() { return actionIndex; } + private boolean isWithinTapBounds(int touchX, int touchY) { int xDelta = Math.abs(touchX - originalTouchX); int yDelta = Math.abs(touchY - originalTouchY); - return xDelta <= TAP_MOVEMENT_THRESHOLD && - yDelta <= TAP_MOVEMENT_THRESHOLD; + return xDelta <= TAP_MOVEMENT_THRESHOLD && yDelta <= TAP_MOVEMENT_THRESHOLD; } private boolean isTap(long eventTime) @@ -135,32 +169,52 @@ private boolean isTap(long eventTime) return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD; } - private byte getMouseButtonIndex() - { - if (actionIndex == 1) { - return MouseButtonPacket.BUTTON_RIGHT; - } - else { - return MouseButtonPacket.BUTTON_LEFT; - } + private byte getMouseButtonIndex() { + return (actionIndex == 1) ? MouseButtonPacket.BUTTON_RIGHT : MouseButtonPacket.BUTTON_LEFT; } @Override public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) { // Get the view dimensions to scale inputs on this touch - xFactor = referenceWidth / (double)targetView.getWidth(); - yFactor = referenceHeight / (double)targetView.getHeight(); + xFactor = Game.REFERENCE_HORIZ_RES / (double)targetView.getWidth() * sense; + yFactor = Game.REFERENCE_VERT_RES / (double)targetView.getHeight() * sense; originalTouchX = lastTouchX = eventX; originalTouchY = lastTouchY = eventY; if (isNewFinger) { + // 新手势开始时,取消可能存在的延迟单击任务 + cancelSingleTapTimer(); + // 新手势开始,取消任何可能存在的按住计时器 + cancelDoubleTapHoldTimer(); + maxPointerCountInGesture = pointerCount; originalTouchTime = eventTime; - cancelled = confirmedDrag = confirmedMove = confirmedScroll = false; + cancelled = confirmedDrag = confirmedMove = confirmedScroll = isDoubleClickDrag = false; distanceMoved = 0; + isPotentialDoubleClick = false; // 重置双击待定状态 + + if (prefConfig.enableDoubleClickDrag) { + long timeSinceLastTap = eventTime - lastTapUpTime; + int xDelta = Math.abs(eventX - lastTapUpX); + int yDelta = Math.abs(eventY - lastTapUpY); + + if (actionIndex == 0 && timeSinceLastTap <= DOUBLE_TAP_TIME_THRESHOLD && + xDelta <= DOUBLE_TAP_MOVEMENT_THRESHOLD && yDelta <= DOUBLE_TAP_MOVEMENT_THRESHOLD) { + + // 符合双击条件,取消第一次单击的发送,进入“待定”状态 + cancelSingleTapTimer(); // 关键:阻止第一次单击事件发送 + isPotentialDoubleClick = true; + cancelDragTimer(); + + // 启动“按住确认拖拽”计时器 + startDoubleTapHoldTimer(); + return true; + } + } + if (actionIndex == 0) { // Start the timer for engaging a drag startDragTimer(); @@ -177,64 +231,76 @@ public void touchUpEvent(int eventX, int eventY, long eventTime) return; } - // Cancel the drag timer - cancelDragTimer(); + // 决策点1:如果在“待定”状态下抬起,说明用户意图是“双击” + if (isPotentialDoubleClick) { + // 用户抬起了,说明是双击,取消“按住确认拖拽”计时器 + cancelDoubleTapHoldTimer(); - byte buttonIndex = getMouseButtonIndex(); + isPotentialDoubleClick = false; - if (confirmedDrag) { - // Raise the button after a drag - conn.sendMouseButtonUp(buttonIndex); - } - else if (isTap(eventTime)) - { - // Lower the mouse button + // 立即发送一次完整的点击 (模拟第一次点击) + byte buttonIndex = MouseButtonPacket.BUTTON_LEFT; conn.sendMouseButtonDown(buttonIndex); + conn.sendMouseButtonUp(buttonIndex); - // Release the mouse button in 100ms to allow for apps that use polling - // to detect mouse button presses. + // 紧接着发送第二次点击 + conn.sendMouseButtonDown(buttonIndex); Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1]; handler.removeCallbacks(buttonUpRunnable); handler.postDelayed(buttonUpRunnable, 100); - } - } - - private void startDragTimer() { - cancelDragTimer(); - handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); - } - - private void cancelDragTimer() { - handler.removeCallbacks(dragTimerRunnable); - } - private void checkForConfirmedMove(int eventX, int eventY) { - // If we've already confirmed something, get out now - if (confirmedMove || confirmedDrag) { + // Invalidate the tap time to prevent a triple-tap from becoming a double-tap drag + lastTapUpTime = 0; return; } - // If it leaves the tap bounds before the drag time expires, it's a move. - if (!isWithinTapBounds(eventX, eventY)) { - confirmedMove = true; - cancelDragTimer(); + if (isDoubleClickDrag) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + isDoubleClickDrag = false; + lastTapUpTime = 0; return; } - // Check if we've exceeded the maximum distance moved - distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2)); - if (distanceMoved >= TAP_DISTANCE_THRESHOLD) { - confirmedMove = true; - cancelDragTimer(); - return; - } - } + cancelDragTimer(); - private void checkForConfirmedScroll() { - // Enter scrolling mode if we've already left the tap zone - // and we have 2 fingers on screen. Leave scroll mode if - // we no longer have 2 fingers on screen - confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove); + byte buttonIndex = getMouseButtonIndex(); + + if (confirmedDrag) { + conn.sendMouseButtonUp(buttonIndex); + } + else if (isTap(eventTime)) + { + // 只有在双击拖拽功能开启时,才需要延迟单击以判断是否为双击 + if (prefConfig.enableDoubleClickDrag && buttonIndex == MouseButtonPacket.BUTTON_LEFT) { + // 记录时间和位置,用于下一次的touchDown判断 + lastTapUpTime = eventTime; + lastTapUpX = eventX; + lastTapUpY = eventY; + + // 创建一个“单击”任务,并延迟执行 + singleTapRunnable = () -> { + conn.sendMouseButtonDown(buttonIndex); + Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1]; + handler.postDelayed(buttonUpRunnable, 100); + singleTapRunnable = null; // 执行后清空 + }; + handler.postDelayed(singleTapRunnable, DOUBLE_TAP_TIME_THRESHOLD); + } else { + // 如果功能关闭,或者不是左键单击(如右键),则立即发送,不延迟 + lastTapUpTime = 0; // 清除非左键单击的记录 + + conn.sendMouseButtonDown(buttonIndex); + + // Release the mouse button in 100ms to allow for apps that use polling + // to detect mouse button presses. + Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1]; + handler.removeCallbacks(buttonUpRunnable); + handler.postDelayed(buttonUpRunnable, 100); + } + } else { + // 无效点击,重置 + lastTapUpTime = 0; + } } @Override @@ -244,34 +310,55 @@ public boolean touchMoveEvent(int eventX, int eventY, long eventTime) return true; } - if (eventX != lastTouchX || eventY != lastTouchY) - { + // 决策点2:如果在“待定”状态下移动,说明用户意图是“双击拖拽” + if (isPotentialDoubleClick) { + int xDelta = Math.abs(eventX - originalTouchX); + int yDelta = Math.abs(eventY - originalTouchY); + if (xDelta > DRAG_START_THRESHOLD || yDelta > DRAG_START_THRESHOLD) { + // 用户移动了,说明是拖拽,取消“按住确认拖拽”计时器 + cancelDoubleTapHoldTimer(); + // 确认是双击拖拽,此时才发送鼠标按下事件 + isPotentialDoubleClick = false; + isDoubleClickDrag = true; + confirmedMove = true; // 标记为已移动,避免后续逻辑冲突 + + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + } + + // 如果发生移动,说明不是单击,取消待处理的单击任务 + if (!isWithinTapBounds(eventX, eventY)) { + cancelSingleTapTimer(); + } + + if (eventX != lastTouchX || eventY != lastTouchY) { checkForConfirmedMove(eventX, eventY); checkForConfirmedScroll(); - // We only send moves and drags for the primary touch point if (actionIndex == 0) { int deltaX = eventX - lastTouchX; int deltaY = eventY - lastTouchY; - - // Scale the deltas based on the factors passed to our constructor - deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); - deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); - - // Fix up the signs - if (eventX < lastTouchX) { - deltaX = -deltaX; - } - if (eventY < lastTouchY) { - deltaY = -deltaY; - } + deltaX = (int) Math.round(Math.abs(deltaX) * xFactor * (eventX < lastTouchX ? -1 : 1)); + deltaY = (int) Math.round(Math.abs(deltaY) * yFactor * (eventY < lastTouchY ? -1 : 1)); if (pointerCount == 2) { if (confirmedScroll) { conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR)); } - } else { - if (prefConfig.absoluteMouseMode) { + } else if (confirmedMove || isDoubleClickDrag || confirmedDrag) { + + if (localCursorRenderer != null) { + // 1. 本地模式:更新本地光标 + localCursorRenderer.updateCursorPosition(deltaX, deltaY); + // 2. 获取绝对坐标并发送给服务器 (保持同步) + float[] absPos = localCursorRenderer.getCursorAbsolutePosition(); + conn.sendMousePosition( + (short) absPos[0], + (short) absPos[1], + (short) targetView.getWidth(), + (short) targetView.getHeight()); + } else if (prefConfig.absoluteMouseMode) { + // 3. 旧版绝对模式 conn.sendMouseMoveAsMousePosition( (short) deltaX, (short) deltaY, @@ -306,15 +393,81 @@ public boolean touchMoveEvent(int eventX, int eventY, long eventTime) public void cancelTouch() { cancelled = true; - // Cancel the drag timer cancelDragTimer(); + // 取消手势时,清除待处理的单击任务 + cancelSingleTapTimer(); + // 取消手势时,也要清理这个新计时器 + cancelDoubleTapHoldTimer(); + + if (isDoubleClickDrag) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + isDoubleClickDrag = false; + } - // If it was a confirmed drag, we'll need to raise the button now if (confirmedDrag) { conn.sendMouseButtonUp(getMouseButtonIndex()); } + + lastTapUpTime = 0; + isPotentialDoubleClick = false; + } + + // 启动“按住确认拖拽”计时器的方法 + private void startDoubleTapHoldTimer() { + cancelDoubleTapHoldTimer(); // 防御性取消 + doubleTapHoldRunnable = () -> { + // 计时器触发,说明用户按住不动,我们主动确认为拖拽 + if (isPotentialDoubleClick) { + isPotentialDoubleClick = false; + isDoubleClickDrag = true; + confirmedMove = true; + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + }; + handler.postDelayed(doubleTapHoldRunnable, DOUBLE_TAP_HOLD_TO_DRAG_THRESHOLD); + } + + // 取消“按住确认拖拽”计时器的方法 + private void cancelDoubleTapHoldTimer() { + if (doubleTapHoldRunnable != null) { + handler.removeCallbacks(doubleTapHoldRunnable); + doubleTapHoldRunnable = null; + } + } + + private void startDragTimer() { + cancelDragTimer(); + handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); + } + + private void cancelDragTimer() { + handler.removeCallbacks(dragTimerRunnable); + } + + // 用于取消延迟单击任务的辅助方法 + private void cancelSingleTapTimer() { + if (singleTapRunnable != null) { + handler.removeCallbacks(singleTapRunnable); + singleTapRunnable = null; + } } + private void checkForConfirmedMove(int eventX, int eventY) { + if (confirmedMove || confirmedDrag || isPotentialDoubleClick) return; + if (!isWithinTapBounds(eventX, eventY)) { + confirmedMove = true; + cancelDragTimer(); + return; + } + distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2)); + if (distanceMoved >= TAP_DISTANCE_THRESHOLD) { + confirmedMove = true; + cancelDragTimer(); + } + } + private void checkForConfirmedScroll() { + confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove); + } @Override public boolean isCancelled() { return cancelled; @@ -328,4 +481,8 @@ public void setPointerCount(int pointerCount) { maxPointerCountInGesture = pointerCount; } } + + public void adjustMsense(double sense){ + this.sense = sense; + } } diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java index ce9f4014e8..cf32c1d5cf 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java @@ -5,12 +5,17 @@ package com.limelight.binding.input.virtual_controller; import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; import android.os.Handler; import android.os.Looper; import android.util.DisplayMetrics; import android.view.View; import android.widget.Button; import android.widget.FrameLayout; +import android.widget.LinearLayout; import android.widget.Toast; import com.limelight.LimeLog; @@ -20,7 +25,7 @@ import java.util.ArrayList; import java.util.List; -public class VirtualController { +public class VirtualController implements SensorEventListener { public static class ControllerInputContext { public short inputMap = 0x0000; public byte leftTrigger = 0x00; @@ -42,6 +47,8 @@ public enum ControllerMode { private final ControllerHandler controllerHandler; private final Context context; private final Handler handler; + private final SensorManager sensorManager; + private boolean gyroEnabled = false; private final Runnable delayedRetransmitRunnable = new Runnable() { @Override @@ -64,6 +71,7 @@ public VirtualController(final ControllerHandler controllerHandler, FrameLayout this.frame_layout = layout; this.context = context; this.handler = new Handler(Looper.getMainLooper()); + this.sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); buttonConfigure = new Button(context); buttonConfigure.setAlpha(0.25f); @@ -163,6 +171,7 @@ public void refreshLayout() { params.topMargin = 15; frame_layout.addView(buttonConfigure, params); + // Start with the default layout VirtualControllerConfigurationLoader.createDefaultLayout(this, context); @@ -212,4 +221,71 @@ void sendControllerInputContext() { handler.postDelayed(delayedRetransmitRunnable, 50); handler.postDelayed(delayedRetransmitRunnable, 75); } + + /** + * 启用或禁用虚拟控制器的陀螺仪功能 + */ + public void setGyroEnabled(boolean enabled) { + if (gyroEnabled == enabled) { + return; + } + + gyroEnabled = enabled; + + if (enabled) { + // 注册陀螺仪传感器监听器 + Sensor gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + if (gyroSensor != null) { + sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME); + LimeLog.info("VirtualController: Gyroscope enabled"); + } else { + LimeLog.warning("VirtualController: No gyroscope sensor available"); + gyroEnabled = false; + } + } else { + // 取消注册陀螺仪传感器监听器 + sensorManager.unregisterListener(this); + LimeLog.info("VirtualController: Gyroscope disabled"); + } + } + + /** + * 检查陀螺仪是否已启用 + */ + public boolean isGyroEnabled() { + return gyroEnabled; + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (event.sensor.getType() == Sensor.TYPE_GYROSCOPE && gyroEnabled) { + // 将陀螺仪数据转换为度/秒 + float gx = event.values[0] * 57.2957795f; // rad/s to deg/s + float gy = event.values[1] * 57.2957795f; + float gz = event.values[2] * 57.2957795f; + + // 通过ControllerHandler报告陀螺仪数据 + // 使用控制器ID 0(虚拟控制器默认使用控制器0) + if (controllerHandler != null) { + // 直接调用ControllerHandler的陀螺仪处理方法 + // 这里需要访问ControllerHandler的私有方法,我们需要添加一个公共方法 + controllerHandler.reportVirtualControllerGyro(gx, gy, gz); + } + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // 不需要处理精度变化 + } + + /** + * 清理资源,取消传感器监听 + */ + public void cleanup() { + if (gyroEnabled) { + sensorManager.unregisterListener(this); + gyroEnabled = false; + } + } } diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java index d66ca8362e..0b0ac9d084 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java @@ -7,6 +7,7 @@ import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; +import android.content.res.Configuration; import android.util.DisplayMetrics; import com.limelight.nvstream.input.ControllerPacket; @@ -26,7 +27,10 @@ private static int getPercent( // The default controls are specified using a grid of 128*72 cells at 16:9 private static int screenScale(int units, int height) { - return (int) (((float) height / (float) 72) * (float) units); + return Math.round(((float) height / (float) 72) * (float) units); + } + private static int screenScaleReverse(int pixel, int height) { + return Math.round((float) pixel / ((float) height / (float) 72)); } private static DigitalPad createDigitalPad( @@ -189,15 +193,25 @@ private static AnalogStick createRightStick( private static final int GUIDE_X = START_X-BACK_X; private static final int GUIDE_Y = START_BACK_Y; + private static int screenHeight = 1080; + private static int screenWidth = 1920; + private static int baseYUnit = 0; + public static void createDefaultLayout(final VirtualController controller, final Context context) { DisplayMetrics screen = context.getResources().getDisplayMetrics(); PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); - // Displace controls on the right by this amount of pixels to account for different aspect ratios - int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9; + screenHeight = screen.heightPixels; + baseYUnit = 0; + if (config.halfHeightOscPortrait && context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + screenHeight /= 2; + baseYUnit = 72; + } - int height = screen.heightPixels; + // Displace controls on the right by this amount of pixels to account for different aspect ratios + screenWidth = screen.widthPixels; + int rightDisplacement = screenWidth - screenHeight * 16 / 9; // NOTE: Some of these getPercent() expressions seem like they can be combined // into a single call. Due to floating point rounding, this isn't actually possible. @@ -205,151 +219,179 @@ public static void createDefaultLayout(final VirtualController controller, final if (!config.onlyL3R3) { controller.addElement(createDigitalPad(controller, context), - screenScale(DPAD_BASE_X, height), - screenScale(DPAD_BASE_Y, height), - screenScale(DPAD_SIZE, height), - screenScale(DPAD_SIZE, height) + screenScale(DPAD_BASE_X, screenHeight), + screenScale(DPAD_BASE_Y + baseYUnit, screenHeight), + screenScale(DPAD_SIZE, screenHeight), + screenScale(DPAD_SIZE, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_A, !config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, !config.flipFaceButtons ? "A" : "B", -1, controller, context), - screenScale(BUTTON_BASE_X, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) + screenScale(BUTTON_BASE_X, screenHeight) + rightDisplacement, + screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE + baseYUnit, screenHeight), + screenScale(BUTTON_SIZE, screenHeight), + screenScale(BUTTON_SIZE, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_B, config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, config.flipFaceButtons ? "A" : "B", -1, controller, context), - screenScale(BUTTON_BASE_X + BUTTON_SIZE, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) + screenScale(BUTTON_BASE_X + BUTTON_SIZE, screenHeight) + rightDisplacement, + screenScale(BUTTON_BASE_Y + BUTTON_SIZE + baseYUnit, screenHeight), + screenScale(BUTTON_SIZE, screenHeight), + screenScale(BUTTON_SIZE, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_X, !config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, !config.flipFaceButtons ? "X" : "Y", -1, controller, context), - screenScale(BUTTON_BASE_X - BUTTON_SIZE, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) + screenScale(BUTTON_BASE_X - BUTTON_SIZE, screenHeight) + rightDisplacement, + screenScale(BUTTON_BASE_Y + BUTTON_SIZE + baseYUnit, screenHeight), + screenScale(BUTTON_SIZE, screenHeight), + screenScale(BUTTON_SIZE, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_Y, config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, config.flipFaceButtons ? "X" : "Y", -1, controller, context), - screenScale(BUTTON_BASE_X, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) + screenScale(BUTTON_BASE_X, screenHeight) + rightDisplacement, + screenScale(BUTTON_BASE_Y + baseYUnit, screenHeight), + screenScale(BUTTON_SIZE, screenHeight), + screenScale(BUTTON_SIZE, screenHeight) ); controller.addElement(createLeftTrigger( 1, "LT", -1, controller, context), - screenScale(TRIGGER_L_BASE_X, height), - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_L_BASE_X, screenHeight), + screenScale(TRIGGER_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); controller.addElement(createRightTrigger( 1, "RT", -1, controller, context), - screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, screenHeight) + rightDisplacement, + screenScale(TRIGGER_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_LB, ControllerPacket.LB_FLAG, 0, 1, "LB", -1, controller, context), - screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, height), - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, screenHeight), + screenScale(TRIGGER_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_RB, ControllerPacket.RB_FLAG, 0, 1, "RB", -1, controller, context), - screenScale(TRIGGER_R_BASE_X, height) + rightDisplacement, - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_R_BASE_X, screenHeight) + rightDisplacement, + screenScale(TRIGGER_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); controller.addElement(createLeftStick(controller, context), - screenScale(ANALOG_L_BASE_X, height), - screenScale(ANALOG_L_BASE_Y, height), - screenScale(ANALOG_SIZE, height), - screenScale(ANALOG_SIZE, height) + screenScale(ANALOG_L_BASE_X, screenHeight), + screenScale(ANALOG_L_BASE_Y + baseYUnit, screenHeight), + screenScale(ANALOG_SIZE, screenHeight), + screenScale(ANALOG_SIZE, screenHeight) ); controller.addElement(createRightStick(controller, context), - screenScale(ANALOG_R_BASE_X, height) + rightDisplacement, - screenScale(ANALOG_R_BASE_Y, height), - screenScale(ANALOG_SIZE, height), - screenScale(ANALOG_SIZE, height) + screenScale(ANALOG_R_BASE_X, screenHeight) + rightDisplacement, + screenScale(ANALOG_R_BASE_Y + baseYUnit, screenHeight), + screenScale(ANALOG_SIZE, screenHeight), + screenScale(ANALOG_SIZE, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_BACK, ControllerPacket.BACK_FLAG, 0, 2, "BACK", -1, controller, context), - screenScale(BACK_X, height), - screenScale(START_BACK_Y, height), - screenScale(START_BACK_WIDTH, height), - screenScale(START_BACK_HEIGHT, height) + screenScale(BACK_X, screenHeight), + screenScale(START_BACK_Y + baseYUnit, screenHeight), + screenScale(START_BACK_WIDTH, screenHeight), + screenScale(START_BACK_HEIGHT, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_START, ControllerPacket.PLAY_FLAG, 0, 3, "START", -1, controller, context), - screenScale(START_X, height) + rightDisplacement, - screenScale(START_BACK_Y, height), - screenScale(START_BACK_WIDTH, height), - screenScale(START_BACK_HEIGHT, height) + screenScale(START_X, screenHeight) + rightDisplacement, + screenScale(START_BACK_Y + baseYUnit, screenHeight), + screenScale(START_BACK_WIDTH, screenHeight), + screenScale(START_BACK_HEIGHT, screenHeight) ); } else { controller.addElement(createDigitalButton( VirtualControllerElement.EID_LSB, ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, controller, context), - screenScale(TRIGGER_L_BASE_X, height), - screenScale(L3_R3_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_L_BASE_X, screenHeight), + screenScale(L3_R3_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); controller.addElement(createDigitalButton( VirtualControllerElement.EID_RSB, ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1, controller, context), - screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, - screenScale(L3_R3_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) + screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, screenHeight) + rightDisplacement, + screenScale(L3_R3_BASE_Y + baseYUnit, screenHeight), + screenScale(TRIGGER_WIDTH, screenHeight), + screenScale(TRIGGER_HEIGHT, screenHeight) ); } if(config.showGuideButton){ controller.addElement(createDigitalButton(VirtualControllerElement.EID_GDB, ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 1, "GUIDE", -1, controller, context), - screenScale(GUIDE_X, height)+ rightDisplacement, - screenScale(GUIDE_Y, height), - screenScale(START_BACK_WIDTH, height), - screenScale(START_BACK_HEIGHT, height) + screenScale(GUIDE_X, screenHeight)+ rightDisplacement, + screenScale(GUIDE_Y + baseYUnit, screenHeight), + screenScale(START_BACK_WIDTH, screenHeight), + screenScale(START_BACK_HEIGHT, screenHeight) ); } controller.setOpacity(config.oscOpacity); } + public static JSONObject prepareSave(JSONObject config) throws JSONException { + // 靠右侧的右对齐 + int dPosX = config.optInt("LEFT") * 2 + config.optInt("WIDTH"); + if (dPosX < screenWidth) { + config.put("R_SIDE", false); + config.put("LEFT", screenScaleReverse(config.optInt("LEFT"), screenHeight)); + } else { + config.put("R_SIDE", true); + config.put("LEFT", screenScaleReverse(screenWidth - config.optInt("LEFT"), screenHeight)); + } + config.put("TOP", screenScaleReverse(config.optInt("TOP"), screenHeight) - baseYUnit); + config.put("WIDTH", screenScaleReverse(config.optInt("WIDTH"), screenHeight)); + config.put("HEIGHT", screenScaleReverse(config.optInt("HEIGHT"), screenHeight)); + return config; + } + + public static JSONObject prepareLoad(JSONObject config) throws JSONException { + if (config.optBoolean("R_SIDE")) { + config.put("LEFT", screenWidth - screenScale(config.optInt("LEFT"), screenHeight)); + } else { + config.put("LEFT", screenScale(config.optInt("LEFT"), screenHeight)); + } + config.put("TOP", screenScale(config.optInt("TOP") + baseYUnit, screenHeight)); + config.put("WIDTH", screenScale(config.optInt("WIDTH"), screenHeight)); + config.put("HEIGHT", screenScale(config.optInt("HEIGHT"), screenHeight)); + return config; + } + public static void saveProfile(final VirtualController controller, final Context context) { SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit(); @@ -357,7 +399,7 @@ public static void saveProfile(final VirtualController controller, for (VirtualControllerElement element : controller.getElements()) { String prefKey = ""+element.elementId; try { - prefEditor.putString(prefKey, element.getConfiguration().toString()); + prefEditor.putString(prefKey, prepareSave(element.getConfiguration()).toString()); } catch (JSONException e) { e.printStackTrace(); } @@ -375,7 +417,7 @@ public static void loadFromPreferences(final VirtualController controller, final String jsonConfig = pref.getString(prefKey, null); if (jsonConfig != null) { try { - element.loadConfiguration(new JSONObject(jsonConfig)); + element.loadConfiguration(prepareLoad(new JSONObject(jsonConfig))); } catch (JSONException e) { e.printStackTrace(); diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java index e45e9ddc75..de4a2ecd83 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java @@ -14,6 +14,8 @@ import android.view.View; import android.widget.FrameLayout; +import com.limelight.R; + import org.json.JSONException; import org.json.JSONObject; @@ -172,7 +174,7 @@ protected int getDefaultStrokeWidth() { } protected void showConfigurationDialog() { - AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext()); + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext(), R.style.AppDialogStyle); alertBuilder.setTitle("Configuration"); diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java index d24ec0dc72..75ae434b95 100644 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java @@ -5,7 +5,9 @@ import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; @@ -39,7 +41,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements Choreographer.FrameCallback { - private static final boolean USE_FRAME_RENDER_TIME = false; + private static final boolean USE_FRAME_RENDER_TIME = true; private static final boolean FRAME_RENDER_TIME_ONLY = USE_FRAME_RENDER_TIME && false; // Used on versions < 5.0 @@ -57,6 +59,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements C private int nextInputBufferIndex = -1; private ByteBuffer nextInputBuffer; + private Context context; private Activity activity; @@ -115,12 +118,51 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements C private int lastFrameNumber; private int refreshRate; private PreferenceConfiguration prefs; + + // Map to track enqueue time for each timestamp + // Key: timestamp in microseconds (from enqueueTimeUs) + // Value: enqueue time in milliseconds (from SystemClock.uptimeMillis()) + private final Map timestampToEnqueueTime = new HashMap<>(); private LinkedBlockingQueue outputBufferQueue = new LinkedBlockingQueue<>(); private static final int OUTPUT_BUFFER_QUEUE_LIMIT = 2; private long lastRenderedFrameTimeNanos; private HandlerThread choreographerHandlerThread; private Handler choreographerHandler; + + // Surface Flinger Raw模式相关变量 + private Thread surfaceFlingerThread; + private volatile boolean surfaceFlingerActive; + private long surfaceFlingerLastFrameTime; + private long surfaceFlingerFrameInterval; + private int surfaceFlingerFrameCount; + private int surfaceFlingerSkippedFrames; // 记录跳过的帧数 + + // 高精度帧率控制 + private long surfaceFlingerTargetTime; // 目标渲染时间(绝对时间) + private long surfaceFlingerTimingError; // 累积时间误差 + + /** + * 安全地设置线程优先级 + */ + private void setThreadPrioritySafely(Thread thread, int priority) { + try { + // 首先尝试设置目标优先级 + thread.setPriority(priority); + LimeLog.info("成功设置Surface Flinger线程优先级为: " + priority); + } catch (IllegalArgumentException e) { + LimeLog.warning("线程优先级 " + priority + " 超出范围,尝试使用次高优先级"); + try { + // 尝试使用次高优先级 + thread.setPriority(Process.THREAD_PRIORITY_DISPLAY); + LimeLog.info("成功设置Surface Flinger线程优先级为: " + Process.THREAD_PRIORITY_DISPLAY); + } catch (IllegalArgumentException e2) { + // 最后使用标准高优先级 + thread.setPriority(Thread.NORM_PRIORITY + 2); + LimeLog.warning("使用标准高优先级: " + (Thread.NORM_PRIORITY + 2)); + } + } + } private int numSpsIn; private int numPpsIn; @@ -223,6 +265,16 @@ private MediaCodecInfo findHevcDecoder(PreferenceConfiguration prefs, boolean me return null; } + // In auto mode, we should still prepare HEVC as a fallback even if AV1 is available + // The server will negotiate the final codec choice based on what it supports + if (prefs.videoFormat == PreferenceConfiguration.FormatOption.AUTO) { + MediaCodecInfo av1DecoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/av01", -1); + if (av1DecoderInfo != null && MediaCodecHelper.isDecoderWhitelistedForAv1(av1DecoderInfo)) { + LimeLog.info("AV1 decoder available in auto mode, but still preparing HEVC as fallback"); + // Continue to prepare HEVC decoder instead of returning null + } + } + // We don't try the first HEVC decoder. We'd rather fall back to hardware accelerated AVC instead // // We need HEVC Main profile, so we could pass that constant to findProbableSafeDecoder, however @@ -259,8 +311,9 @@ else if (avcDecoder != null && decoderCanMeetPerformancePointWithHevcAndNotAvc(h } private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) { - // For now, don't use AV1 unless explicitly requested - if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1) { + // Use AV1 if explicitly requested or in auto mode + if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1 && + prefs.videoFormat != PreferenceConfiguration.FormatOption.AUTO) { return null; } @@ -269,7 +322,7 @@ private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) { if (!MediaCodecHelper.isDecoderWhitelistedForAv1(decoderInfo)) { LimeLog.info("Found AV1 decoder, but it's not whitelisted - "+decoderInfo.getName()); - // Force HEVC enabled if the user asked for it + // Force AV1 enabled if the user asked for it if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1) { LimeLog.info("Forcing AV1 enabled despite non-whitelisted decoder"); } @@ -392,14 +445,29 @@ public boolean isAvcSupported() { return avcDecoder != null; } + public boolean isHevcMain10Supported() { + if (hevcDecoder == null) { + return false; + } + + for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) { + if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10) { + LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10"); + return true; + } + } + return false; + } + public boolean isHevcMain10Hdr10Supported() { if (hevcDecoder == null) { return false; } for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) { - if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10) { - LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10 HDR10"); + if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10 || + profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10Plus) { + LimeLog.info("HEVC 解码器 " + hevcDecoder.getName() + " 支持 HEVC Main10 HDR10/HDR10+"); return true; } } @@ -546,6 +614,7 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { vpsBuffers.clear(); spsBuffers.clear(); ppsBuffers.clear(); + timestampToEnqueueTime.clear(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // This will contain the actual accepted input format attributes @@ -687,11 +756,18 @@ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { videoDecoder.setOnFrameRenderedListener(new MediaCodec.OnFrameRenderedListener() { @Override public void onFrameRendered(MediaCodec mediaCodec, long presentationTimeUs, long renderTimeNanos) { - long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000); + // presentationTimeUs: 我们告诉系统这一帧应该在什么时间点显示 (单位: 微秒) + // renderTimeNanos: 系统报告的这一帧实际显示在屏幕上的时间点 (单位: 纳秒) + long presentationTimeMs = presentationTimeUs / 1000; + long renderTimeMs = renderTimeNanos / 1000000L; + + // 计算从“应该显示”到“实际显示”的延迟 + long delta = renderTimeMs - presentationTimeMs; + + // 过滤掉异常值 if (delta >= 0 && delta < 1000) { - if (USE_FRAME_RENDER_TIME) { - activeWindowVideoStats.totalTimeMs += delta; - } + activeWindowVideoStats.renderingTimeMs += delta; + activeWindowVideoStats.totalTimeMs += delta; } } }, null); @@ -993,6 +1069,11 @@ public void doFrame(long frameTimeNanos) { // frame of buffer to smooth over network/rendering jitter. Integer nextOutputBuffer = outputBufferQueue.poll(); if (nextOutputBuffer != null) { + if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_EXPERIMENTAL_LOW_LATENCY) { + // 实验性低延迟模式:进一步优化V-Sync处理 + // 安全的提前量:不超过V-Sync周期的1/2 + frameTimeNanos -= 500000000 / refreshRate; + } try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { videoDecoder.releaseOutputBuffer(nextOutputBuffer, frameTimeNanos); @@ -1025,13 +1106,16 @@ public void doFrame(long frameTimeNanos) { } private void startChoreographerThread() { - if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED && + prefs.framePacing != PreferenceConfiguration.FRAME_PACING_EXPERIMENTAL_LOW_LATENCY) { // Not using Choreographer in this pacing mode return; } // We use a separate thread to avoid any main thread delays from delaying rendering - choreographerHandlerThread = new HandlerThread("Video - Choreographer", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE); + choreographerHandlerThread = new HandlerThread("Video - Choreographer", + prefs.framePacing == PreferenceConfiguration.FRAME_PACING_EXPERIMENTAL_LOW_LATENCY ? + Process.THREAD_PRIORITY_DISPLAY : Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE); choreographerHandlerThread.start(); // Start the frame callbacks @@ -1044,6 +1128,117 @@ public void run() { }); } + private void startSurfaceFlingerThread() { + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_SURFACE_FLINGER_RAW) { + return; + } + + LimeLog.info("启动Surface Flinger Raw模式"); + + surfaceFlingerActive = true; + surfaceFlingerFrameInterval = 1000000000L / refreshRate; // 纳秒 + surfaceFlingerTargetTime = System.nanoTime() + surfaceFlingerFrameInterval; + surfaceFlingerLastFrameTime = System.nanoTime(); + surfaceFlingerFrameCount = 0; + surfaceFlingerSkippedFrames = 0; + surfaceFlingerTimingError = 0; + + surfaceFlingerThread = new Thread() { + @Override + public void run() { + Thread.currentThread().setName("Video - Surface Flinger Raw"); + // 使用安全的线程优先级设置 + setThreadPrioritySafely(Thread.currentThread(), Process.THREAD_PRIORITY_URGENT_DISPLAY); + + while (surfaceFlingerActive && !stopping) { + try { + long currentTime = System.nanoTime(); + + // 使用绝对目标时间而不是相对时间间隔 + if (currentTime >= surfaceFlingerTargetTime) { + // 检查是否有待渲染的帧 + Integer nextOutputBuffer = outputBufferQueue.poll(); + if (nextOutputBuffer != null) { + // 直接释放缓冲区进行渲染,不使用时间戳 + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Surface Flinger Raw模式:直接渲染,让系统处理同步 + videoDecoder.releaseOutputBuffer(nextOutputBuffer, 0); + } else { + videoDecoder.releaseOutputBuffer(nextOutputBuffer, true); + } + + surfaceFlingerLastFrameTime = currentTime; + surfaceFlingerFrameCount++; + activeWindowVideoStats.totalFramesRendered++; + + // 计算时间误差并进行补偿 + long actualInterval = currentTime - surfaceFlingerLastFrameTime; + surfaceFlingerTimingError += (actualInterval - surfaceFlingerFrameInterval); + + // 每100帧记录一次性能数据 + if (surfaceFlingerFrameCount % 100 == 0) { + float avgError = surfaceFlingerTimingError / 1000000.0f / surfaceFlingerFrameCount; + LimeLog.info(String.format("SF Raw: %d帧, 跳帧: %d, 平均误差: %.3fms", + surfaceFlingerFrameCount, surfaceFlingerSkippedFrames, avgError)); + } + + } catch (IllegalStateException e) { + LimeLog.warning("Surface Flinger Raw渲染异常: " + e.getMessage()); + handleDecoderException(e); + } + } else { + // 没有可用的帧,记录为跳帧 + surfaceFlingerSkippedFrames++; + } + + // 更新下一帧的绝对目标时间 + surfaceFlingerTargetTime += surfaceFlingerFrameInterval; + + // 如果累积误差过大(>2帧),重新同步 + if (Math.abs(currentTime - surfaceFlingerTargetTime) > surfaceFlingerFrameInterval * 2) { + LimeLog.warning("SF Raw: 时间漂移过大,重新同步"); + surfaceFlingerTargetTime = currentTime + surfaceFlingerFrameInterval; + surfaceFlingerTimingError = 0; + } + } + + // 精确休眠到下一帧 + currentTime = System.nanoTime(); + long sleepTimeNs = surfaceFlingerTargetTime - currentTime; + + if (sleepTimeNs > 2000000) { // 超过2ms,使用sleep + // 提前1ms醒来,用忙等待精确控制 + long sleepMs = (sleepTimeNs - 1000000) / 1000000; + if (sleepMs > 0) { + Thread.sleep(sleepMs); + } + } + + // 忙等待最后的微秒级精度 + while (System.nanoTime() < surfaceFlingerTargetTime) { + // 短暂让出CPU,避免100%占用 + if (surfaceFlingerTargetTime - System.nanoTime() > 100000) { + Thread.yield(); + } + } + + } catch (InterruptedException e) { + LimeLog.info("Surface Flinger线程被中断"); + break; + } catch (Exception e) { + LimeLog.warning("Surface Flinger线程异常: " + e.getMessage()); + e.printStackTrace(); + } + } + + LimeLog.info("Surface Flinger Raw线程结束"); + } + }; + + surfaceFlingerThread.start(); + } + private void startRendererThread() { rendererThread = new Thread() { @@ -1060,8 +1255,10 @@ public void run() { numFramesOut++; - // Render the latest frame now if frame pacing isn't in balanced mode - if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { + // Render the latest frame now if frame pacing isn't in balanced mode or Surface Flinger mode + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED && + prefs.framePacing != PreferenceConfiguration.FRAME_PACING_EXPERIMENTAL_LOW_LATENCY && + prefs.framePacing != PreferenceConfiguration.FRAME_PACING_SURFACE_FLINGER_RAW) { // Get the last output buffer in the queue while ((outIndex = videoDecoder.dequeueOutputBuffer(info, 0)) >= 0) { videoDecoder.releaseOutputBuffer(lastIndex, false); @@ -1078,18 +1275,15 @@ public void run() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Use a PTS that will cause this frame to never be dropped videoDecoder.releaseOutputBuffer(lastIndex, 0); - } - else { + } else { videoDecoder.releaseOutputBuffer(lastIndex, true); } - } - else { + } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Use a PTS that will cause this frame to be dropped if another comes in within // the same V-sync period videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime()); - } - else { + } else { videoDecoder.releaseOutputBuffer(lastIndex, true); } } @@ -1097,8 +1291,9 @@ public void run() { activeWindowVideoStats.totalFramesRendered++; } else { - // For balanced frame pacing case, the Choreographer callback will handle rendering. - // We just put all frames into the output buffer queue and let it handle things. + // For balanced frame pacing, experimental low latency, and Surface Flinger modes + // The respective callback threads will handle rendering. + // We just put all frames into the output buffer queue and let them handle things. // Discard the oldest buffer if we've exceeded our limit. // @@ -1120,7 +1315,7 @@ public void run() { } // Add delta time to the totals (excluding probable outliers) - long delta = SystemClock.uptimeMillis() - (presentationTimeUs / 1000); + long delta = calculateDecoderTime(presentationTimeUs); if (delta >= 0 && delta < 1000) { activeWindowVideoStats.decoderTimeMs += delta; if (!USE_FRAME_RENDER_TIME) { @@ -1231,18 +1426,28 @@ private boolean fetchNextInputBuffer() { public void start() { startRendererThread(); startChoreographerThread(); + startSurfaceFlingerThread(); } // !!! May be called even if setup()/start() fails !!! public void prepareForStop() { // Let the decoding code know to ignore codec exceptions now stopping = true; + + // Clear timestamp tracking map + timestampToEnqueueTime.clear(); // Halt the rendering thread if (rendererThread != null) { rendererThread.interrupt(); } + // Stop Surface Flinger thread + surfaceFlingerActive = false; + if (surfaceFlingerThread != null) { + surfaceFlingerThread.interrupt(); + } + // Stop any active codec recovery operations synchronized (codecRecoveryMonitor) { codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); @@ -1283,6 +1488,20 @@ public void stop() { } } + // Wait for the Surface Flinger thread to shut down + if (surfaceFlingerThread != null) { + try { + surfaceFlingerThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + // Wait for the renderer thread to shut down try { rendererThread.join(); @@ -1299,6 +1518,7 @@ public void stop() { @Override public void cleanup() { videoDecoder.release(); + timestampToEnqueueTime.clear(); } @Override @@ -1335,6 +1555,9 @@ private boolean queueNextInputBuffer(long timestampUs, int codecFlags) { boolean codecRecovered; try { + // Record the enqueue time for this timestamp + timestampToEnqueueTime.put(timestampUs, SystemClock.uptimeMillis()); + videoDecoder.queueInputBuffer(nextInputBufferIndex, 0, nextInputBuffer.position(), timestampUs, codecFlags); @@ -1396,7 +1619,7 @@ private void doProfileSpecificSpsPatching(SeqParameterSet sps) { @Override public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs) { + long receiveTimeUs, long enqueueTimeUs) { if (stopping) { // Don't bother if we're stopping return MoonBridge.DR_OK; @@ -1423,43 +1646,59 @@ public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int dec // Flip stats windows roughly every second if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) { - if (prefs.enablePerfOverlay) { - VideoStats lastTwo = new VideoStats(); - lastTwo.add(lastWindowVideoStats); - lastTwo.add(activeWindowVideoStats); - VideoStatsFps fps = lastTwo.getFps(); - String decoder; - - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - decoder = avcDecoder.getName(); - } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { - decoder = hevcDecoder.getName(); - } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { - decoder = av1Decoder.getName(); - } else { - decoder = "(unknown)"; - } - - float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived; - long rttInfo = MoonBridge.getEstimatedRttInfo(); - StringBuilder sb = new StringBuilder(); - sb.append(context.getString(R.string.perf_overlay_streamdetails, initialWidth + "x" + initialHeight, fps.totalFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_decoder, decoder)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_incomingfps, fps.receivedFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_renderingfps, fps.renderedFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_netdrops, - (float)lastTwo.framesLost / lastTwo.totalFrames * 100)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_netlatency, - (int)(rttInfo >> 32), (int)rttInfo)).append('\n'); - if (lastTwo.framesWithHostProcessingLatency > 0) { - sb.append(context.getString(R.string.perf_overlay_hostprocessinglatency, - (float)lastTwo.minHostProcessingLatency / 10, - (float)lastTwo.maxHostProcessingLatency / 10, - (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency)).append('\n'); - } - sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs)); - perfListener.onPerfUpdate(sb.toString()); - } + VideoStats lastTwo = new VideoStats(); + lastTwo.add(lastWindowVideoStats); + lastTwo.add(activeWindowVideoStats); + VideoStatsFps fps = lastTwo.getFps(); + String decoder; + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + decoder = avcDecoder.getName(); + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + decoder = hevcDecoder.getName(); + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + decoder = av1Decoder.getName(); + } else { + decoder = "(unknown)"; + } + float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived; + long rttInfo = MoonBridge.getEstimatedRttInfo(); + float lostFrameRate = (float)lastTwo.framesLost / lastTwo.totalFrames * 100; + float minHostProcessingLatency = (float)lastTwo.minHostProcessingLatency / 10; + float maxHostProcessingLatency = (float)lastTwo.minHostProcessingLatency / 10; + float aveHostProcessingLatency = (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency; + + // 计算平均“解码+渲染”总时间 + float aveTotalProcessingTimeMs = 0; + if (lastTwo.totalFramesRendered > 0) { + aveTotalProcessingTimeMs = (float) lastTwo.totalTimeMs / lastTwo.totalFramesRendered; + } + + // 计算平均"纯渲染延迟" + // 注意:这里用总处理时间减去解码时间。如果结果为负,说明数据有抖动,取0即可。 + float avePureRenderingLatencyMs = Math.max(0, aveTotalProcessingTimeMs - decodeTimeMs); + + PerformanceInfo performanceInfo = new PerformanceInfo(); + performanceInfo.context = context; + performanceInfo.initialWidth = initialWidth; + performanceInfo.initialHeight = initialHeight; + performanceInfo.decoder = decoder; + performanceInfo.totalFps = fps.totalFps; + performanceInfo.receivedFps = fps.receivedFps; + performanceInfo.renderedFps = fps.renderedFps; + performanceInfo.lostFrameRate = lostFrameRate; + performanceInfo.rttInfo = rttInfo; + performanceInfo.framesWithHostProcessingLatency = frameHostProcessingLatency; + performanceInfo.isHdrActive = (currentHdrMetadata != null); // 基于实际HDR元数据状态 + performanceInfo.minHostProcessingLatency = minHostProcessingLatency; + performanceInfo.maxHostProcessingLatency = maxHostProcessingLatency; + performanceInfo.aveHostProcessingLatency = aveHostProcessingLatency; + performanceInfo.decodeTimeMs = decodeTimeMs; + performanceInfo.renderingLatencyMs = avePureRenderingLatencyMs; + performanceInfo.totalTimeMs = aveTotalProcessingTimeMs; + + perfListener.onPerfUpdateV(performanceInfo); + perfListener.onPerfUpdateWG(performanceInfo); globalVideoStats.add(activeWindowVideoStats); lastWindowVideoStats.copy(activeWindowVideoStats); @@ -1695,7 +1934,8 @@ else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FO // Count time from first packet received to enqueue time as receive time // We will count DU queue time as part of decoding, because it is directly // caused by a slow decoder. - activeWindowVideoStats.totalTimeMs += enqueueTimeMs - receiveTimeMs; + // receiveTimeUs and enqueueTimeUs are in microseconds, convert to milliseconds + activeWindowVideoStats.totalTimeMs += (enqueueTimeUs - receiveTimeUs) / 1000; } if (!fetchNextInputBuffer()) { @@ -1721,7 +1961,7 @@ else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FO } } - long timestampUs = enqueueTimeMs * 1000; + long timestampUs = enqueueTimeUs; if (timestampUs <= lastTimestampUs) { // We can't submit multiple buffers with the same timestamp // so bump it up by one before queuing @@ -1817,6 +2057,31 @@ public int getAverageDecoderLatency() { return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived); } + public String getSurfaceFlingerStats() { + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_SURFACE_FLINGER_RAW) { + return null; + } + + if (globalVideoStats.totalFramesReceived == 0) { + return null; + } + + // 计算跳帧率 + // surfaceFlingerSkippedFrames: Surface Flinger线程因缓冲区为空而跳过的帧 + // 总跳帧 = SF线程跳帧 + 网络丢帧 + long totalFramesExpected = surfaceFlingerFrameCount + surfaceFlingerSkippedFrames; + float skipRate = 0f; + + if (totalFramesExpected > 0) { + skipRate = (float)surfaceFlingerSkippedFrames / totalFramesExpected * 100f; + } + + return String.format("[SF Raw: %d渲染/%d接收, 跳帧率: %.1f%%]", + (int)globalVideoStats.totalFramesRendered, + (int)globalVideoStats.totalFramesReceived, + skipRate); + } + static class DecoderHungException extends RuntimeException { private int hangTimeMs; @@ -1969,4 +2234,18 @@ else if (renderer.numFramesOut <= renderer.refreshRate * 30) { return str; } } + + // Calculate decoder time using the enqueue time we recorded + // presentationTimeUs: presentation timestamp in microseconds (from MediaCodec) + // Returns: decoder time in milliseconds + private long calculateDecoderTime(long presentationTimeUs) { + // Look up the enqueue time for this timestamp (stored in milliseconds) + Long enqueueTimeMs = timestampToEnqueueTime.remove(presentationTimeUs); + if (enqueueTimeMs != null) { + long delta = SystemClock.uptimeMillis() - enqueueTimeMs; + return delta > 0 && delta < 1000 ? delta : 0; + } + // If we can't find the enqueue time, return 0 + return 0; + } } diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java index 24d1f01ab0..49504637cd 100644 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java @@ -40,6 +40,7 @@ public class MediaCodecHelper { private static final List refFrameInvalidationHevcPrefixes; private static final List useFourSlicesPrefixes; private static final List qualcommDecoderPrefixes; + private static final List mtkDecoderPrefixes; //ALONSOJR1980 private static final List kirinDecoderPrefixes; private static final List exynosDecoderPrefixes; private static final List amlogicDecoderPrefixes; @@ -50,6 +51,7 @@ public class MediaCodecHelper { private static boolean isLowEndSnapdragon = false; private static boolean isAdreno620 = false; + private static boolean isSnapdragonGSeries = false; private static boolean initialized = false; static { @@ -230,6 +232,14 @@ public class MediaCodecHelper { qualcommDecoderPrefixes.add("c2.qti"); } + //ALONSOJR1980 + static { + mtkDecoderPrefixes = new LinkedList<>(); + + mtkDecoderPrefixes.add("omx.mtk"); + mtkDecoderPrefixes.add("c2.mtk"); + } + static { kirinDecoderPrefixes = new LinkedList<>(); @@ -285,6 +295,19 @@ private static boolean isLowEndSnapdragonRenderer(String glRenderer) { return modelNumber.charAt(1) == '0'; } + private static boolean isSnapdragonGSeries(String glRenderer) { + glRenderer = glRenderer.toLowerCase().trim(); + if (!glRenderer.contains("adreno")) { + return false; + } + + // Snapdragon G Series For Gaming includes G1-A11, G2-A21, G3-A31 + Pattern modelNumberPattern = Pattern.compile("(.*)(a[0-9]{2})(.*)"); + + Matcher matcher = modelNumberPattern.matcher(glRenderer); + return matcher.matches(); + } + private static int getAdrenoRendererModelNumber(String glRenderer) { String modelNumber = getAdrenoVersionString(glRenderer); if (modelNumber == null) { @@ -350,6 +373,7 @@ public static void initialize(Context context, String glRenderer) { LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion); isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer); + isSnapdragonGSeries = isSnapdragonGSeries(glRenderer); isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620; // Tegra K1 and later can do reference frame invalidation properly @@ -488,7 +512,7 @@ private static boolean decoderSupportsMaxOperatingRate(String decoderName) { // NB: Even on Android 10, this optimization still provides significant // performance gains on Pixel 2. return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - isDecoderInList(qualcommDecoderPrefixes, decoderName) && + (isDecoderInList(qualcommDecoderPrefixes, decoderName) || isSnapdragonGSeries) && !isAdreno620; } @@ -505,9 +529,11 @@ public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, Media // If this decoder officially supports FEATURE_LowLatency, we will just use that alone // for try 0. Otherwise, we'll include it as best effort with other options. - if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { - return true; - } + + // ALONSOJR1980: "low-latency" is not enough, continuing to add specific extensions + // if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { + // return true; + // } } if (tryNumber < 2 && @@ -562,6 +588,47 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { } if (tryNumber < 5) { videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1); + + // ALONSOJR1980 - CONFIRMED WORKING: Snapdragon Elite, SD8 gen 3, SD8 gen 2 + // latency-wise, software fencing is the most important flag for latest Snapdragons + videoFormat.setInteger("vendor.qti-ext-output-sw-fence-enable.value", 1); // Snapdragon 8 gen 2 + videoFormat.setInteger("vendor.qti-ext-output-fence.enable", 1); // Snapdragon 8s Gen 3 and Elite + videoFormat.setInteger("vendor.qti-ext-output-fence.fence_type", 1); // Snapdragon 8s Gen 3 and ELite / 0 = none, 1 = sw, 2 = hw, 3 = hybrid. Best option = 1 + setNewOption = true; + } + } + //ALONSOJR1980 + else if (isDecoderInList(mtkDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + videoFormat.setInteger("vendor.mtk.vdec.cpu.boost.mode", 1); + videoFormat.setInteger("vendor.mtk.vdec.cpu.boost.mode.value", 2); + videoFormat.setInteger("vendor.mtk.ext.dolby.vision.cpu-boost", 1); + + videoFormat.setInteger("vendor.mtk.vdec.dvfs.mode", 1); + videoFormat.setInteger("vendor.mtk.vdec.dvfs.level", 1); + + videoFormat.setInteger("vendor.mtk.vdec.bq.guard.interval.time.value", 2); + videoFormat.setInteger("vendor.mtk.vdec.buffer.fetch.timeout.ms.value", 2); + + // Pacing: controlled by the app + videoFormat.setInteger("vendor.mtk.vdec.vsync.adjust.enable", 0); + + // Queue / timeouts (moderate) + try { + videoFormat.setInteger("vendor.mtk.vdec.buffer.fetch.timeout.ms", 4); + videoFormat.setInteger("vendor.mtk.vdec.bq.guard.interval.time", 4); + videoFormat.setInteger("vendor.mtk.vdec.input.max.queue.depth", 3); + videoFormat.setInteger("vendor.mtk.vdec.output.max.queue.depth", 3); + } catch (Throwable ignored) {} + + // Pipeline / code path + try { + videoFormat.setInteger("vendor.mtk.vdec.low-latency.mode", 1); // Enable low-latency path + videoFormat.setInteger("vendor.mtk.vdec.ultra-low-latency", 0); // ULL off for stability + videoFormat.setInteger("vendor.mtk.vdec.disable-idle", 1); // Prevent clock downscaling + videoFormat.setInteger("vendor.mtk.vdec.preload.frame.count", 1); // Light prebuffering + } catch (Throwable ignored) {} + setNewOption = true; } } @@ -763,8 +830,8 @@ else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) { return false; } - // TODO: Test some AV1 decoders - return false; + // Allow hardware-accelerated AV1 decoders + return true; } @SuppressWarnings("deprecation") diff --git a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java index 281f95a046..c1cd821749 100644 --- a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java +++ b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java @@ -1,5 +1,8 @@ package com.limelight.binding.video; public interface PerfOverlayListener { - void onPerfUpdate(final String text); +// void onPerfUpdate(final String text); + void onPerfUpdateV(final PerformanceInfo performanceInfo); + void onPerfUpdateWG(final PerformanceInfo performanceInfo); + boolean isPerfOverlayVisible(); } diff --git a/app/src/main/java/com/limelight/binding/video/PerformanceInfo.java b/app/src/main/java/com/limelight/binding/video/PerformanceInfo.java new file mode 100644 index 0000000000..aa9c34a46d --- /dev/null +++ b/app/src/main/java/com/limelight/binding/video/PerformanceInfo.java @@ -0,0 +1,25 @@ +package com.limelight.binding.video; + +import android.content.Context; + +public class PerformanceInfo { + + public Context context; + public String decoder; + public int initialWidth; + public int initialHeight; + public float totalFps; + public float receivedFps; + public float renderedFps; + public float lostFrameRate; + public long rttInfo; + public int framesWithHostProcessingLatency; + public float minHostProcessingLatency; + public float maxHostProcessingLatency; + public float aveHostProcessingLatency; + public float decodeTimeMs; + public float totalTimeMs; + public String bandWidth; + public boolean isHdrActive; // 实际HDR激活状态 + public float renderingLatencyMs; // 渲染时间 +} diff --git a/app/src/main/java/com/limelight/binding/video/VideoStats.java b/app/src/main/java/com/limelight/binding/video/VideoStats.java index b65b897ecf..f3270e90ce 100644 --- a/app/src/main/java/com/limelight/binding/video/VideoStats.java +++ b/app/src/main/java/com/limelight/binding/video/VideoStats.java @@ -16,6 +16,7 @@ class VideoStats { int totalHostProcessingLatency; int framesWithHostProcessingLatency; long measurementStartTimestamp; + public long renderingTimeMs;// 渲染时间 void add(VideoStats other) { this.decoderTimeMs += other.decoderTimeMs; @@ -26,6 +27,9 @@ void add(VideoStats other) { this.frameLossEvents += other.frameLossEvents; this.framesLost += other.framesLost; + // 累加渲染时间 + this.renderingTimeMs += other.renderingTimeMs; + if (this.minHostProcessingLatency == 0) { this.minHostProcessingLatency = other.minHostProcessingLatency; } else { @@ -55,6 +59,9 @@ void copy(VideoStats other) { this.totalHostProcessingLatency = other.totalHostProcessingLatency; this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency; this.measurementStartTimestamp = other.measurementStartTimestamp; + + // 复制渲染时间 + this.renderingTimeMs = other.renderingTimeMs; } void clear() { @@ -70,6 +77,7 @@ void clear() { this.totalHostProcessingLatency = 0; this.framesWithHostProcessingLatency = 0; this.measurementStartTimestamp = 0; + this.renderingTimeMs = 0; } VideoStatsFps getFps() { diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java index 990a8953f2..c75e5b23c1 100644 --- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java +++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java @@ -23,6 +23,7 @@ import com.limelight.nvstream.http.PairingManager; import com.limelight.nvstream.mdns.MdnsComputer; import com.limelight.nvstream.mdns.MdnsDiscoveryListener; +import com.limelight.preferences.PreferenceConfiguration; import com.limelight.utils.CacheHelper; import com.limelight.utils.NetHelper; import com.limelight.utils.ServerHelper; @@ -331,6 +332,9 @@ public boolean onUnbind(Intent intent) { } private void populateExternalAddress(ComputerDetails details) { + PreferenceConfiguration prefConfig = PreferenceConfiguration.readPreferences(this); + if (!prefConfig.enableStun) + return; boolean boundToNetwork = false; boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this); ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); @@ -549,7 +553,7 @@ private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.Addre boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE && details.activeAddress != null && address.port == details.activeAddress.port; - NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert, + NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), "", details.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this)); // If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond. @@ -575,6 +579,11 @@ else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) { return null; } catch (IOException e) { return null; + } catch (InterruptedException e) { + // Thread was interrupted during HTTP request (e.g., when parallel polling is cancelled) + // This is expected behavior, just return null to indicate polling failed + Thread.currentThread().interrupt(); // Restore interrupt status + return null; } } @@ -638,54 +647,65 @@ private ComputerDetails parallelPollPc(ComputerDetails details) throws Interrupt startParallelPollThread(remoteInfo, uniqueAddresses); startParallelPollThread(ipv6Info, uniqueAddresses); + ComputerDetails result = null; + ComputerDetails.AddressTuple primaryAddress = null; + try { - // Check local first + // 等待所有轮询完成 synchronized (localInfo) { while (!localInfo.complete) { localInfo.wait(); } - - if (localInfo.returnedDetails != null) { - localInfo.returnedDetails.activeAddress = localInfo.address; - return localInfo.returnedDetails; - } } - - // Now manual synchronized (manualInfo) { while (!manualInfo.complete) { manualInfo.wait(); } - - if (manualInfo.returnedDetails != null) { - manualInfo.returnedDetails.activeAddress = manualInfo.address; - return manualInfo.returnedDetails; - } } - - // Now remote IPv4 synchronized (remoteInfo) { while (!remoteInfo.complete) { remoteInfo.wait(); } - - if (remoteInfo.returnedDetails != null) { - remoteInfo.returnedDetails.activeAddress = remoteInfo.address; - return remoteInfo.returnedDetails; - } } - - // Now global IPv6 synchronized (ipv6Info) { while (!ipv6Info.complete) { ipv6Info.wait(); } + } - if (ipv6Info.returnedDetails != null) { - ipv6Info.returnedDetails.activeAddress = ipv6Info.address; - return ipv6Info.returnedDetails; + // 按优先级收集所有成功的地址 + if (localInfo.returnedDetails != null) { + result = localInfo.returnedDetails; + primaryAddress = localInfo.address; + result.addAvailableAddress(localInfo.address); + } + if (manualInfo.returnedDetails != null) { + if (result == null) { + result = manualInfo.returnedDetails; + primaryAddress = manualInfo.address; } + result.addAvailableAddress(manualInfo.address); } + if (remoteInfo.returnedDetails != null) { + if (result == null) { + result = remoteInfo.returnedDetails; + primaryAddress = remoteInfo.address; + } + result.addAvailableAddress(remoteInfo.address); + } + if (ipv6Info.returnedDetails != null) { + if (result == null) { + result = ipv6Info.returnedDetails; + primaryAddress = ipv6Info.address; + } + result.addAvailableAddress(ipv6Info.address); + } + + // 设置主要活跃地址 + if (result != null && primaryAddress != null) { + result.activeAddress = primaryAddress; + } + } finally { // Stop any further polling if we've found a working address or we've been // interrupted by an attempt to stop polling. @@ -695,7 +715,7 @@ private ComputerDetails parallelPollPc(ComputerDetails details) throws Interrupt ipv6Info.interrupt(); } - return null; + return result; } private boolean pollComputer(ComputerDetails details) throws InterruptedException { @@ -867,7 +887,7 @@ public void run() { PollingTuple tuple = getPollingTuple(computer); try { - NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(), + NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(), "", computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this)); String appList; @@ -925,6 +945,11 @@ else if (appList.isEmpty()) { e.printStackTrace(); } catch (XmlPullParserException e) { e.printStackTrace(); + } catch (InterruptedException e) { + // The thread was interrupted. Stop polling. + LimeLog.info("App list polling thread interrupted for " + computer.name); + Thread.currentThread().interrupt(); // Restore the interrupted status + break; } } while (waitPollingDelay()); } diff --git a/app/src/main/java/com/limelight/dialogs/AddressSelectionDialog.java b/app/src/main/java/com/limelight/dialogs/AddressSelectionDialog.java new file mode 100644 index 0000000000..bf7e1b469d --- /dev/null +++ b/app/src/main/java/com/limelight/dialogs/AddressSelectionDialog.java @@ -0,0 +1,203 @@ +package com.limelight.dialogs; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; + +import java.util.List; + +public class AddressSelectionDialog { + + public interface OnAddressSelectedListener { + void onAddressSelected(ComputerDetails.AddressTuple address); + } + + private AlertDialog dialog; + private ComputerDetails computerDetails; + private OnAddressSelectedListener listener; + private ComputerDetails.AddressTuple selectedAddress; + private AddressListAdapter adapter; + private ListView addressList; + + public AddressSelectionDialog(Context context, ComputerDetails computerDetails, OnAddressSelectedListener listener) { + this.computerDetails = computerDetails; + this.listener = listener; + this.selectedAddress = computerDetails.activeAddress; // 默认选择当前活跃地址 + + createDialog(context); + } + + private void createDialog(Context context) { + AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.AppDialogStyle); + View dialogView = LayoutInflater.from(context).inflate(R.layout.address_selection_dialog, null); + + // 设置主机名称 + TextView computerNameView = dialogView.findViewById(R.id.computer_name); + computerNameView.setText(computerDetails.name); + + // 设置地址列表 + addressList = dialogView.findViewById(R.id.address_list); + adapter = new AddressListAdapter(context, computerDetails.getAvailableAddresses()); + addressList.setAdapter(adapter); + // 默认选中第一项,避免首次按确认只进入选择态 + if (adapter.getCount() > 0) { + addressList.setSelection(0); + } + + // 设置控制器支持 + setupControllerSupport(); + + // 列表项点击:直接连接 + addressList.setOnItemClickListener((parent, view, position, id) -> { + ComputerDetails.AddressTuple address = (ComputerDetails.AddressTuple) adapter.getItem(position); + if (listener != null) { + listener.onAddressSelected(address); + } + dialog.dismiss(); + }); + + + builder.setView(dialogView); + dialog = builder.create(); + } + + public void show() { + if (dialog != null) { + dialog.show(); + } + } + + public void dismiss() { + if (dialog != null) { + dialog.dismiss(); + } + } + + /** + * 设置控制器支持 + */ + private void setupControllerSupport() { + // 设置ListView的焦点支持 + addressList.setFocusable(true); + addressList.setFocusableInTouchMode(true); + addressList.setClickable(true); + + // 设置初始焦点 + addressList.requestFocus(); + + // 处理ListView的按键事件 + addressList.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + // 只处理确认键选择,让系统处理方向键导航 + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == KeyEvent.KEYCODE_ENTER) { + return handleListViewKeyEvent(keyCode); + } + } + return false; // 让系统处理所有方向键导航 + }); + } + + /** + * 处理ListView的按键事件 + */ + private boolean handleListViewKeyEvent(int keyCode) { + int itemCount = adapter.getCount(); + if (itemCount == 0) return false; + + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + // 直接连接当前项 + int selectedPosition = addressList.getSelectedItemPosition(); + if (selectedPosition >= 0 && selectedPosition < itemCount) { + ComputerDetails.AddressTuple address = (ComputerDetails.AddressTuple) adapter.getItem(selectedPosition); + if (listener != null) { + listener.onAddressSelected(address); + } + dialog.dismiss(); + } + return true; + } + return false; + } + + private class AddressListAdapter extends BaseAdapter { + private Context context; + private List addresses; + + public AddressListAdapter(Context context, List addresses) { + this.context = context; + this.addresses = addresses; + } + + @Override + public int getCount() { + return addresses.size(); + } + + @Override + public Object getItem(int position) { + return addresses.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + + if (convertView == null) { + convertView = LayoutInflater.from(context).inflate(R.layout.address_list_item, parent, false); + holder = new ViewHolder(); + // holder.addressIcon = convertView.findViewById(R.id.address_icon); + holder.addressText = convertView.findViewById(R.id.address_text); + holder.addressType = convertView.findViewById(R.id.address_type); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + ComputerDetails.AddressTuple address = addresses.get(position); + + // 设置地址文本 + holder.addressText.setText(address.toString()); + + // 设置地址类型和图标 + String addressType = computerDetails.getAddressTypeDescription(address); + holder.addressType.setText(addressType); + + // 设置焦点状态 + boolean isFocused = (position == addressList.getSelectedItemPosition()); + convertView.setSelected(isFocused); + + // 设置点击事件 - 直接连接 + convertView.setOnClickListener(v -> { + if (listener != null) { + listener.onAddressSelected(address); + } + dialog.dismiss(); + }); + + return convertView; + } + + private class ViewHolder { + ImageView addressIcon; + TextView addressText; + TextView addressType; + } + } +} diff --git a/app/src/main/java/com/limelight/grid/AppGridAdapter.java b/app/src/main/java/com/limelight/grid/AppGridAdapter.java index de594bba74..c55d946193 100644 --- a/app/src/main/java/com/limelight/grid/AppGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/AppGridAdapter.java @@ -1,6 +1,8 @@ package com.limelight.grid; +import android.app.Activity; import android.content.Context; +import android.content.ContextWrapper; import android.graphics.BitmapFactory; import android.view.View; import android.widget.ImageView; @@ -14,8 +16,10 @@ import com.limelight.grid.assets.DiskAssetLoader; import com.limelight.grid.assets.MemoryAssetLoader; import com.limelight.grid.assets.NetworkAssetLoader; +import com.limelight.grid.assets.ScaledBitmap; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.utils.AppIconCache; import java.util.ArrayList; import java.util.Collections; @@ -27,8 +31,8 @@ @SuppressWarnings("unchecked") public class AppGridAdapter extends GenericGridAdapter { private static final int ART_WIDTH_PX = 300; - private static final int SMALL_WIDTH_DP = 100; - private static final int LARGE_WIDTH_DP = 150; + private static final int SMALL_WIDTH_DP = 120; + private static final int LARGE_WIDTH_DP = 180; private final ComputerDetails computer; private final String uniqueId; @@ -44,7 +48,6 @@ public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDe this.computer = computer; this.uniqueId = uniqueId; this.showHiddenApps = showHiddenApps; - updateLayoutWithPreferences(context, prefs); } @@ -105,7 +108,7 @@ public void updateLayoutWithPreferences(Context context, PreferenceConfiguration cancelQueuedOperations(); } - this.loader = new CachedAppAssetLoader(computer, scalingDivisor, + this.loader = new CachedAppAssetLoader(context, computer, scalingDivisor, new NetworkAssetLoader(context, uniqueId), new MemoryAssetLoader(), new DiskAssetLoader(context), @@ -120,7 +123,11 @@ public void cancelQueuedOperations() { loader.cancelBackgroundLoads(); loader.freeCacheMemory(); } - + + public CachedAppAssetLoader getLoader() { + return loader; + } + private static void sortList(List list) { Collections.sort(list, new Comparator() { @Override @@ -136,16 +143,14 @@ public void addApp(AppView.AppObject app) { // Always add the app to the all apps list allApps.add(app); - sortList(allApps); // Add the app to the adapter data if it's not hidden if (showHiddenApps || !app.isHidden) { // Queue a request to fetch this bitmap into cache loader.queueCacheLoad(app.app); - // Add the app to our sorted list + // Add the app to the list (maintaining server order) itemList.add(app); - sortList(itemList); } } @@ -154,6 +159,28 @@ public void removeApp(AppView.AppObject app) { allApps.remove(app); } + public void rebuildAppList(List newApps) { + // Clear existing lists + allApps.clear(); + itemList.clear(); + + // Add all new apps in server order + for (AppView.AppObject app : newApps) { + // Update hidden state + app.isHidden = hiddenAppIds.contains(app.app.getAppId()); + + // Always add to allApps + allApps.add(app); + + // Add to itemList if not hidden or if showing hidden apps + if (showHiddenApps || !app.isHidden) { + // Queue a request to fetch this bitmap into cache + loader.queueCacheLoad(app.app); + itemList.add(app); + } + } + } + @Override public void clear() { super.clear(); @@ -161,16 +188,39 @@ public void clear() { } @Override - public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) { - // Let the cached asset loader handle it - loader.populateImageView(obj.app, imgView, txtView); + public void populateView(View parentView, ImageView imgView, View spinnerView, TextView txtView, ImageView overlayView, AppView.AppObject obj) { + ImageView appBackgroundImage = getActivity(context).findViewById(R.id.appBackgroundImage); + + // Let the cached asset loader handle it with callback + loader.populateImageView(obj, imgView, txtView, false, () -> { + try { + // 图片加载完成后,尝试从内存缓存获取bitmap并存储到全局缓存 + CachedAppAssetLoader.LoaderTuple tuple = new CachedAppAssetLoader.LoaderTuple(computer, obj.app); + ScaledBitmap scaledBitmap = loader.getBitmapFromCache(tuple); + if (scaledBitmap != null && scaledBitmap.bitmap != null) { + AppIconCache.getInstance().putIcon(computer, obj.app, scaledBitmap.bitmap); + // 添加调试信息 + System.out.println("成功缓存app icon: " + obj.app.getAppName()); + } else { + System.out.println("无法获取app icon进行缓存: " + obj.app.getAppName()); + } + } catch (Exception e) { + System.out.println("缓存app icon时发生异常: " + obj.app.getAppName() + " - " + e.getMessage()); + } + }); if (obj.isRunning) { // Show the play button overlay - overlayView.setImageResource(R.drawable.ic_play); + overlayView.setImageResource(R.drawable.ic_play_cute); overlayView.setVisibility(View.VISIBLE); + // 使用更平滑的背景图片加载 + loader.populateImageView(obj, appBackgroundImage, txtView, true); } else { + if (obj.app.getAppName().equalsIgnoreCase("desktop") && appBackgroundImage.getDrawable() == null) { + // 使用更平滑的背景图片加载 + loader.populateImageView(obj, appBackgroundImage, txtView, true); + } overlayView.setVisibility(View.GONE); } @@ -181,4 +231,12 @@ public void populateView(View parentView, ImageView imgView, ProgressBar prgView parentView.setAlpha(1.0f); } } + public static Activity getActivity(Context context) { + if (context instanceof Activity) { + return (Activity) context; + } else if (context instanceof ContextWrapper) { + return getActivity(((ContextWrapper) context).getBaseContext()); + } + return null; + } } diff --git a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java index cec3de5b7c..22bcb4d303 100644 --- a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java @@ -18,6 +18,8 @@ public abstract class GenericGridAdapter extends BaseAdapter { private int layoutId; final ArrayList itemList = new ArrayList<>(); private final LayoutInflater inflater; + // Track a selected position for UI updates (some activities call setSelectedPosition) + protected int selectedPosition = -1; GenericGridAdapter(Context context, int layoutId) { this.context = context; @@ -26,6 +28,16 @@ public abstract class GenericGridAdapter extends BaseAdapter { this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } + public void setSelectedPosition(int pos) { + this.selectedPosition = pos; + // Let views refresh to reflect selection change if they care + notifyDataSetChanged(); + } + + public int getSelectedPosition() { + return selectedPosition; + } + void setLayoutId(int layoutId) { if (layoutId != this.layoutId) { this.layoutId = layoutId; @@ -54,7 +66,7 @@ public long getItemId(int i) { return i; } - public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj); + public abstract void populateView(View parentView, ImageView imgView, View spinnerView, TextView txtView, ImageView overlayView, T obj); @Override public View getView(int i, View convertView, ViewGroup viewGroup) { @@ -65,9 +77,9 @@ public View getView(int i, View convertView, ViewGroup viewGroup) { ImageView imgView = convertView.findViewById(R.id.grid_image); ImageView overlayView = convertView.findViewById(R.id.grid_overlay); TextView txtView = convertView.findViewById(R.id.grid_text); - ProgressBar prgView = convertView.findViewById(R.id.grid_spinner); + View spinnerView = convertView.findViewById(R.id.grid_spinner); - populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i)); + populateView(convertView, imgView, spinnerView, txtView, overlayView, itemList.get(i)); return convertView; } diff --git a/app/src/main/java/com/limelight/grid/PcGridAdapter.java b/app/src/main/java/com/limelight/grid/PcGridAdapter.java index 91e313f383..2d25f8cd81 100644 --- a/app/src/main/java/com/limelight/grid/PcGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/PcGridAdapter.java @@ -1,6 +1,10 @@ package com.limelight.grid; +import android.annotation.SuppressLint; import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.RelativeSizeSpan; import android.view.View; import android.widget.ImageView; import android.widget.ProgressBar; @@ -48,8 +52,9 @@ public boolean removeComputer(PcView.ComputerObject computer) { return itemList.remove(computer); } + @SuppressLint("SetTextI18n") @Override - public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) { + public void populateView(View parentView, ImageView imgView, View spinnerView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) { imgView.setImageResource(R.drawable.ic_computer); if (obj.details.state == ComputerDetails.State.ONLINE) { imgView.setAlpha(1.0f); @@ -58,25 +63,54 @@ public void populateView(View parentView, ImageView imgView, ProgressBar prgView imgView.setAlpha(0.4f); } + // 设置多层叠加背景效果 + if (obj.details.state == ComputerDetails.State.ONLINE && + obj.details.hasMultipleAddresses()) { + parentView.setBackgroundResource(R.drawable.pc_item_multiple_addresses_selector); + } else { + parentView.setBackgroundResource(R.drawable.pc_item_selector); + } + + // 将View转换为ImageView来处理白色点点点动画 + ImageView spinnerImageView = (ImageView) spinnerView; if (obj.details.state == ComputerDetails.State.UNKNOWN) { - prgView.setVisibility(View.VISIBLE); + spinnerImageView.setVisibility(View.VISIBLE); + // 启动动画 + if (spinnerImageView.getDrawable() instanceof android.graphics.drawable.AnimatedVectorDrawable) { + android.graphics.drawable.AnimatedVectorDrawable animatedDrawable = + (android.graphics.drawable.AnimatedVectorDrawable) spinnerImageView.getDrawable(); + animatedDrawable.start(); + } } else { - prgView.setVisibility(View.INVISIBLE); + spinnerImageView.setVisibility(View.INVISIBLE); + // 停止动画 + if (spinnerImageView.getDrawable() instanceof android.graphics.drawable.AnimatedVectorDrawable) { + android.graphics.drawable.AnimatedVectorDrawable animatedDrawable = + (android.graphics.drawable.AnimatedVectorDrawable) spinnerImageView.getDrawable(); + animatedDrawable.stop(); + } } + SpannableString spannableString = new SpannableString(obj.details.name); +// spannableString.setSpan(new RelativeSizeSpan(1.2f), 0, 2, Spannable.SPAN_INCLUSIVE_INCLUSIVE); +// spannableString.setSpan(new AlignmentSpan.Standard(Gravity.TOP), 0, spannableString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); txtView.setText(obj.details.name); + if (obj.details.state == ComputerDetails.State.ONLINE) { txtView.setAlpha(1.0f); } else { - txtView.setAlpha(0.4f); + txtView.setAlpha(0.3f); } if (obj.details.state == ComputerDetails.State.OFFLINE) { overlayView.setImageResource(R.drawable.ic_pc_offline); - overlayView.setAlpha(0.4f); + overlayView.setAlpha(0.3f); overlayView.setVisibility(View.VISIBLE); + overlayView.setPadding(0, 0, 10, 12); + overlayView.setScaleX(1.4f); + overlayView.setScaleY(1.4f); } // We must check if the status is exactly online and unpaired // to avoid colliding with the loading spinner when status is unknown diff --git a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java index 83253b241a..151921d6ab 100644 --- a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java @@ -10,7 +10,10 @@ import android.view.animation.AnimationUtils; import android.widget.ImageView; import android.widget.TextView; +import android.content.Context; +import com.limelight.AppView; +import com.limelight.LimeLog; import com.limelight.R; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.nvstream.http.NvApp; @@ -31,6 +34,8 @@ public class CachedAppAssetLoader { private static final int MAX_PENDING_NETWORK_LOADS = 40; private static final int MAX_PENDING_DISK_LOADS = 40; + private final Context context; + private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor( MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS, Long.MAX_VALUE, TimeUnit.DAYS, @@ -57,9 +62,10 @@ public class CachedAppAssetLoader { private final Bitmap placeholderBitmap; private final Bitmap noAppImageBitmap; - public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider, + public CachedAppAssetLoader(Context context, ComputerDetails computer, double scalingDivider, NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader, DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) { + this.context = context; this.computer = computer; this.scalingDivider = scalingDivider; this.networkLoader = networkLoader; @@ -92,6 +98,48 @@ public void freeCacheMemory() { memoryLoader.clearCache(); } + public ScaledBitmap getBitmapFromCache(LoaderTuple tuple) { + return diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + } + + /** + * 压缩过大的Bitmap + */ + private Bitmap compressLargeBitmap(Bitmap original) { + if (original == null) return null; + + // 计算Bitmap的内存大小(字节) + int byteCount = original.getByteCount(); + int maxSize = 1024 * 1024; // 1MB限制 + + // 如果大小超过限制,进行压缩 + if (byteCount > maxSize) { + try { + // 计算压缩比例 + float scale = (float) Math.sqrt((double) maxSize / byteCount); + int newWidth = Math.round(original.getWidth() * scale); + int newHeight = Math.round(original.getHeight() * scale); + + // 确保最小尺寸 + newWidth = Math.max(newWidth, 300); + newHeight = Math.max(newHeight, 400); + + // 创建压缩后的Bitmap + Bitmap compressed = Bitmap.createScaledBitmap(original, newWidth, newHeight, true); + + LimeLog.info("Compressed bitmap from " + original.getWidth() + "x" + original.getHeight() + + " to " + newWidth + "x" + newHeight + " (size: " + byteCount + " -> " + compressed.getByteCount() + " bytes)"); + + return compressed; + } catch (Exception e) { + LimeLog.warning("Failed to compress bitmap: " + e.getMessage()); + return original; + } + } + + return original; + } + private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) { // Try 3 times for (int i = 0; i < 3; i++) { @@ -146,13 +194,25 @@ private class LoaderTask extends AsyncTask { private final WeakReference imageViewRef; private final WeakReference textViewRef; private final boolean diskOnly; + private final boolean isBackground; + private final Runnable onLoadComplete; private LoaderTuple tuple; public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) { + this(imageView, textView, diskOnly, false, null); + } + + public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly, boolean isBackground) { + this(imageView, textView, diskOnly, isBackground, null); + } + + public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly, boolean isBackground, Runnable onLoadComplete) { this.imageViewRef = new WeakReference<>(imageView); this.textViewRef = new WeakReference<>(textView); this.diskOnly = diskOnly; + this.isBackground = isBackground; + this.onLoadComplete = onLoadComplete; } @Override @@ -178,7 +238,16 @@ protected ScaledBitmap doInBackground(LoaderTuple... params) { // Cache the bitmap if (bmp != null) { - memoryLoader.populateCache(tuple, bmp); + // 压缩过大的Bitmap + Bitmap compressedBitmap = compressLargeBitmap(bmp.bitmap); + if (compressedBitmap != bmp.bitmap) { + // 如果压缩了,创建新的ScaledBitmap + ScaledBitmap compressedScaledBitmap = new ScaledBitmap(bmp.originalWidth, bmp.originalHeight, compressedBitmap); + memoryLoader.populateCache(tuple, compressedScaledBitmap); + } else { + // 如果没有压缩,使用原始Bitmap + memoryLoader.populateCache(tuple, bmp); + } } return bmp; @@ -197,12 +266,16 @@ protected void onProgressUpdate(Void... nothing) { if (getLoaderTask(imageView) == this) { // Set off another loader task on the network executor. This time our AsyncDrawable // will use the app image placeholder bitmap, rather than an empty bitmap. - LoaderTask task = new LoaderTask(imageView, textView, false); + LoaderTask task = new LoaderTask(imageView, textView, false, isBackground); AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task); imageView.setImageDrawable(asyncDrawable); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + // Use different animation for background images + int animationRes = isBackground ? R.anim.background_fadein : R.anim.boxart_fadein; + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), animationRes)); imageView.setVisibility(View.VISIBLE); - textView.setVisibility(View.VISIBLE); + if (textView != null) { + textView.setVisibility(View.VISIBLE); + } task.executeOnExecutor(networkExecutor, tuple); } } @@ -220,11 +293,14 @@ protected void onPostExecute(final ScaledBitmap bitmap) { // Fade in the box art if (bitmap != null) { // Show the text if it's a placeholder - textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); + if (textView != null) { + textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); + } if (imageView.getVisibility() == View.VISIBLE) { // Fade out the placeholder first - Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout); + int fadeOutAnimRes = isBackground ? R.anim.background_fadeout : R.anim.boxart_fadeout; + Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), fadeOutAnimRes); fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} @@ -233,7 +309,8 @@ public void onAnimationStart(Animation animation) {} public void onAnimationEnd(Animation animation) { // Fade in the new box art imageView.setImageBitmap(bitmap.bitmap); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + int fadeInAnimRes = isBackground ? R.anim.background_fadein : R.anim.boxart_fadein; + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), fadeInAnimRes)); } @Override @@ -244,10 +321,16 @@ public void onAnimationRepeat(Animation animation) {} else { // View is invisible already, so just fade in the new art imageView.setImageBitmap(bitmap.bitmap); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + int fadeInAnimRes = isBackground ? R.anim.background_fadein : R.anim.boxart_fadein; + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), fadeInAnimRes)); imageView.setVisibility(View.VISIBLE); } } + + // 如果提供了回调,执行它 + if (onLoadComplete != null) { + onLoadComplete.run(); + } } } } @@ -331,8 +414,16 @@ private boolean isBitmapPlaceholder(ScaledBitmap bitmap) { (bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0 } - public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) { - LoaderTuple tuple = new LoaderTuple(computer, app); + public boolean populateImageView(AppView.AppObject obj, ImageView imgView, TextView textView) { + return populateImageView(obj, imgView, textView, false); + } + + public boolean populateImageView(AppView.AppObject obj, ImageView imgView, TextView textView, boolean isBackground) { + return populateImageView(obj, imgView, textView, isBackground, null); + } + + public boolean populateImageView(AppView.AppObject obj, ImageView imgView, TextView textView, boolean isBackground, Runnable onLoadComplete) { + LoaderTuple tuple = new LoaderTuple(computer, obj.app); // If there's already a task in progress for this view, // cancel it. If the task is already loading the same image, @@ -342,25 +433,34 @@ public boolean populateImageView(NvApp app, ImageView imgView, TextView textView } // Always set the name text so we have it if needed later - textView.setText(app.getAppName()); - + if (textView != null) { + textView.setText(obj.app.getAppName()); + } // First, try the memory cache in the current context ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple); if (bmp != null) { // Show the bitmap immediately imgView.setVisibility(View.VISIBLE); imgView.setImageBitmap(bmp.bitmap); - // Show the text if it's a placeholder bitmap - textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE); + if (textView != null) { + textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE); + } + + // 如果提供了回调,执行它 + if (onLoadComplete != null) { + onLoadComplete.run(); + } return true; } // If it's not in memory, create an async task to load it. This task will be attached // via AsyncDrawable to this view. - final LoaderTask task = new LoaderTask(imgView, textView, true); + final LoaderTask task = new LoaderTask(imgView, textView, true, isBackground, onLoadComplete); final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task); - textView.setVisibility(View.INVISIBLE); + if (textView != null) { + textView.setVisibility(View.INVISIBLE); + } imgView.setVisibility(View.INVISIBLE); imgView.setImageDrawable(asyncDrawable); diff --git a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java index dc496a6bbd..1e39d445fc 100644 --- a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java @@ -16,8 +16,8 @@ import java.io.OutputStream; public class DiskAssetLoader { - // 5 MB - private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024; + // 20 MB + private static final long MAX_ASSET_SIZE = 20 * 1024 * 1024; // Standard box art is 300x400 private static final int STANDARD_ASSET_WIDTH = 300; @@ -91,8 +91,8 @@ public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, // Load the image scaled to the appropriate size BitmapFactory.Options options = new BitmapFactory.Options(); options.inSampleSize = calculateInSampleSize(decodeOnlyOptions, - STANDARD_ASSET_WIDTH / sampleSize, - STANDARD_ASSET_HEIGHT / sampleSize); + decodeOnlyOptions.outWidth / sampleSize, + decodeOnlyOptions.outHeight / sampleSize); if (isLowRamDevice) { options.inPreferredConfig = Bitmap.Config.RGB_565; options.inDither = true; @@ -104,6 +104,14 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options); if (bmp != null) { LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize); + + // 检查并压缩过大的Bitmap + Bitmap compressedBmp = compressLargeBitmap(bmp); + if (compressedBmp != bmp) { + bmp.recycle(); // 回收原始Bitmap + bmp = compressedBmp; + } + return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp); } } @@ -117,12 +125,22 @@ public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo im scaledBitmap.originalWidth = imageInfo.getSize().getWidth(); scaledBitmap.originalHeight = imageInfo.getSize().getHeight(); - imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT); + // imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT); if (isLowRamDevice) { imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM); } } }); + + // 检查并压缩过大的Bitmap + if (scaledBitmap.bitmap != null) { + Bitmap compressedBmp = compressLargeBitmap(scaledBitmap.bitmap); + if (compressedBmp != scaledBitmap.bitmap) { + scaledBitmap.bitmap.recycle(); // 回收原始Bitmap + scaledBitmap.bitmap = compressedBmp; + } + } + return scaledBitmap; } catch (IOException e) { e.printStackTrace(); @@ -132,6 +150,44 @@ public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo im return null; } + + /** + * 压缩过大的Bitmap + */ + private Bitmap compressLargeBitmap(Bitmap original) { + if (original == null) return null; + + // 计算Bitmap的内存大小(字节) + int byteCount = original.getByteCount(); + int maxSize = 1024 * 1024; // 1MB限制 + + // 如果大小超过限制,进行压缩 + if (byteCount > maxSize) { + try { + // 计算压缩比例 + float scale = (float) Math.sqrt((double) maxSize / byteCount); + int newWidth = Math.round(original.getWidth() * scale); + int newHeight = Math.round(original.getHeight() * scale); + + // 确保最小尺寸 + newWidth = Math.max(newWidth, 300); + newHeight = Math.max(newHeight, 400); + + // 创建压缩后的Bitmap + Bitmap compressed = Bitmap.createScaledBitmap(original, newWidth, newHeight, true); + + LimeLog.info("DiskAssetLoader: Compressed bitmap from " + original.getWidth() + "x" + original.getHeight() + + " to " + newWidth + "x" + newHeight + " (size: " + byteCount + " -> " + compressed.getByteCount() + " bytes)"); + + return compressed; + } catch (Exception e) { + LimeLog.warning("DiskAssetLoader: Failed to compress bitmap: " + e.getMessage()); + return original; + } + } + + return original; + } public File getFile(String computerUuid, int appId) { return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png"); diff --git a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java index d75b4c5440..c82c271638 100644 --- a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java @@ -23,9 +23,11 @@ public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) { InputStream in = null; try { NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), - tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert, + tuple.computer.httpsPort, uniqueId, "", tuple.computer.serverCert, PlatformBinding.getCryptoProvider(context)); in = http.getBoxArt(tuple.app); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore interrupt status } catch (IOException ignored) {} if (in != null) { diff --git a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java index d6b9224273..2442fa634b 100644 --- a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java +++ b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java @@ -31,4 +31,12 @@ public class ConnectionContext { public int negotiatedPacketSize; public int videoCapabilities; + + // 设备亮度范围 + public int minBrightness; + public int maxBrightness; + public int maxAverageBrightness; + + // 选择的显示器名称 + public String displayName; } diff --git a/app/src/main/java/com/limelight/nvstream/NvConnection.java b/app/src/main/java/com/limelight/nvstream/NvConnection.java index f29563f1c1..041e169795 100644 --- a/app/src/main/java/com/limelight/nvstream/NvConnection.java +++ b/app/src/main/java/com/limelight/nvstream/NvConnection.java @@ -10,6 +10,7 @@ import android.net.NetworkInfo; import android.net.RouteInfo; import android.os.Build; +import android.provider.Settings; import java.io.IOException; import java.net.InetAddress; @@ -19,8 +20,6 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.cert.X509Certificate; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.Semaphore; import javax.crypto.KeyGenerator; @@ -44,30 +43,112 @@ public class NvConnection { // Context parameters private LimelightCryptoProvider cryptoProvider; private String uniqueId; + private String clientName; private ConnectionContext context; private static Semaphore connectionAllowed = new Semaphore(1); private final boolean isMonkey; private final Context appContext; + private ComputerDetails.AddressTuple host; - public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert) + public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, String pairName, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert) + { + this(appContext, host, httpsPort, uniqueId, pairName, config, cryptoProvider, serverCert, null); + } + + public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, String pairName, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert, String displayName) { this.appContext = appContext; + this.host = host; this.cryptoProvider = cryptoProvider; this.uniqueId = uniqueId; + this.clientName = !pairName.isEmpty() ? pairName : Settings.Global.getString(appContext.getContentResolver(), "device_name"); this.context = new ConnectionContext(); this.context.serverAddress = host; this.context.httpsPort = httpsPort; this.context.streamConfig = config; this.context.serverCert = serverCert; + this.context.displayName = displayName; // This is unique per connection this.context.riKey = generateRiAesKey(); this.context.riKeyId = generateRiKeyId(); + + // 获取设备亮度范围(min, max, maxAverage) + int[] brightnessRange = getBrightnessRange(appContext); + this.context.minBrightness = brightnessRange[0]; + this.context.maxBrightness = brightnessRange[1]; + this.context.maxAverageBrightness = brightnessRange[2]; this.isMonkey = ActivityManager.isUserAMonkey(); } + /** + * 获取设备屏幕亮度的最小值、最大值与最大平均亮度(以 nits 为单位) + * @param context 应用上下文 + * @return 数组,[0]=最小亮度 nits, [1]=最大亮度 nits, [2]=最大平均亮度 nits (用于 HDR 平均亮度参考) + */ + private static int[] getBrightnessRange(Context context) { + int minBrightness = 2; // 大多数设备最低亮度约2 nits + int maxBrightness = 500; // 默认SDR最大亮度约500 nits + int maxAverageBrightness = 200; // 默认最大平均亮度 + // Try to obtain a Display regardless of whether we have an Activity. + // Some callers pass the application context, so relying on instanceof Activity + // causes us to always fall back to defaults. + if (context == null) { + LimeLog.info("Device brightness range: " + minBrightness + " - " + maxBrightness + " nits (default, null context)"); + return new int[]{minBrightness, maxBrightness}; + } + + try { + Object wmObj = context.getSystemService(Context.WINDOW_SERVICE); + if (wmObj == null) { + LimeLog.info("WindowManager unavailable, using default brightness range"); + return new int[]{minBrightness, maxBrightness}; + } + + android.view.WindowManager wm = (android.view.WindowManager) wmObj; + android.view.Display display = wm.getDefaultDisplay(); + if (display == null) { + LimeLog.info("Display unavailable, using default brightness range"); + return new int[]{minBrightness, maxBrightness}; + } + + // Android 7.0+ 可以获取 HDR 信息(以 nits 为单位) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + android.view.Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + if (hdrCaps != null) { + float maxLuminance = hdrCaps.getDesiredMaxLuminance(); + float minLuminance = hdrCaps.getDesiredMinLuminance(); + float maxAvgLuminance = hdrCaps.getDesiredMaxAverageLuminance(); + + // 有些设备/实现可能返回 0 或 NaN,当值合理时才使用 + if (Float.isFinite(maxLuminance) && maxLuminance > 0.1f) { + // 向上取整以避免过小 + maxBrightness = Math.max(maxBrightness, (int) Math.ceil(maxLuminance)); + } + if (Float.isFinite(maxAvgLuminance) && maxAvgLuminance > 0.1f) { + maxAverageBrightness = Math.max(maxAverageBrightness, (int) Math.ceil(maxAvgLuminance)); + } + if (Float.isFinite(minLuminance) && minLuminance >= 0f) { + minBrightness = Math.max(1, (int) Math.floor(minLuminance)); + } + + LimeLog.info("HDR capabilities - Max: " + maxLuminance + " nits, Min: " + minLuminance + " nits, MaxAvg: " + maxAvgLuminance + " nits"); + } + } + + LimeLog.info("Device brightness range: " + minBrightness + " - " + maxBrightness + " nits"); + } catch (Exception e) { + LimeLog.warning("Failed to get brightness range, using defaults: " + e.getMessage()); + minBrightness = 2; + maxBrightness = 500; + maxAverageBrightness = 200; + } + + return new int[]{minBrightness, maxBrightness, maxAverageBrightness}; + } + private static SecretKey generateRiAesKey() { try { KeyGenerator keyGen = KeyGenerator.getInstance("AES"); @@ -219,9 +300,8 @@ else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { return StreamConfiguration.STREAM_CFG_AUTO; } - private boolean startApp() throws XmlPullParserException, IOException - { - NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider); + private boolean startApp() throws XmlPullParserException, IOException, InterruptedException { + NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, clientName, context.serverCert, cryptoProvider); String serverInfo = h.getServerInfo(true); @@ -255,17 +335,17 @@ private boolean startApp() throws XmlPullParserException, IOException // // Check for a supported stream resolution - if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && + if ((context.streamConfig.getReqWidth() > 4096 || context.streamConfig.getReqHeight() > 4096) && (h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) { context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K."); return false; } - else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && + else if ((context.streamConfig.getReqWidth() > 4096 || context.streamConfig.getReqHeight() > 4096) && (context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) { context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K."); return false; } - else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) { + else if (context.streamConfig.getReqHeight() >= 2160 && !h.supports4K(serverInfo)) { // Client wants 4K but the server can't do it context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p."); @@ -345,7 +425,7 @@ else if (e.getErrorCode() == 525) { } protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException, - XmlPullParserException { + XmlPullParserException, InterruptedException { try { if (!h.quitApp()) { context.connListener.displayMessage("Failed to quit previous session! You must quit it manually"); @@ -367,7 +447,7 @@ protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOEx } private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context) - throws IOException, XmlPullParserException { + throws IOException, XmlPullParserException, InterruptedException { // Launch the app since it's not running if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) { context.connListener.displayMessage("Failed to launch application"); @@ -381,70 +461,75 @@ private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context) public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener) { - new Thread(new Runnable() { - public void run() { - context.connListener = connectionListener; - context.videoCapabilities = videoDecoderRenderer.getCapabilities(); + new Thread(() -> { + context.connListener = connectionListener; + context.videoCapabilities = videoDecoderRenderer.getCapabilities(); - String appName = context.streamConfig.getApp().getAppName(); + String appName = context.streamConfig.getApp().getAppName(); - context.connListener.stageStarting(appName); + context.connListener.stageStarting(appName); - try { - if (!startApp()) { - context.connListener.stageFailed(appName, 0, 0); - return; - } - context.connListener.stageComplete(appName); - } catch (HostHttpResponseException e) { - e.printStackTrace(); - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, 0, e.getErrorCode()); - return; - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0); + try { + if (!startApp()) { + context.connListener.stageFailed(appName, 0, 0); return; } + context.connListener.stageComplete(appName); + } catch (HostHttpResponseException e) { + e.printStackTrace(); + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, 0, e.getErrorCode()); + return; + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0); + return; + } catch (InterruptedException e) { + // Thread was interrupted during app start + context.connListener.displayMessage("Connection interrupted"); + context.connListener.stageFailed(appName, 0, 0); + return; + } - ByteBuffer ib = ByteBuffer.allocate(16); - ib.putInt(context.riKeyId); + ByteBuffer ib = ByteBuffer.allocate(16); + ib.putInt(context.riKeyId); - // Acquire the connection semaphore to ensure we only have one - // connection going at once. - try { - connectionAllowed.acquire(); - } catch (InterruptedException e) { - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, 0, 0); - return; - } + // Acquire the connection semaphore to ensure we only have one + // connection going at once. + try { + connectionAllowed.acquire(); + } catch (InterruptedException e) { + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, 0, 0); + return; + } - // Moonlight-core is not thread-safe with respect to connection start and stop, so - // we must not invoke that functionality in parallel. - synchronized (MoonBridge.class) { - MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener); - int ret = MoonBridge.startConnection(context.serverAddress.address, - context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl, - context.serverCodecModeSupport, - context.negotiatedWidth, context.negotiatedHeight, - context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(), - context.negotiatedPacketSize, context.negotiatedRemoteStreaming, - context.streamConfig.getAudioConfiguration().toInt(), - context.streamConfig.getSupportedVideoFormats(), - context.streamConfig.getClientRefreshRateX100(), - context.riKey.getEncoded(), ib.array(), - context.videoCapabilities, - context.streamConfig.getColorSpace(), - context.streamConfig.getColorRange()); - if (ret != 0) { - // LiStartConnection() failed, so the caller is not expected - // to stop the connection themselves. We need to release their - // semaphore count for them. - connectionAllowed.release(); - return; - } + // Moonlight-core is not thread-safe with respect to connection start and stop, so + // we must not invoke that functionality in parallel. + synchronized (MoonBridge.class) { + MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener); + int ret = MoonBridge.startConnection(context.serverAddress.address, + context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl, + context.serverCodecModeSupport, + context.negotiatedWidth, context.negotiatedHeight, + context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(), + context.negotiatedPacketSize, context.negotiatedRemoteStreaming, + context.streamConfig.getAudioConfiguration().toInt(), + context.streamConfig.getSupportedVideoFormats(), + context.streamConfig.getClientRefreshRateX100(), + context.riKey.getEncoded(), ib.array(), + context.videoCapabilities, + context.streamConfig.getColorSpace(), + context.streamConfig.getColorRange(), + context.streamConfig.getEnableMic(), + context.streamConfig.getControlOnly()); + if (ret != 0) { + // LiStartConnection() failed, so the caller is not expected + // to stop the connection themselves. We need to release their + // semaphore count for them. + connectionAllowed.release(); + return; } } }).start(); @@ -588,4 +673,120 @@ public void sendUtf8Text(final String text) { public static String findExternalAddressForMdns(String stunHostname, int stunPort) { return MoonBridge.findExternalAddressIP4(stunHostname, stunPort); } + + public void doStopAndQuit() throws IOException, XmlPullParserException { + this.stop(); + new Thread(() -> { + NvHTTP h; + try { + h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, clientName, context.serverCert, cryptoProvider); + } catch (IOException e) { + throw new RuntimeException(e); + } + try { + h.quitApp(); + } catch (InterruptedException e) { + // Thread was interrupted during quit, just return + LimeLog.info("Quit app interrupted"); + } catch (IOException | XmlPullParserException e) { + e.printStackTrace(); + } + }).start(); + } + + public void sendSuperCmd(String cmdId) throws IOException, XmlPullParserException { + new Thread(() -> { + NvHTTP h; + try { + h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, clientName, context.serverCert, cryptoProvider); + } catch (IOException e) { + throw new RuntimeException(e); + } + try { + h.sendSuperCmd(cmdId); + } catch (InterruptedException e) { + // Thread was interrupted during super command, just return + LimeLog.info("Send super command interrupted"); + } catch (IOException | XmlPullParserException e) { + e.printStackTrace(); + } + }).start(); + } + + /** + * 发送码率调整命令 + * @param bitrateKbps 目标码率(kbps) + * @param callback 回调接口,用于通知结果 + */ + public void setBitrate(int bitrateKbps, BitrateAdjustmentCallback callback) throws IOException, XmlPullParserException { + new Thread(() -> { + NvHTTP h; + try { + h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, clientName, context.serverCert, cryptoProvider); + LimeLog.info("NvHTTP created successfully for bitrate adjustment"); + } catch (IOException e) { + // 记录错误但不抛出RuntimeException,避免应用崩溃 + LimeLog.warning("Failed to create NvHTTP for bitrate adjustment: " + e.getMessage()); + if (callback != null) { + callback.onFailure("Failed to create HTTP connection: " + e.getMessage()); + } + return; + } + try { + LimeLog.info("Sending bitrate adjustment request..."); + boolean success = h.setBitrate(bitrateKbps); + if (success) { + // 更新本地配置 + context.streamConfig.setBitrate(bitrateKbps); + LimeLog.info("Bitrate adjustment successful, updated local config to " + bitrateKbps + " kbps"); + if (callback != null) { + callback.onSuccess(bitrateKbps); + } + } else { + LimeLog.warning("Bitrate adjustment request failed (server returned false)"); + if (callback != null) { + callback.onFailure("Server returned failure response"); + } + } + } catch (InterruptedException e) { + // Thread was interrupted during bitrate adjustment + Thread.currentThread().interrupt(); // Restore interrupt status + LimeLog.warning("Bitrate adjustment interrupted: " + e.getMessage()); + if (callback != null) { + callback.onFailure("Operation interrupted"); + } + } catch (IOException | XmlPullParserException e) { + // 记录错误但不抛出RuntimeException,避免应用崩溃 + LimeLog.warning("Failed to set bitrate: " + e.getMessage()); + e.printStackTrace(); + if (callback != null) { + callback.onFailure("Network error: " + e.getMessage()); + } + } + }).start(); + } + + /** + * 码率调整回调接口 + */ + public interface BitrateAdjustmentCallback { + void onSuccess(int newBitrate); + void onFailure(String errorMessage); + } + + /** + * 获取主机地址 + * @return 主机地址字符串 + */ + public String getHost() { + return context.serverAddress.address; + } + + /** + * 获取当前码率 + * @return 当前码率(kbps) + */ + public int getCurrentBitrate() { + return context.streamConfig.getBitrate(); + } } diff --git a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java index 317f9381c1..f35fe0ef4a 100644 --- a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java +++ b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java @@ -16,6 +16,7 @@ public class StreamConfiguration { private int launchRefreshRate; private int clientRefreshRateX100; private int bitrate; + private int hostResolutionScaleX100; private boolean sops; private boolean enableAdaptiveResolution; private boolean playLocalAudio; @@ -28,6 +29,10 @@ public class StreamConfiguration { private int colorRange; private int colorSpace; private boolean persistGamepadsAfterDisconnect; + private boolean enableMic; + private boolean useVdd; + private boolean controlOnly; + private int customScreenMode; public static class Builder { private StreamConfiguration config = new StreamConfiguration(); @@ -62,6 +67,11 @@ public StreamConfiguration.Builder setBitrate(int bitrate) { config.bitrate = bitrate; return this; } + + public StreamConfiguration.Builder setResolutionScale(int scale) { + config.hostResolutionScaleX100 = scale; + return this; + } public StreamConfiguration.Builder setEnableSops(boolean enable) { config.sops = enable; @@ -128,6 +138,26 @@ public StreamConfiguration.Builder setColorSpace(int colorSpace) { return this; } + public StreamConfiguration.Builder setUseVdd(boolean value) { + config.useVdd = value; + return this; + } + + public StreamConfiguration.Builder setEnableMic(boolean enable) { + config.enableMic = enable; + return this; + } + + public StreamConfiguration.Builder setControlOnly(boolean controlOnly) { + config.controlOnly = controlOnly; + return this; + } + + public StreamConfiguration.Builder setCustomScreenMode(int customScreenMode) { + config.customScreenMode = customScreenMode; + return this; + } + public StreamConfiguration build() { return config; } @@ -141,6 +171,7 @@ private StreamConfiguration() { this.refreshRate = 60; this.launchRefreshRate = 60; this.bitrate = 10000; + this.hostResolutionScaleX100 = 100; this.maxPacketSize = 1024; this.remote = STREAM_CFG_AUTO; this.sops = true; @@ -148,8 +179,11 @@ private StreamConfiguration() { this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO; this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; this.attachedGamepadMask = 0; + this.enableMic = false; + this.useVdd = false; + this.controlOnly = false; + this.customScreenMode = -1; } - public int getWidth() { return width; } @@ -157,6 +191,18 @@ public int getWidth() { public int getHeight() { return height; } + + public int getReqWidth() { + return width * hostResolutionScaleX100 / 100; + } + + public int getReqHeight() { + return height * hostResolutionScaleX100 / 100; + } + + public int getResolutionScale() { + return hostResolutionScaleX100; + } public int getRefreshRate() { return refreshRate; @@ -170,6 +216,10 @@ public int getBitrate() { return bitrate; } + public void setBitrate(int bitrate) { + this.bitrate = bitrate; + } + public int getMaxPacketSize() { return maxPacketSize; } @@ -221,4 +271,14 @@ public int getColorRange() { public int getColorSpace() { return colorSpace; } + + public boolean getEnableMic() { + return enableMic; + } + + public boolean getUseVdd() { return useVdd; } + + public boolean getControlOnly() { return controlOnly; } + + public int getCustomScreenMode() { return customScreenMode; } } diff --git a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java index 8c222eeee8..a0f53f276f 100644 --- a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java +++ b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java @@ -11,7 +11,7 @@ public abstract class VideoDecoderRenderer { // for an IDR frame which contains several parameter sets and the I-frame data. public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs); + long receiveTimeUs, long enqueueTimeUs); public abstract void cleanup(); diff --git a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java index 44ed59628d..8cdf4d5d05 100644 --- a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java +++ b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java @@ -1,6 +1,11 @@ package com.limelight.nvstream.http; +import android.content.Context; +import android.content.SharedPreferences; + import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; @@ -70,16 +75,19 @@ public String toString() { // Transient attributes public State state; public AddressTuple activeAddress; + public List availableAddresses; // 存储所有可用的地址 public int httpsPort; public int externalPort; public PairingManager.PairState pairState; public int runningGameId; public String rawAppList; public boolean nvidiaServer; + public boolean useVdd; public ComputerDetails() { // Use defaults state = State.UNKNOWN; + availableAddresses = new ArrayList<>(); } public ComputerDetails(ComputerDetails details) { @@ -145,7 +153,64 @@ else if (this.remoteAddress != null && details.externalPort != 0) { this.pairState = details.pairState; this.runningGameId = details.runningGameId; this.nvidiaServer = details.nvidiaServer; + this.useVdd = details.useVdd; this.rawAppList = details.rawAppList; + if (details.availableAddresses != null) { + this.availableAddresses = new ArrayList<>(details.availableAddresses); + } + } + + /** + * 添加可用地址到列表中 + */ + public void addAvailableAddress(AddressTuple address) { + if (availableAddresses == null) { + availableAddresses = new ArrayList<>(); + } + if (address != null && !availableAddresses.contains(address)) { + availableAddresses.add(address); + } + } + + /** + * 获取所有可用地址 + */ + public List getAvailableAddresses() { + if (availableAddresses == null) { + availableAddresses = new ArrayList<>(); + } + return availableAddresses; + } + + /** + * 检查是否有多个可用地址 + */ + public boolean hasMultipleAddresses() { + return availableAddresses != null && availableAddresses.size() > 1; + } + + /** + * 获取地址类型描述 + */ + public String getAddressTypeDescription(AddressTuple address) { + if (address == null) return ""; + + if (address.equals(localAddress)) { + return "本地网络"; + } else if (address.equals(remoteAddress)) { + return "远程网络"; + } else if (address.equals(manualAddress)) { + return "手动配置"; + } else if (address.equals(ipv6Address)) { + return "IPv6网络"; + } else { + return "其他网络"; + } + } + + public String getPairName(Context context) { + SharedPreferences sharedPreferences = context.getSharedPreferences("pair_name_map", context.MODE_PRIVATE); + return sharedPreferences.getString(uuid, ""); } @Override diff --git a/app/src/main/java/com/limelight/nvstream/http/NvApp.java b/app/src/main/java/com/limelight/nvstream/http/NvApp.java index bb5a1072c7..d5f6fe96a3 100644 --- a/app/src/main/java/com/limelight/nvstream/http/NvApp.java +++ b/app/src/main/java/com/limelight/nvstream/http/NvApp.java @@ -1,12 +1,18 @@ package com.limelight.nvstream.http; +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.limelight.LimeLog; + public class NvApp { private String appName = ""; private int appId; private boolean initialized; private boolean hdrSupported; + private JsonArray cmdList; public NvApp() {} @@ -59,12 +65,22 @@ public boolean isInitialized() { return this.initialized; } + public void setCmdList(String cmdList) { + this.cmdList = new Gson().fromJson(cmdList, JsonArray.class); + } + + public JsonArray getCmdList() { + return this.cmdList; + } + + @NonNull @Override public String toString() { StringBuilder str = new StringBuilder(); str.append("Name: ").append(appName).append("\n"); str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n"); str.append("ID: ").append(appId).append("\n"); + if (cmdList!= null) str.append("Super CMDs: ").append(cmdList.toString()).append("\n"); return str.toString(); } } diff --git a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java index 13dad8be9f..fae4e13454 100644 --- a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java +++ b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java @@ -1,7 +1,7 @@ package com.limelight.nvstream.http; import android.os.Build; - +import android.provider.Settings; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -22,12 +22,18 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.LinkedList; +import java.util.List; import java.util.ListIterator; +import java.util.Objects; import java.util.Stack; import java.util.UUID; import java.util.concurrent.TimeUnit; +import org.json.JSONArray; +import org.json.JSONObject; + import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.KeyManager; @@ -63,6 +69,7 @@ public class NvHTTP { private String uniqueId; private PairingManager pm; + private String clientName; private static final int DEFAULT_HTTPS_PORT = 47984; public static final int DEFAULT_HTTP_PORT = 47989; @@ -189,7 +196,7 @@ public boolean verify(String hostname, SSLSession session) { .build(); } - public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException { + public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException, InterruptedException { if (httpsPort == 0) { // Fetch the HTTPS port if we don't have it already httpsPort = getHttpsPort(openHttpConnectionToString(likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout, @@ -199,11 +206,13 @@ public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException { return new HttpUrl.Builder().scheme("https").host(baseUrlHttp.host()).port(httpsPort).build(); } - public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException { + public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, String clientName, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException { // Use the same UID for all Moonlight clients so we can quit games // started by other Moonlight clients. this.uniqueId = "0123456789ABCDEF"; + if (!clientName.isEmpty()) this.clientName = clientName; + this.serverCert = serverCert; initializeHttpState(cryptoProvider); @@ -298,7 +307,7 @@ private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpRespo } } - public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException { + public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException, InterruptedException { String resp; // If we believe the PC is online, give it a little extra time to respond @@ -391,7 +400,7 @@ public ComputerDetails getComputerDetails(String serverInfo) throws IOException, return details; } - public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException { + public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException, InterruptedException { return getComputerDetails(getServerInfo(likelyOnline)); } @@ -414,11 +423,12 @@ private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) { .addPathSegment(path) .query(query) .addQueryParameter("uniqueid", uniqueId) + .addQueryParameter("clientname", clientName) .addQueryParameter("uuid", UUID.randomUUID().toString()) .build(); } - private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { + private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException, InterruptedException { return openHttpConnection(client, baseUrl, path, null); } @@ -426,7 +436,7 @@ private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, St // on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit. // The initial pair query does require outside action (user entering a PIN) but subsequent pairing // queries do not. - private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { + private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException, InterruptedException { HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query); Request request = new Request.Builder().url(completeUrl).get().build(); Response response = performAndroidTlsHack(client).newCall(request).execute(); @@ -450,11 +460,11 @@ private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, St } } - private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { + private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException, InterruptedException { return openHttpConnectionToString(client, baseUrl, path, null); } - private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { + private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException, InterruptedException { try { ResponseBody resp = openHttpConnection(client, baseUrl, path, query); String respString = resp.string(); @@ -475,12 +485,61 @@ private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, } } + /** + * 发送POST请求到API端点 + * @param client HTTP客户端 + * @param baseUrl 基础URL + * @param path API路径 + * @param jsonData JSON数据 + * @return 响应字符串 + */ + private String openHttpConnectionPost(OkHttpClient client, HttpUrl baseUrl, String path, String jsonData) throws IOException, InterruptedException { + HttpUrl completeUrl = getCompleteUrl(baseUrl, path, null); + + // 构建POST请求 + okhttp3.RequestBody body = okhttp3.RequestBody.create( + okhttp3.MediaType.parse("application/json; charset=utf-8"), + jsonData + ); + + Request request = new Request.Builder() + .url(completeUrl) + .post(body) + .addHeader("Content-Type", "application/json") + .build(); + + Response response = performAndroidTlsHack(client).newCall(request).execute(); + ResponseBody responseBody = response.body(); + + if (response.isSuccessful() && responseBody != null) { + String respString = responseBody.string(); + responseBody.close(); + + if (verbose) { + LimeLog.info(completeUrl.toString() + " -> " + respString); + } + + return respString; + } + + // 处理错误 + if (responseBody != null) { + responseBody.close(); + } + + if (response.code() == 404) { + throw new FileNotFoundException(completeUrl.toString()); + } else { + throw new HostHttpResponseException(response.code(), response.message()); + } + } + public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException { // appversion is present in all supported GFE versions return getXmlString(serverInfo, "appversion", true); } - public PairingManager.PairState getPairState() throws IOException, XmlPullParserException { + public PairingManager.PairState getPairState() throws IOException, XmlPullParserException, InterruptedException { return getPairState(getServerInfo(true)); } @@ -592,7 +651,7 @@ public int getExternalPort(String serverInfo) { * @see #getAppByName(String) for alternative. * @return app details, or null if no app with that ID exists */ - public NvApp getAppById(int appId) throws IOException, XmlPullParserException { + public NvApp getAppById(int appId) throws IOException, XmlPullParserException, InterruptedException { LinkedList appList = getAppList(); for (NvApp appFromList : appList) { if (appFromList.getAppId() == appId) { @@ -611,7 +670,7 @@ public NvApp getAppById(int appId) throws IOException, XmlPullParserException { * @see #getAppById(int) for alternative. * @return app details, or null if no app with that name exists */ - public NvApp getAppByName(String appName) throws IOException, XmlPullParserException { + public NvApp getAppByName(String appName) throws IOException, XmlPullParserException, InterruptedException { LinkedList appList = getAppList(); for (NvApp appFromList : appList) { if (appFromList.getAppName().equalsIgnoreCase(appName)) { @@ -661,6 +720,11 @@ public static LinkedList getAppListByReader(Reader r) throws XmlPullParse app.setAppId(xpp.getText()); } else if (currentTag.peek().equals("IsHdrSupported")) { app.setHdrSupported(xpp.getText().equals("1")); + } else if (currentTag.peek().equals("SuperCmds")) { + String cmdListStr = xpp.getText(); + if (!Objects.equals(cmdListStr, "null")) { + app.setCmdList(xpp.getText()); + } } break; } @@ -687,11 +751,11 @@ public static LinkedList getAppListByReader(Reader r) throws XmlPullParse return appList; } - public String getAppListRaw() throws IOException { + public String getAppListRaw() throws IOException, InterruptedException { return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "applist"); } - public LinkedList getAppList() throws HostHttpResponseException, IOException, XmlPullParserException { + public LinkedList getAppList() throws HostHttpResponseException, IOException, XmlPullParserException, InterruptedException { if (verbose) { // Use the raw function so the app list is printed return getAppListByReader(new StringReader(getAppListRaw())); @@ -703,25 +767,78 @@ public LinkedList getAppList() throws HostHttpResponseException, IOExcept } } - String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException { + String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException, InterruptedException { return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout, baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments); } - String executePairingChallenge() throws HostHttpResponseException, IOException { + String executePairingChallenge() throws HostHttpResponseException, IOException, InterruptedException { return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "pair", "devicename=roth&updateState=1&phrase=pairchallenge"); } - public void unpair() throws IOException { + public void unpair() throws IOException, InterruptedException { openHttpConnectionToString(httpClientLongConnectTimeout, baseUrlHttp, "unpair"); } - public InputStream getBoxArt(NvApp app) throws IOException { + public InputStream getBoxArt(NvApp app) throws IOException, InterruptedException { ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0"); return resp.byteStream(); } + /** + * 获取主机可用的显示器列表 + * @return 显示器列表,每个显示器包含 index 和 name + * @throws IOException 如果请求失败 + * @throws InterruptedException 如果请求被中断 + */ + public static class DisplayInfo { + public int index; + public String name; // 友好名字,用于显示 + public String guid; // GUID,用于传递给服务端 + + public DisplayInfo(int index, String name, String guid) { + this.index = index; + this.name = name; + this.guid = guid; + } + } + + public List getDisplays() throws IOException, InterruptedException { + try { + String jsonStr = openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "displays"); + JSONObject json = new JSONObject(jsonStr); + + int statusCode = json.optInt("status_code", 0); + if (statusCode != 200) { + throw new IOException("Failed to get displays: " + json.optString("status_message", "Unknown error")); + } + + JSONArray displaysArray = json.optJSONArray("displays"); + if (displaysArray == null) { + return new ArrayList<>(); + } + + List displays = new ArrayList<>(displaysArray.length()); + for (int i = 0; i < displaysArray.length(); i++) { + JSONObject displayObj = displaysArray.getJSONObject(i); + + String friendlyName = displayObj.optString("friendly_name", ""); + if (friendlyName.isEmpty()) { + friendlyName = displayObj.optString("display_name", "Display " + (i + 1)); + } + + String guid = displayObj.optString("device_id", ""); + + displays.add(new DisplayInfo(i, friendlyName, guid)); + } + + return displays; + } catch (org.json.JSONException e) { + throw new IOException("Failed to parse displays response: " + e.getMessage(), e); + } + } + public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException { return getServerAppVersionQuad(serverInfo)[0]; } @@ -753,7 +870,7 @@ private static String bytesToHex(byte[] bytes) { return new String(hexChars); } - public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException { + public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException, InterruptedException { // Using an FPS value over 60 causes SOPS to default to 720p60, // so force it to 0 to ensure the correct resolution is set. We // used to use 60 here but that locked the frame rate to 60 FPS @@ -776,10 +893,10 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool } } - String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, - "appid=" + appId + - "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps + + String queryParams = "appid=" + appId + + "&mode=" + context.streamConfig.getReqWidth() + "x" + context.streamConfig.getReqHeight() + "x" + fps + "&additionalStates=1&sops=" + (enableSops ? 1 : 0) + + "&resolutionScale=" + context.streamConfig.getResolutionScale() + "&rikey="+bytesToHex(context.riKey.getEncoded()) + "&rikeyid="+context.riKeyId + (!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") + @@ -787,8 +904,24 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool "&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() + "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() + "&gcmap=" + context.streamConfig.getAttachedGamepadMask() + - "&gcpersist="+(context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + - MoonBridge.getLaunchUrlQueryParameters()); + "&gcpersist=" + (context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + "&useVdd="+(context.streamConfig.getUseVdd() ? 1 : 0) + + "&minBrightness=" + context.minBrightness + + "&maxBrightness=" + context.maxBrightness + + "&maxAverageBrightness=" + context.maxAverageBrightness; + + int customScreenMode = context.streamConfig.getCustomScreenMode(); + if (customScreenMode != -1) { + queryParams += "&customScreenMode=" + customScreenMode; + } + + // 如果指定了显示器GUID,添加到查询参数中 + if (context.displayName != null && !context.displayName.isEmpty()) { + queryParams += "&display_name=" + context.displayName; + } + + queryParams += MoonBridge.getLaunchUrlQueryParameters(); + + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, queryParams); if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") || (verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) { // sessionUrl0 will be missing for older GFE versions @@ -800,7 +933,7 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool } } - public boolean quitApp() throws IOException, XmlPullParserException { + public boolean quitApp() throws IOException, XmlPullParserException, InterruptedException { String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "cancel"); if (getXmlString(xmlStr, "cancel", true).equals("0")) { return false; @@ -816,4 +949,31 @@ public boolean quitApp() throws IOException, XmlPullParserException { return true; } + + public boolean pcSleep() throws IOException, XmlPullParserException, InterruptedException { + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "pcsleep"); + return !getXmlString(xmlStr, "pcsleep", true).equals("0"); + } + + public boolean sendSuperCmd(String cmdId) throws IOException, XmlPullParserException, InterruptedException { + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "supercmd", "cmdId=" + cmdId); + return !getXmlString(xmlStr, "supercmd", true).equals("0"); + } + + /** + * 发送码率调整命令 + * @param bitrateKbps 目标码率(kbps) + * @return 是否成功 + */ + public boolean setBitrate(int bitrateKbps) throws IOException, XmlPullParserException, InterruptedException { + // 构建查询参数 + String query = String.format("bitrate=%d", bitrateKbps); + + // 调用主机端API: GET /api/stream/bitrate?bitrate=xxx + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, + getHttpsUrl(true), "bitrate", query); + + // 检查响应是否成功 + return !getXmlString(xmlStr, "bitrate", true).equals("0"); + } } diff --git a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java index 65994b61ca..f26707ff55 100644 --- a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java +++ b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java @@ -182,115 +182,134 @@ public X509Certificate getPairedCert() { return serverCert; } - public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException { + /** + * 配对处理 + * @param serverInfo 服务端信息 + * @param pin 配对PIN码 + * @return PairResult 包含配对状态和pairname + * @throws IOException + * @throws XmlPullParserException + */ + public PairResult pair(String serverInfo, String pin) throws IOException, XmlPullParserException, InterruptedException { PairingHashAlgorithm hashAlgo; int serverMajorVersion = http.getServerMajorVersion(serverInfo); - LimeLog.info("Pairing with server generation: "+serverMajorVersion); + LimeLog.info("Pairing with server generation: " + serverMajorVersion); if (serverMajorVersion >= 7) { // Gen 7+ uses SHA-256 hashing hashAlgo = new Sha256PairingHash(); - } - else { + } else { // Prior to Gen 7, SHA-1 is used hashAlgo = new Sha1PairingHash(); } - - // Generate a salt for hashing the PIN + + // 生成用于PIN哈希的salt byte[] salt = generateRandomBytes(16); - // Combine the salt and pin, then create an AES key from them + // 合并salt和pin,然后用它们生成AES密钥 byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin)); - - // Send the salt and get the server cert. This doesn't have a read timeout - // because the user must enter the PIN before the server responds - String getCert = http.executePairingCommand("phrase=getservercert&salt="+ - bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes), + + // 发送salt并获取服务端证书。此处没有读取超时,因为用户必须输入PIN后服务端才会响应 + String getCert = http.executePairingCommand("phrase=getservercert&salt=" + + bytesToHex(salt) + "&clientcert=" + bytesToHex(pemCertBytes), false); if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) { - return PairState.FAILED; + return new PairResult(PairState.FAILED, null); } - // Save this cert for retrieval later + // 获取配对名(pairname),兼容服务端未返回pairname的情况 + String pairName = NvHTTP.getXmlString(getCert, "pairname", false); + + // 保存证书以便后续检索 serverCert = extractPlainCert(getCert); if (serverCert == null) { - // Attempting to pair while another device is pairing will cause GFE - // to give an empty cert in the response. + // 如果有其他设备正在配对,GFE会返回空证书 http.unpair(); - return PairState.ALREADY_IN_PROGRESS; + return new PairResult(PairState.ALREADY_IN_PROGRESS, pairName); } - // Require this cert for TLS to this host + // 要求此证书用于与此主机的TLS通信 http.setServerCert(serverCert); - - // Generate a random challenge and encrypt it with our AES key + + // 生成随机挑战并用AES密钥加密 byte[] randomChallenge = generateRandomBytes(16); byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey); - - // Send the encrypted challenge to the server - String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true); + + // 发送加密挑战到服务端 + String challengeResp = http.executePairingCommand("clientchallenge=" + bytesToHex(encryptedChallenge), true); if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) { http.unpair(); - return PairState.FAILED; + return new PairResult(PairState.FAILED, pairName); } - - // Decode the server's response and subsequent challenge + + // 解码服务端响应和后续挑战 byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true)); byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey); - + byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength()); byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16); - - // Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge + + // 用另一个16字节的secret,结合secret、证书签名和challenge计算挑战响应哈希 byte[] clientSecret = generateRandomBytes(16); byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret)); byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey); - String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true); + String secretResp = http.executePairingCommand("serverchallengeresp=" + bytesToHex(challengeRespEncrypted), true); if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) { http.unpair(); - return PairState.FAILED; + return new PairResult(PairState.FAILED, pairName); } - - // Get the server's signed secret + + // 获取服务端签名的secret byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true)); byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16); byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length); - // Ensure the authenticity of the data + // 校验数据真实性 if (!verifySignature(serverSecret, serverSignature, serverCert)) { - // Cancel the pairing process + // 取消配对流程 http.unpair(); - - // Looks like a MITM - return PairState.FAILED; + // 可能是中间人攻击 + return new PairResult(PairState.FAILED, pairName); } - - // Ensure the server challenge matched what we expected (aka the PIN was correct) + + // 校验服务端challenge是否与预期一致(即PIN是否正确) byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret)); if (!Arrays.equals(serverChallengeRespHash, serverResponse)) { - // Cancel the pairing process + // 取消配对流程 http.unpair(); - - // Probably got the wrong PIN - return PairState.PIN_WRONG; + // 可能PIN错误 + return new PairResult(PairState.PIN_WRONG, pairName); } - - // Send the server our signed secret + + // 发送客户端签名的secret给服务端 byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk)); - String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true); + String clientSecretResp = http.executePairingCommand("clientpairingsecret=" + bytesToHex(clientPairingSecret), true); if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) { http.unpair(); - return PairState.FAILED; + return new PairResult(PairState.FAILED, pairName); } - - // Do the initial challenge (seems necessary for us to show as paired) + + // 执行初始挑战(似乎有必要让我们显示为已配对) String pairChallenge = http.executePairingChallenge(); if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) { http.unpair(); - return PairState.FAILED; + return new PairResult(PairState.FAILED, pairName); } - return PairState.PAIRED; + return new PairResult(PairState.PAIRED, pairName); + } + + /** + * 配对结果类,包含配对状态和pairname + */ + public static class PairResult { + public final PairState state; + public final String pairName; + + public PairResult(PairState state, String pairName) { + this.state = state; + this.pairName = pairName != null && !pairName.equals("unknown") ? pairName : ""; + } } private interface PairingHashAlgorithm { diff --git a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java index 0a92ac9162..8ebd7db4f1 100644 --- a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java +++ b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java @@ -217,10 +217,10 @@ public static void bridgeDrCleanup() { public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs) { + long receiveTimeUs, long enqueueTimeUs) { if (videoRenderer != null) { return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength, - decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs); + decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeUs, enqueueTimeUs); } else { return DR_OK; @@ -346,7 +346,8 @@ public static native int startConnection(String address, String appVersion, Stri int clientRefreshRateX100, byte[] riAesKey, byte[] riAesIv, int videoCapabilities, - int colorSpace, int colorRange); + int colorSpace, int colorRange, boolean enableMic, + boolean controlOnly); public static native void stopConnection(); @@ -417,4 +418,13 @@ public static native int sendPenEvent(byte eventType, byte toolType, byte penBut public static native boolean guessControllerHasShareButton(int vendorId, int productId); public static native void init(); + + // This function returns any extended feature flags supported by the host. + public static native int getHostFeatureFlags(); + + // 获取麦克风端口号 + public static native int getMicPortNumber(); + + // 检查主机是否请求麦克风输入 + public static native boolean isMicrophoneRequested(); } diff --git a/app/src/main/java/com/limelight/preferences/AboutDialogPreference.java b/app/src/main/java/com/limelight/preferences/AboutDialogPreference.java new file mode 100644 index 0000000000..73960b10d4 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/AboutDialogPreference.java @@ -0,0 +1,122 @@ +package com.limelight.preferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import android.app.AlertDialog; + +import com.limelight.R; + +public class AboutDialogPreference extends Preference { + + private static final String GITHUB_REPO_URL = "https://github.com/qiin2333/moonlight-android"; + private static final String GITHUB_STAR_URL = "https://github.com/qiin2333/moonlight-android/stargazers"; + + public AboutDialogPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public AboutDialogPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public AboutDialogPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AboutDialogPreference(Context context) { + super(context); + } + + @Override + protected void onClick() { + showAboutDialog(); + } + + private void showAboutDialog() { + Context context = getContext(); + + // 创建自定义布局 + View dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_about, null); + + // 设置版本信息 + TextView versionText = dialogView.findViewById(R.id.text_version); + String versionInfo = getVersionInfo(context); + versionText.setText(versionInfo); + + // 设置应用名称 + TextView appNameText = dialogView.findViewById(R.id.text_app_name); + String appName = getAppName(context); + appNameText.setText(appName); + + // 设置描述信息 + TextView descriptionText = dialogView.findViewById(R.id.text_description); + descriptionText.setText(R.string.about_dialog_description); + + // 创建对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setView(dialogView); + + // 设置按钮 + builder.setPositiveButton(R.string.about_dialog_github, (dialog, which) -> { + // 打开项目仓库 + openUrl(GITHUB_REPO_URL); + }); + + builder.setNeutralButton(R.string.about_dialog_star, (dialog, which) -> { + // 打开Star页面 + openUrl(GITHUB_STAR_URL); + }); + + builder.setNegativeButton(R.string.about_dialog_close, (dialog, which) -> { + dialog.dismiss(); + }); + + // 显示对话框 + AlertDialog dialog = builder.create(); + dialog.show(); + } + + @SuppressLint("DefaultLocale") + private String getVersionInfo(Context context) { + try { + PackageInfo packageInfo = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0); + return String.format("Version %s (Build %d)", + packageInfo.versionName, + packageInfo.versionCode); + } catch (PackageManager.NameNotFoundException e) { + return "Version Unknown"; + } + } + + private String getAppName(Context context) { + try { + PackageInfo packageInfo = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0); + return packageInfo.applicationInfo.loadLabel(context.getPackageManager()).toString(); + } catch (PackageManager.NameNotFoundException e) { + return "Moonlight V+"; + } + } + + private void openUrl(String url) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + } catch (Exception e) { + // 如果无法打开链接,忽略错误 + } + } +} diff --git a/app/src/main/java/com/limelight/preferences/AddComputerManually.java b/app/src/main/java/com/limelight/preferences/AddComputerManually.java index febb655892..562ea3fc69 100644 --- a/app/src/main/java/com/limelight/preferences/AddComputerManually.java +++ b/app/src/main/java/com/limelight/preferences/AddComputerManually.java @@ -55,6 +55,24 @@ public void onServiceDisconnected(ComponentName className) { } }; + /** + * 检测是否为IPv6地址 + */ + private boolean isIPv6Address(String address) { + try { + InetAddress inetAddress = InetAddress.getByName(address); + // IPv6地址的字节长度为16,IPv4为4 + return inetAddress.getAddress().length == 16; + } catch (Exception e) { + // 如果无法解析,通过格式判断(包含多个冒号) + int colonCount = 0; + for (char c : address.toCharArray()) { + if (c == ':') colonCount++; + } + return colonCount >= 2; + } + } + private boolean isWrongSubnetSiteLocalAddress(String address) { try { InetAddress targetAddress = InetAddress.getByName(address); @@ -99,6 +117,16 @@ private boolean isWrongSubnetSiteLocalAddress(String address) { } private URI parseRawUserInputToUri(String rawUserInput) { + // 检测是否可能是IPv6地址(包含多个冒号) + // IPv6地址至少有2个冒号,例如 ::1 或 fe80::1 + int colonCount = 0; + for (char c : rawUserInput.toCharArray()) { + if (c == ':') colonCount++; + } + + // 如果有多个冒号,很可能是IPv6地址 + boolean likelyIPv6 = colonCount >= 2; + try { // Try adding a scheme and parsing the remaining input. // This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1. @@ -108,14 +136,62 @@ private URI parseRawUserInputToUri(String rawUserInput) { } } catch (URISyntaxException ignored) {} - try { - // Attempt to escape the input as an IPv6 literal. - // This handles input like ::1. - URI uri = new URI("moonlight://[" + rawUserInput + "]"); - if (uri.getHost() != null && !uri.getHost().isEmpty()) { - return uri; - } - } catch (URISyntaxException ignored) {} + // 如果第一次解析失败,且输入看起来像IPv6地址 + if (likelyIPv6 && !rawUserInput.startsWith("[")) { + try { + // 尝试智能添加中括号 + // 情况1: 纯IPv6地址,如 "::1" 或 "2001:0db8::1" + // 情况2: IPv6地址+端口,如 "::1:47989" 或 "2001:0db8::1:47989" + + // 查找最后一个冒号,可能是端口分隔符 + int lastColonIndex = rawUserInput.lastIndexOf(':'); + + // 尝试判断最后部分是否是端口号(纯数字) + boolean hasPort = false; + if (lastColonIndex > 0 && lastColonIndex < rawUserInput.length() - 1) { + String possiblePort = rawUserInput.substring(lastColonIndex + 1); + try { + int port = Integer.parseInt(possiblePort); + // 合理的端口范围 + if (port > 0 && port <= 65535) { + hasPort = true; + } + } catch (NumberFormatException e) { + // 不是数字,不是端口 + } + } + + String addressWithBrackets; + if (hasPort) { + // 将地址部分用中括号括起来,保留端口 + // "2001:0db8::1:47989" -> "[2001:0db8::1]:47989" + String address = rawUserInput.substring(0, lastColonIndex); + String port = rawUserInput.substring(lastColonIndex + 1); + addressWithBrackets = "[" + address + "]:" + port; + } else { + // 整个地址用中括号括起来 + // "2001:0db8::1" -> "[2001:0db8::1]" + addressWithBrackets = "[" + rawUserInput + "]"; + } + + URI uri = new URI("moonlight://" + addressWithBrackets); + if (uri.getHost() != null && !uri.getHost().isEmpty()) { + return uri; + } + } catch (URISyntaxException ignored) {} + } + + // 最后的回退:尝试简单地添加中括号(处理边缘情况) + if (!rawUserInput.startsWith("[")) { + try { + // Attempt to escape the input as an IPv6 literal. + // This handles input like ::1. + URI uri = new URI("moonlight://[" + rawUserInput + "]"); + if (uri.getHost() != null && !uri.getHost().isEmpty()) { + return uri; + } + } catch (URISyntaxException ignored) {} + } return null; } @@ -125,6 +201,8 @@ private void doAddPc(String rawUserInput) throws InterruptedException { boolean invalidInput = false; boolean success; int portTestResult; + String hostAddress = null; // 保存主机地址用于后续判断 + boolean isIPv6 = false; // 标记是否为IPv6地址 SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc), getResources().getString(R.string.msg_add_pc), false); @@ -138,6 +216,10 @@ private void doAddPc(String rawUserInput) throws InterruptedException { String host = uri.getHost(); int port = uri.getPort(); + // 保存主机地址并检测是否为IPv6 + hostAddress = host; + isIPv6 = isIPv6Address(host); + // If a port was not specified, use the default if (port == -1) { port = NvHTTP.DEFAULT_HTTP_PORT; @@ -191,6 +273,15 @@ else if (!success) { else { dialogText = getResources().getString(R.string.addpc_fail); } + + // 如果是IPv6地址连接失败,添加防火墙提示 + if (isIPv6) { + dialogText += "\n\n提示:如果您使用的是IPv6地址,请检查:\n" + + "1. 光猫防火墙是否放行了IPv6流量\n" + + "2. 路由器是否启用了IPv6端口转发\n" + + "3. 目标主机的IPv6防火墙设置"; + } + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false); } else { diff --git a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java index 01f5c34073..c835319ece 100644 --- a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java +++ b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java @@ -35,4 +35,6 @@ public void onClick(DialogInterface dialog, int which) { Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show(); } } + + } diff --git a/app/src/main/java/com/limelight/preferences/CustomResolutionsPreference.java b/app/src/main/java/com/limelight/preferences/CustomResolutionsPreference.java new file mode 100644 index 0000000000..f473640bc4 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/CustomResolutionsPreference.java @@ -0,0 +1,616 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.DialogPreference; +import android.text.Editable; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.*; +import com.limelight.R; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 自定义分辨率常量类 + */ +class CustomResolutionsConsts { + public static final String CUSTOM_RESOLUTIONS_FILE = "custom_resolutions"; + public static final String CUSTOM_RESOLUTIONS_KEY = "custom_resolutions"; +} + +/** + * 分辨率验证工具类 + */ +class ResolutionValidator { + private static final int MIN_WIDTH = 320; + private static final int MAX_WIDTH = 7680; + private static final int MIN_HEIGHT = 240; + private static final int MAX_HEIGHT = 4320; + + private static final String RESOLUTION_REGEX = "^\\d{3,5}x\\d{3,5}$"; + private static final Pattern RESOLUTION_PATTERN = Pattern.compile(RESOLUTION_REGEX); + + /** + * 验证分辨率字符串格式 + */ + public static boolean isValidResolutionFormat(String resolution) { + return RESOLUTION_PATTERN.matcher(resolution).matches(); + } + + /** + * 验证宽度范围 + */ + public static boolean isValidWidth(int width) { + return width >= MIN_WIDTH && width <= MAX_WIDTH; + } + + /** + * 验证高度范围 + */ + public static boolean isValidHeight(int height) { + return height >= MIN_HEIGHT && height <= MAX_HEIGHT; + } + + /** + * 验证是否为偶数 + */ + public static boolean isEven(int value) { + return value % 2 == 0; + } + + /** + * 解析分辨率字符串为宽度和高度 + */ + public static Resolution parseResolution(String resolution) { + try { + String[] parts = resolution.split("x"); + if (parts.length != 2) { + return null; + } + int width = Integer.parseInt(parts[0]); + int height = Integer.parseInt(parts[1]); + return new Resolution(width, height); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 分辨率数据类 + */ + public static class Resolution { + public final int width; + public final int height; + + public Resolution(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public String toString() { + return width + "x" + height; + } + } +} + +/** + * 自定义分辨率偏好设置类 + */ +public class CustomResolutionsPreference extends DialogPreference { + private final Context context; + private final CustomResolutionsAdapter adapter; + + public CustomResolutionsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + this.adapter = new CustomResolutionsAdapter(context); + adapter.setOnDataChangedListener(new UpdateStorageEventListener()); + } + + /** + * 处理分辨率提交 + */ + void onSubmitResolution(EditText widthField, EditText heightField) { + // 清除之前的错误 + clearErrors(widthField, heightField); + + // 获取输入值 + String widthText = widthField.getText().toString().trim(); + String heightText = heightField.getText().toString().trim(); + + // 验证输入 + ResolutionValidationResult validation = validateInput(widthText, heightText); + if (!validation.isValid()) { + showValidationErrors(validation, widthField, heightField); + return; + } + + // 创建分辨率字符串 + String resolution = validation.getResolution().toString(); + + // 检查是否已存在 + if (adapter.exists(resolution)) { + Toast.makeText(context, context.getString(R.string.resolution_already_exists), Toast.LENGTH_SHORT).show(); + return; + } + + // 添加分辨率 + addResolution(resolution, widthField, heightField); + } + + /** + * 清除输入框错误 + */ + private void clearErrors(EditText widthField, EditText heightField) { + widthField.setError(null); + heightField.setError(null); + } + + /** + * 验证输入 + */ + private ResolutionValidationResult validateInput(String widthText, String heightText) { + // 检查空值 + if (widthText.isEmpty()) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.WIDTH_EMPTY); + } + if (heightText.isEmpty()) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.HEIGHT_EMPTY); + } + + // 解析数字 + int width, height; + try { + width = Integer.parseInt(widthText); + height = Integer.parseInt(heightText); + } catch (NumberFormatException e) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.INVALID_FORMAT); + } + + // 验证范围 + if (!ResolutionValidator.isValidWidth(width)) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.WIDTH_OUT_OF_RANGE); + } + if (!ResolutionValidator.isValidHeight(height)) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.HEIGHT_OUT_OF_RANGE); + } + + // 验证偶数 + if (!ResolutionValidator.isEven(width)) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.WIDTH_ODD); + } + if (!ResolutionValidator.isEven(height)) { + return ResolutionValidationResult.error(ResolutionValidationResult.ErrorType.HEIGHT_ODD); + } + + return ResolutionValidationResult.success(new ResolutionValidator.Resolution(width, height)); + } + + /** + * 显示验证错误 + */ + private void showValidationErrors(ResolutionValidationResult validation, EditText widthField, EditText heightField) { + String errorMessage = getErrorMessage(validation.getErrorType()); + + switch (validation.getErrorType()) { + case WIDTH_EMPTY: + case WIDTH_OUT_OF_RANGE: + case WIDTH_ODD: + case INVALID_FORMAT: + widthField.setError(errorMessage); + break; + case HEIGHT_EMPTY: + case HEIGHT_OUT_OF_RANGE: + case HEIGHT_ODD: + heightField.setError(errorMessage); + break; + } + } + + /** + * 获取错误消息 + */ + private String getErrorMessage(ResolutionValidationResult.ErrorType errorType) { + switch (errorType) { + case WIDTH_EMPTY: + case HEIGHT_EMPTY: + return context.getString(R.string.width_hint); + case INVALID_FORMAT: + return context.getString(R.string.invalid_resolution_format); + case WIDTH_OUT_OF_RANGE: + return "宽度应在320-7680之间"; + case HEIGHT_OUT_OF_RANGE: + return "高度应在240-4320之间"; + case WIDTH_ODD: + return "宽度不能为奇数"; + case HEIGHT_ODD: + return "高度不能为奇数"; + default: + return context.getString(R.string.invalid_resolution_format); + } + } + + /** + * 添加分辨率 + */ + private void addResolution(String resolution, EditText widthField, EditText heightField) { + adapter.addItem(resolution); + Toast.makeText(context, context.getString(R.string.resolution_added_successfully), Toast.LENGTH_SHORT).show(); + + // 清空输入框并设置焦点 + clearInputFields(widthField, heightField); + } + + /** + * 清空输入框 + */ + private void clearInputFields(EditText widthField, EditText heightField) { + widthField.setText(""); + heightField.setText(""); + widthField.requestFocus(); + } + + @Override + protected void onBindDialogView(View view) { + loadStoredResolutions(); + super.onBindDialogView(view); + } + + /** + * 加载存储的分辨率 + */ + private void loadStoredResolutions() { + SharedPreferences prefs = context.getSharedPreferences(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_FILE, Context.MODE_PRIVATE); + Set stored = prefs.getStringSet(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_KEY, null); + + if (stored == null) return; + + ArrayList sortedList = sortResolutions(new ArrayList<>(stored)); + adapter.addAll(sortedList); + } + + /** + * 排序分辨率列表 + */ + private ArrayList sortResolutions(ArrayList resolutions) { + Collections.sort(resolutions, new ResolutionComparator()); + return resolutions; + } + + @Override + protected View onCreateDialogView() { + LinearLayout body = createMainLayout(); + ListView list = createListView(); + View inputRow = createInputRow(); + + body.addView(list); + body.addView(inputRow); + + return body; + } + + /** + * 创建主布局 + */ + private LinearLayout createMainLayout() { + LinearLayout body = new LinearLayout(context); + + // 设置弹窗宽度为屏幕宽度的80%,最小宽度400dp + int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + int dialogWidth = Math.min((int) (screenWidth * 0.8), dpToPx(400)); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + dialogWidth, + AbsListView.LayoutParams.WRAP_CONTENT + ); + layoutParams.gravity = Gravity.CENTER; + body.setLayoutParams(layoutParams); + body.setOrientation(LinearLayout.VERTICAL); + body.setPadding(dpToPx(16), dpToPx(16), dpToPx(16), dpToPx(16)); + + return body; + } + + /** + * 将dp转换为px + */ + private int dpToPx(int value) { + float density = context.getResources().getDisplayMetrics().density; + return (int) (value * density + 0.5f); + } + + /** + * 创建列表视图 + */ + private ListView createListView() { + ListView list = new ListView(context); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ); + list.setLayoutParams(layoutParams); + list.setAdapter(adapter); + list.setDividerHeight(dpToPx(1)); + list.setDivider(context.getResources().getDrawable(android.R.color.darker_gray)); + return list; + } + + /** + * 创建输入行 + */ + private View createInputRow() { + LayoutInflater inflater = LayoutInflater.from(context); + View inputRow = inflater.inflate(R.layout.custom_resolutions_form, null); + + EditText widthField = inputRow.findViewById(R.id.custom_resolution_width_field); + EditText heightField = inputRow.findViewById(R.id.custom_resolution_height_field); + Button addButton = inputRow.findViewById(R.id.add_resolution_button); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + AbsListView.LayoutParams.WRAP_CONTENT + ); + layoutParams.topMargin = dpToPx(16); + inputRow.setLayoutParams(layoutParams); + + setupInputListeners(widthField, heightField, addButton); + + return inputRow; + } + + /** + * 设置输入监听器 + */ + private void setupInputListeners(EditText widthField, EditText heightField, Button addButton) { + // 设置按钮点击事件 + addButton.setOnClickListener(view -> onSubmitResolution(widthField, heightField)); + + // 设置回车键监听 + heightField.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) { + onSubmitResolution(widthField, heightField); + return true; + } + return false; + }); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + StreamSettings settingsActivity = (StreamSettings) getContext(); + settingsActivity.reloadSettings(); + } + + /** + * 存储更新事件监听器 + */ + private class UpdateStorageEventListener implements EventListener { + @Override + public void onTrigger() { + saveResolutions(); + } + } + + /** + * 保存分辨率到存储 + */ + private void saveResolutions() { + SharedPreferences prefs = context.getSharedPreferences(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_FILE, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + Set set = new HashSet<>(adapter.getAll()); + editor.putStringSet(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_KEY, set).apply(); + } +} + +/** + * 事件监听器接口 + */ +interface EventListener { + void onTrigger(); +} + +/** + * 分辨率验证结果类 + */ +class ResolutionValidationResult { + private final boolean valid; + private final ResolutionValidator.Resolution resolution; + private final ErrorType errorType; + + private ResolutionValidationResult(boolean valid, ResolutionValidator.Resolution resolution, ErrorType errorType) { + this.valid = valid; + this.resolution = resolution; + this.errorType = errorType; + } + + public static ResolutionValidationResult success(ResolutionValidator.Resolution resolution) { + return new ResolutionValidationResult(true, resolution, null); + } + + public static ResolutionValidationResult error(ErrorType errorType) { + return new ResolutionValidationResult(false, null, errorType); + } + + public boolean isValid() { + return valid; + } + + public ResolutionValidator.Resolution getResolution() { + return resolution; + } + + public ErrorType getErrorType() { + return errorType; + } + + public enum ErrorType { + WIDTH_EMPTY, + HEIGHT_EMPTY, + INVALID_FORMAT, + WIDTH_OUT_OF_RANGE, + HEIGHT_OUT_OF_RANGE, + WIDTH_ODD, + HEIGHT_ODD + } +} + +/** + * 分辨率比较器 + */ +class ResolutionComparator implements Comparator { + @Override + public int compare(String s1, String s2) { + ResolutionValidator.Resolution res1 = ResolutionValidator.parseResolution(s1); + ResolutionValidator.Resolution res2 = ResolutionValidator.parseResolution(s2); + + if (res1 == null || res2 == null) { + return s1.compareTo(s2); + } + + if (res1.width == res2.width) { + return Integer.compare(res1.height, res2.height); + } + return Integer.compare(res1.width, res2.width); + } +} + +/** + * 自定义分辨率适配器 + */ +class CustomResolutionsAdapter extends BaseAdapter { + private final ArrayList resolutions = new ArrayList<>(); + private final Context context; + private EventListener listener; + + public CustomResolutionsAdapter(Context context) { + this.context = context; + } + + public void setOnDataChangedListener(EventListener listener) { + this.listener = listener; + } + + @Override + public void notifyDataSetChanged() { + if (listener != null) { + listener.onTrigger(); + } + super.notifyDataSetChanged(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = createListItemView(); + } + + setupListItemView(convertView, position); + return convertView; + } + + /** + * 创建列表项视图 + */ + private View createListItemView() { + LinearLayout row = new LinearLayout(context); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + row.setLayoutParams(layoutParams); + row.setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(12)); + row.setOrientation(LinearLayout.HORIZONTAL); + + TextView listItemText = new TextView(context); + ImageButton deleteButton = new ImageButton(context); + + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + textParams.weight = 1; + textParams.gravity = Gravity.CENTER_VERTICAL; + textParams.leftMargin = dpToPx(8); + listItemText.setLayoutParams(textParams); + listItemText.setTextSize(16); + + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(dpToPx(48), dpToPx(48)); + buttonParams.gravity = Gravity.CENTER_VERTICAL; + deleteButton.setLayoutParams(buttonParams); + deleteButton.setImageResource(R.drawable.ic_delete); + deleteButton.setBackgroundResource(android.R.color.transparent); + deleteButton.setPadding(dpToPx(8), dpToPx(8), dpToPx(8), dpToPx(8)); + + row.addView(listItemText); + row.addView(deleteButton); + + return row; + } + + /** + * 设置列表项视图 + */ + private void setupListItemView(View convertView, int position) { + LinearLayout row = (LinearLayout) convertView; + TextView listItemText = (TextView) row.getChildAt(0); + ImageButton deleteButton = (ImageButton) row.getChildAt(1); + + listItemText.setText(resolutions.get(position)); + + // 设置删除按钮点击事件 + deleteButton.setOnClickListener(view -> { + resolutions.remove(position); + notifyDataSetChanged(); + }); + } + + @Override + public int getCount() { + return resolutions.size(); + } + + @Override + public Object getItem(int position) { + return resolutions.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + public void addItem(String value) { + if (!resolutions.contains(value)) { + resolutions.add(value); + notifyDataSetChanged(); + } + } + + public ArrayList getAll() { + return new ArrayList<>(resolutions); + } + + public void addAll(ArrayList list) { + this.resolutions.addAll(list); + notifyDataSetChanged(); + } + + public boolean exists(String item) { + return resolutions.contains(item); + } + + /** + * 将dp转换为px + */ + private int dpToPx(int value) { + float density = this.context.getResources().getDisplayMetrics().density; + return (int) (value * density + 0.5f); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/DynamicPerfOverlayPositionPreference.java b/app/src/main/java/com/limelight/preferences/DynamicPerfOverlayPositionPreference.java new file mode 100644 index 0000000000..41436b99b5 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/DynamicPerfOverlayPositionPreference.java @@ -0,0 +1,95 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.ListPreference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +import com.limelight.R; + +public class DynamicPerfOverlayPositionPreference extends ListPreference { + + public DynamicPerfOverlayPositionPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + updateEntries(); + } + + public DynamicPerfOverlayPositionPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + updateEntries(); + } + + public DynamicPerfOverlayPositionPreference(Context context, AttributeSet attrs) { + super(context, attrs); + updateEntries(); + } + + public DynamicPerfOverlayPositionPreference(Context context) { + super(context); + updateEntries(); + } + + @Override + protected void onAttachedToHierarchy(PreferenceManager preferenceManager) { + super.onAttachedToHierarchy(preferenceManager); + updateEntries(); + } + + private void updateEntries() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + String orientation = prefs.getString("list_perf_overlay_orientation", "horizontal"); + + if ("vertical".equals(orientation)) { + // 垂直方向:显示四个角选项 + setEntries(R.array.perf_overlay_position_vertical_names); + setEntryValues(R.array.perf_overlay_position_vertical_values); + + // 如果当前值不在垂直选项中,重置为默认值 + String currentValue = getValue(); + if (currentValue != null && !isValidVerticalValue(currentValue)) { + setValue("top_left"); + } + } else { + // 水平方向:显示顶部和底部选项 + setEntries(R.array.perf_overlay_position_horizontal_names); + setEntryValues(R.array.perf_overlay_position_horizontal_values); + + // 如果当前值不在水平选项中,重置为默认值 + String currentValue = getValue(); + if (currentValue != null && !isValidHorizontalValue(currentValue)) { + setValue("top"); + } + } + } + + private boolean isValidVerticalValue(String value) { + return "top_left".equals(value) || "top_right".equals(value) || + "bottom_left".equals(value) || "bottom_right".equals(value); + } + + private boolean isValidHorizontalValue(String value) { + return "top".equals(value) || "bottom".equals(value); + } + + public void refreshEntries() { + updateEntries(); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (positiveResult) { + // 当位置改变时,清除自定义拖动位置 + clearCustomPosition(); + } + } + + private void clearCustomPosition() { + SharedPreferences prefs = getContext().getSharedPreferences("performance_overlay", Context.MODE_PRIVATE); + prefs.edit().clear().apply(); + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.java b/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.java new file mode 100644 index 0000000000..2f453c727f --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.java @@ -0,0 +1,70 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.res.TypedArray; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.preference.CheckBoxPreference; +import android.util.AttributeSet; +import android.view.Display; + +import com.limelight.ExternalDisplayManager; + +/** + * 外接显示器状态偏好设置 + */ +public class ExternalDisplayPreference extends CheckBoxPreference { + + public ExternalDisplayPreference(Context context) { + super(context); + init(context); + } + + public ExternalDisplayPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public ExternalDisplayPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(Context context) { + updateSummary(); + } + + @Override + protected void onAttachedToActivity() { + super.onAttachedToActivity(); + updateSummary(); + } + + private void updateSummary() { + try { + if (ExternalDisplayManager.hasExternalDisplay(getContext())) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + DisplayManager displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + if (displayManager != null) { + Display[] displays = displayManager.getDisplays(); + for (Display display : displays) { + if (display.getDisplayId() != Display.DEFAULT_DISPLAY) { + setSummary("检测到外接显示器: " + display.getName() + " (ID: " + display.getDisplayId() + ")"); + setEnabled(true); + return; + } + } + } + } + } else { + setSummary("未检测到外接显示器"); + setEnabled(false); + setChecked(false); + } + } catch (Exception e) { + setSummary("检测外接显示器失败: " + e); + setEnabled(false); + setChecked(false); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/IconListPreference.java b/app/src/main/java/com/limelight/preferences/IconListPreference.java new file mode 100644 index 0000000000..69b1435f23 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/IconListPreference.java @@ -0,0 +1,131 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.ListPreference; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import com.limelight.R; +import com.limelight.Game; + +public class IconListPreference extends ListPreference { + private int[] mEntryIcons; + private String mOriginalSummary; + + public IconListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.IconListPreference); + int iconsResId = a.getResourceId(R.styleable.IconListPreference_entryIcons, 0); + if (iconsResId != 0) { + TypedArray icons = context.getResources().obtainTypedArray(iconsResId); + mEntryIcons = new int[icons.length()]; + for (int i = 0; i < icons.length(); i++) { + mEntryIcons[i] = icons.getResourceId(i, 0); + } + icons.recycle(); + } + a.recycle(); + + // 保存原始summary用于以后显示 + mOriginalSummary = (String) getSummary(); + + // 设置值变化监听器 + setOnPreferenceChangeListener(new OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(android.preference.Preference preference, Object newValue) { + // 当值变化时更新summary + updateSummary(newValue.toString()); + return true; // 允许变化发生 + } + }); + + // 初始化summary显示当前值 + updateSummary(getValue()); + } + + @Override + protected void onPrepareDialogBuilder(android.app.AlertDialog.Builder builder) { + if (getEntries() == null || mEntryIcons == null) { + super.onPrepareDialogBuilder(builder); + return; + } + + + + ArrayAdapter adapter = new ArrayAdapter( + getContext(), R.layout.icon_list_item, R.id.text, getEntries()) { + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = super.getView(position, convertView, parent); + ImageView iconView = view.findViewById(R.id.icon); + if (position < mEntryIcons.length && mEntryIcons[position] != 0) { + iconView.setImageResource(mEntryIcons[position]); + iconView.setVisibility(View.VISIBLE); + } else { + iconView.setVisibility(View.GONE); + } + return view; + } + }; + + builder.setAdapter(adapter, this); + super.onPrepareDialogBuilder(builder); + } + + @Override + public void setSummary(CharSequence summary) { + // 如果不是我们程序化设置的summary,保存它作为原始summary + if (mOriginalSummary == null || !summary.toString().contains(mOriginalSummary)) { + mOriginalSummary = summary.toString(); + } + super.setSummary(summary); + } + + private void updateSummary(String value) { + // 找到当前值对应的显示文本 + CharSequence[] entries = getEntries(); + CharSequence[] entryValues = getEntryValues(); + + if (entries == null || entryValues == null) { + return; + } + + int index = findIndexOfValue(value); + if (index >= 0) { + // 组合原始summary和当前选择值 + String currentEntry = entries[index].toString(); + String summary = mOriginalSummary + " (当前:" + currentEntry + ")"; + super.setSummary(summary); + } else { + super.setSummary(mOriginalSummary); + } + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (positiveResult) { + // 当对话框关闭并确认后,更新summary + updateSummary(getValue()); + + // 如果当前正在游戏中,通知Activity刷新显示位置 + if (getContext() instanceof Game) { + ((Game) getContext()).refreshDisplayPosition(); + } + } + } + + // 在旧版API中,需要覆盖这些方法来确保summary更新 + @Override + protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { + super.onSetInitialValue(restoreValue, defaultValue); + updateSummary(getValue()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/LanguagePreference.java b/app/src/main/java/com/limelight/preferences/LanguagePreference.java index 6549b01512..f79cf5a17c 100644 --- a/app/src/main/java/com/limelight/preferences/LanguagePreference.java +++ b/app/src/main/java/com/limelight/preferences/LanguagePreference.java @@ -46,4 +46,6 @@ protected void onClick() { // If we don't have native app locale settings, launch the normal dialog super.onClick(); } + + } diff --git a/app/src/main/java/com/limelight/preferences/LocalImagePickerPreference.java b/app/src/main/java/com/limelight/preferences/LocalImagePickerPreference.java new file mode 100644 index 0000000000..ad9ad5a739 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/LocalImagePickerPreference.java @@ -0,0 +1,173 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.provider.MediaStore; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.limelight.LimeLog; +import com.limelight.preferences.StreamSettings; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * 本地图片选择器偏好设置类 + * 提供一个按钮来选择本地图片 + */ +public class LocalImagePickerPreference extends Preference { + public static final int PICK_IMAGE_REQUEST = 1001; + private StreamSettings activity; + private PreferenceFragment fragment; + private static LocalImagePickerPreference instance; + + // 自定义背景图片的文件名 + private static final String BACKGROUND_FILE_NAME = "custom_background_image.png"; + + public LocalImagePickerPreference(Context context, AttributeSet attrs) { + super(context, attrs); + if (context instanceof StreamSettings) { + this.activity = (StreamSettings) context; + } + instance = this; + } + + public LocalImagePickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + if (context instanceof StreamSettings) { + this.activity = (StreamSettings) context; + } + instance = this; + } + + public static LocalImagePickerPreference getInstance() { + return instance; + } + + public void setFragment(PreferenceFragment fragment) { + this.fragment = fragment; + } + + @Override + protected void onClick() { + openImagePicker(); + } + + /** + * 打开图片选择器 + * 使用 ACTION_GET_CONTENT 可以让用户自己选择是用“相册”选还是用“文件管理器”选 + */ + private void openImagePicker() { + // 创建 Intent,动作为“获取内容” + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + // 限制类型为图片 + intent.setType("image/*"); + // 确保返回的文件是可以打开读取的 + intent.addCategory(Intent.CATEGORY_OPENABLE); + + try { + Intent chooserIntent = Intent.createChooser(intent, "选择背景图片"); + + // 启动选择 + if (fragment != null) { + fragment.startActivityForResult(chooserIntent, PICK_IMAGE_REQUEST); + } else if (activity != null) { + activity.startActivityForResult(chooserIntent, PICK_IMAGE_REQUEST); + } + } catch (Exception e) { + LimeLog.warning("Failed to open image picker: " + e.getMessage()); + Toast.makeText(getContext(), + "无法打开图片选择器", + Toast.LENGTH_SHORT).show(); + } + } + + /** + * 处理图片选择结果 + */ + public void handleImagePickerResult(Intent data) { + if (data != null && data.getData() != null) { + Uri imageUri = data.getData(); + + // 将图片复制到应用私有目录 + String internalPath = copyImageToInternalStorage(getContext(), imageUri); + + if (internalPath != null) { + // 保存私有文件的路径到偏好设置 + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + prefs.edit() + .putString("background_image_url", internalPath) + .apply(); + + Toast.makeText(getContext(), "背景图片设置成功", Toast.LENGTH_SHORT).show(); + LimeLog.info("Image saved to internal storage: " + internalPath); + + // 发送广播通知 PcView 更新背景图片 + Intent broadcastIntent = new Intent("com.limelight.REFRESH_BACKGROUND_IMAGE"); + getContext().sendBroadcast(broadcastIntent); + } else { + Toast.makeText(getContext(), "图片保存失败,请重试", Toast.LENGTH_SHORT).show(); + LimeLog.warning("Failed to copy image to internal storage"); + } + } else { + Toast.makeText(getContext(), "图片选择已取消", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 将选中的图片复制到应用的内部存储空间 + * 这样无论应用重启还是权限变更,只要应用不被卸载,图片都能访问 + */ + private String copyImageToInternalStorage(Context context, Uri sourceUri) { + InputStream inputStream = null; + OutputStream outputStream = null; + try { + // 打开输入流 + inputStream = context.getContentResolver().openInputStream(sourceUri); + if (inputStream == null) return null; + + // 创建内部存储的目标文件 + File destFile = new File(context.getFilesDir(), BACKGROUND_FILE_NAME); + + // 如果文件已存在,先删除(覆盖) + if (destFile.exists()) { + destFile.delete(); + } + + // 打开输出流 + outputStream = new FileOutputStream(destFile); + + // 执行复制 + byte[] buffer = new byte[4096]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + + outputStream.flush(); + + // 返回该文件的绝对路径 + return destFile.getAbsolutePath(); + + } catch (Exception e) { + LimeLog.warning("Error copying image: " + e.getMessage()); + e.printStackTrace(); + return null; + } finally { + try { + if (inputStream != null) inputStream.close(); + if (outputStream != null) outputStream.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/PerfOverlayDisplayItemsPreference.java b/app/src/main/java/com/limelight/preferences/PerfOverlayDisplayItemsPreference.java new file mode 100644 index 0000000000..097eff268d --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/PerfOverlayDisplayItemsPreference.java @@ -0,0 +1,94 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.MultiSelectListPreference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +import com.limelight.R; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class PerfOverlayDisplayItemsPreference extends MultiSelectListPreference { + + private static final String DEFAULT_ITEMS = "resolution,decoder,render_fps,network_latency,decode_latency,host_latency,packet_loss,battery"; + + public PerfOverlayDisplayItemsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public PerfOverlayDisplayItemsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public PerfOverlayDisplayItemsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public PerfOverlayDisplayItemsPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setEntries(R.array.perf_overlay_display_items_names); + setEntryValues(R.array.perf_overlay_display_items_values); + + // 设置默认值(所有项目都默认选中) + String[] defaultValues = DEFAULT_ITEMS.split(","); + Set defaultSet = new HashSet<>(Arrays.asList(defaultValues)); + setDefaultValue(defaultSet); + } + + @Override + protected void onAttachedToHierarchy(PreferenceManager preferenceManager) { + super.onAttachedToHierarchy(preferenceManager); + + // 如果没有保存的值,设置默认值 + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + if (!prefs.contains(getKey())) { + String[] defaultValues = DEFAULT_ITEMS.split(","); + Set defaultSet = new HashSet<>(Arrays.asList(defaultValues)); + setValues(defaultSet); + } + } + + /** + * 获取默认的显示项目 + */ + public static Set getDefaultDisplayItems() { + String[] defaultValues = DEFAULT_ITEMS.split(","); + return new HashSet<>(Arrays.asList(defaultValues)); + } + + /** + * 检查特定项目是否被选中显示 + */ + public static boolean isItemEnabled(Context context, String itemKey) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + Set selectedItems = prefs.getStringSet("perf_overlay_display_items", getDefaultDisplayItems()); + return selectedItems.contains(itemKey); + } + + /** + * 测试用:获取当前选中的所有显示项目 + */ + public static Set getSelectedItems(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getStringSet("perf_overlay_display_items", getDefaultDisplayItems()); + } + + /** + * 测试用:手动设置显示项目 + */ + public static void setDisplayItems(Context context, Set items) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putStringSet("perf_overlay_display_items", items).apply(); + } +} diff --git a/app/src/main/java/com/limelight/preferences/PerfOverlayOrientationPreference.java b/app/src/main/java/com/limelight/preferences/PerfOverlayOrientationPreference.java new file mode 100644 index 0000000000..48497b3526 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/PerfOverlayOrientationPreference.java @@ -0,0 +1,48 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.preference.ListPreference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +public class PerfOverlayOrientationPreference extends ListPreference { + + public PerfOverlayOrientationPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public PerfOverlayOrientationPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public PerfOverlayOrientationPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public PerfOverlayOrientationPreference(Context context) { + super(context); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (positiveResult) { + // 当方向改变时,通知位置Preference更新选项 + updatePositionPreference(); + } + } + + private void updatePositionPreference() { + PreferenceManager preferenceManager = getPreferenceManager(); + if (preferenceManager != null) { + DynamicPerfOverlayPositionPreference positionPref = + (DynamicPerfOverlayPositionPreference) preferenceManager.findPreference("list_perf_overlay_position"); + if (positionPref != null) { + positionPref.refreshEntries(); + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index 8ed01e3610..a2f4117a6d 100644 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -6,9 +6,16 @@ import android.os.Build; import android.preference.PreferenceManager; import android.view.Display; +import android.graphics.Point; +import android.view.WindowManager; +import android.view.KeyEvent; import com.limelight.nvstream.jni.MoonBridge; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + public class PreferenceConfiguration { public enum FormatOption { AUTO, @@ -23,13 +30,40 @@ public enum AnalogStickForScrolling { LEFT } + public enum PerfOverlayOrientation { + HORIZONTAL, + VERTICAL + } + + public enum PerfOverlayPosition { + // 水平方向选项 + TOP, + BOTTOM, + // 垂直方向选项(四个角) + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT + } + + private static final String ENABLE_DOUBLE_CLICK_DRAG_PREF_STRING = "pref_enable_double_click_drag"; + private static final String ENABLE_LOCAL_CURSOR_RENDERING_PREF_STRING = "pref_enable_local_cursor_rendering"; + private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps"; private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround"; static final String RESOLUTION_PREF_STRING = "list_resolution"; static final String FPS_PREF_STRING = "list_fps"; static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps"; + static final String HOST_SCALE_PREF_STRING = "seekbar_resolutions_scale"; private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate"; + static final String LONG_PRESS_FLAT_REGION_PIXELS_PREF_STRING = "seekbar_flat_region_pixels"; + static final String SYNC_TOUCH_EVENT_WITH_DISPLAY_PREF_STRING = "checkbox_sync_touch_event_with_display"; + static final String ENABLE_KEYBOARD_TOGGLE_IN_NATIVE_TOUCH = "checkbox_enable_keyboard_toggle_in_native_touch"; + static final String NATIVE_TOUCH_FINGERS_TO_TOGGLE_KEYBOARD_PREF_STRING = "seekbar_keyboard_toggle_fingers_native_touch"; + + + private static final String STRETCH_PREF_STRING = "checkbox_stretch_video"; private static final String SOPS_PREF_STRING = "checkbox_enable_sops"; private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings"; @@ -42,13 +76,17 @@ public enum AnalogStickForScrolling { static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config"; private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver"; private static final String VIDEO_FORMAT_PREF_STRING = "video_format"; - private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; + private static final String ONSCREEN_KEYBOARD_PREF_STRING = "checkbox_show_onscreen_keyboard"; private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3"; private static final String SHOW_GUIDE_BUTTON_PREF_STRING = "checkbox_show_guide_button"; + private static final String HALF_HEIGHT_OSC_PORTRAIT_PREF_STRING = "checkbox_half_height_osc_portrait"; private static final String LEGACY_DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop"; private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr"; private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip"; private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay"; + private static final String PERF_OVERLAY_LOCKED_STRING = "perf_overlay_locked"; + private static final String PERF_OVERLAY_ORIENTATION_STRING = "list_perf_overlay_orientation"; + private static final String PERF_OVERLAY_POSITION_STRING = "list_perf_overlay_position"; private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all"; private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation"; private static final String ANALOG_SCROLLING_PREF_STRING = "analog_scrolling"; @@ -58,18 +96,59 @@ public enum AnalogStickForScrolling { private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback"; private static final String VIBRATE_FALLBACK_STRENGTH_PREF_STRING = "seekbar_vibrate_fallback_strength"; private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons"; - private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad"; + public static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad"; private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast"; + private static final String ENABLE_STUN_PREF_STRING = "checkbox_enable_stun"; + private static final String LOCK_SCREEN_AFTER_DISCONNECT_PREF_STRING = "checkbox_lock_screen_after_disconnect"; + private static final String SWAP_QUIT_AND_DISCONNECT_PERF_STRING = "checkbox_swap_quit_and_disconnect"; + private static final String SCREEN_COMBINATION_MODE_PREF_STRING = "list_screen_combination_mode"; private static final String FRAME_PACING_PREF_STRING = "frame_pacing"; private static final String ABSOLUTE_MOUSE_MODE_PREF_STRING = "checkbox_absolute_mouse_mode"; + public static final String ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING = "checkbox_enable_native_mouse_pointer"; + public static final String NATIVE_MOUSE_MODE_PRESET_PREF_STRING = "list_native_mouse_mode_preset"; + // Card visibility preferences + private static final String SHOW_BITRATE_CARD_PREF_STRING = "checkbox_show_bitrate_card"; + private static final String SHOW_GYRO_CARD_PREF_STRING = "checkbox_show_gyro_card"; + private static final String SHOW_QuickKeyCard = "checkbox_show_QuickKeyCard"; + + public static final String ENABLE_ENHANCED_TOUCH_PREF_STRING = "checkbox_enable_enhanced_touch"; + private static final String ENHANCED_TOUCH_ON_RIGHT_PREF_STRING = "checkbox_enhanced_touch_on_which_side"; + private static final String ENHANCED_TOUCH_ZONE_DIVIDER_PREF_STRING = "enhanced_touch_zone_divider"; + private static final String POINTER_VELOCITY_FACTOR_PREF_STRING = "pointer_velocity_factor"; + // private static final String POINTER_FIXED_X_VELOCITY_PREF_STRING = "fixed_x_velocity"; + + private static final String ENABLE_AUDIO_FX_PREF_STRING = "checkbox_enable_audiofx"; + private static final String ENABLE_SPATIALIZER_PREF_STRING = "checkbox_enable_spatializer"; private static final String REDUCE_REFRESH_RATE_PREF_STRING = "checkbox_reduce_refresh_rate"; private static final String FULL_RANGE_PREF_STRING = "checkbox_full_range"; private static final String GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING = "checkbox_gamepad_touchpad_as_mouse"; private static final String GAMEPAD_MOTION_SENSORS_PREF_STRING = "checkbox_gamepad_motion_sensors"; private static final String GAMEPAD_MOTION_FALLBACK_PREF_STRING = "checkbox_gamepad_motion_fallback"; + + // 陀螺仪偏好设置 + private static final String GYRO_SENSITIVITY_MULTIPLIER_PREF_STRING = "gyro_sensitivity_multiplier"; + private static final String GYRO_INVERT_X_AXIS_PREF_STRING = "gyro_invert_x_axis"; + private static final String GYRO_INVERT_Y_AXIS_PREF_STRING = "gyro_invert_y_axis"; + private static final String GYRO_ACTIVATION_KEY_CODE_PREF_STRING = "gyro_activation_key_code"; + + // 麦克风设置 + private static final String ENABLE_MIC_PREF_STRING = "checkbox_enable_mic"; + private static final String MIC_BITRATE_PREF_STRING = "seekbar_mic_bitrate_kbps"; + private static final String MIC_ICON_COLOR_PREF_STRING = "list_mic_icon_color"; + private static final String ENABLE_ESC_MENU_PREF_STRING = "checkbox_enable_esc_menu"; + + // 控制流only模式设置 + private static final String CONTROL_ONLY_PREF_STRING = "checkbox_control_only"; + + //wg + private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; + static final String IMPORT_CONFIG_STRING = "import_super_config"; + static final String EXPORT_CONFIG_STRING = "export_super_config"; + static final String MERGE_CONFIG_STRING = "merge_super_config"; + static final String ABOUT_AUTHOR = "about_author"; - static final String DEFAULT_RESOLUTION = "1280x720"; + static final String DEFAULT_RESOLUTION = "1920x1080"; static final String DEFAULT_FPS = "60"; private static final boolean DEFAULT_STRETCH = false; private static final boolean DEFAULT_SOPS = true; @@ -80,18 +159,23 @@ public enum AnalogStickForScrolling { public static final String DEFAULT_LANGUAGE = "default"; private static final boolean DEFAULT_MULTI_CONTROLLER = true; private static final boolean DEFAULT_USB_DRIVER = true; - private static final String DEFAULT_VIDEO_FORMAT = "auto"; private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false; + private static final boolean ONSCREEN_KEYBOARD_DEFAULT = false; private static final boolean ONLY_L3_R3_DEFAULT = false; private static final boolean SHOW_GUIDE_BUTTON_DEFAULT = true; + private static final boolean HALF_HEIGHT_OSC_PORTRAIT_DEFAULT = true; private static final boolean DEFAULT_ENABLE_HDR = false; private static final boolean DEFAULT_ENABLE_PIP = false; private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false; + private static final boolean DEFAULT_PERF_OVERLAY_LOCKED = false; + private static final String DEFAULT_PERF_OVERLAY_ORIENTATION = "horizontal"; + private static final String DEFAULT_PERF_OVERLAY_POSITION = "top"; private static final boolean DEFAULT_BIND_ALL_USB = false; private static final boolean DEFAULT_MOUSE_EMULATION = true; private static final String DEFAULT_ANALOG_STICK_FOR_SCROLLING = "right"; private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false; + private static final String DEFAULT_NATIVE_MOUSE_MODE_PRESET = "classic"; private static final boolean DEFAULT_UNLOCK_FPS = false; private static final boolean DEFAULT_VIBRATE_OSC = true; private static final boolean DEFAULT_VIBRATE_FALLBACK = false; @@ -100,19 +184,46 @@ public enum AnalogStickForScrolling { private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true; private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo private static final boolean DEFAULT_LATENCY_TOAST = false; + private static final boolean DEFAULT_ENABLE_STUN = false; + private static final String DEFAULT_SCREEN_COMBINATION_MODE = "-1"; private static final String DEFAULT_FRAME_PACING = "latency"; private static final boolean DEFAULT_ABSOLUTE_MOUSE_MODE = false; + private static final boolean DEFAULT_ENABLE_NATIVE_MOUSE_POINTER = false; private static final boolean DEFAULT_ENABLE_AUDIO_FX = false; + private static final boolean DEFAULT_ENABLE_SPATIALIZER = false; private static final boolean DEFAULT_REDUCE_REFRESH_RATE = false; private static final boolean DEFAULT_FULL_RANGE = false; private static final boolean DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE = false; private static final boolean DEFAULT_GAMEPAD_MOTION_SENSORS = true; private static final boolean DEFAULT_GAMEPAD_MOTION_FALLBACK = false; + + // 陀螺仪偏好默认值 + private static final float DEFAULT_GYRO_SENSITIVITY_MULTIPLIER = 1.0f; + private static final boolean DEFAULT_GYRO_INVERT_X_AXIS = false; + private static final boolean DEFAULT_GYRO_INVERT_Y_AXIS = false; + private static final int DEFAULT_GYRO_ACTIVATION_KEY_CODE = KeyEvent.KEYCODE_BUTTON_L2; + // 麦克风设置默认值 + private static final boolean DEFAULT_ENABLE_MIC = false; + private static final int DEFAULT_MIC_BITRATE = 96; // 默认128 kbps + private static final String DEFAULT_MIC_ICON_COLOR = "solid_white"; // 默认白 + private static final boolean DEFAULT_ENABLE_ESC_MENU = true; // 默认启用ESC菜单 + + // 控制流only模式默认值 + private static final boolean DEFAULT_CONTROL_ONLY = false; + + private static final boolean DEFAULT_ENABLE_DOUBLE_CLICK_DRAG = false; + public boolean enableDoubleClickDrag; + + private static final boolean DEFAULT_ENABLE_LOCAL_CURSOR_RENDERING = true; + public boolean enableLocalCursorRendering; + public static final int FRAME_PACING_MIN_LATENCY = 0; public static final int FRAME_PACING_BALANCED = 1; public static final int FRAME_PACING_CAP_FPS = 2; public static final int FRAME_PACING_MAX_SMOOTHNESS = 3; + public static final int FRAME_PACING_EXPERIMENTAL_LOW_LATENCY = 4; + public static final int FRAME_PACING_SURFACE_FLINGER_RAW = 5; public static final String RES_360P = "640x360"; public static final String RES_480P = "854x480"; @@ -122,8 +233,57 @@ public enum AnalogStickForScrolling { public static final String RES_4K = "3840x2160"; public static final String RES_NATIVE = "Native"; - public int width, height, fps; + private static final String VIDEO_FORMAT_AUTO = "auto"; + private static final String VIDEO_FORMAT_AV1 = "forceav1"; + private static final String VIDEO_FORMAT_HEVC = "forceh265"; + private static final String VIDEO_FORMAT_H264 = "neverh265"; + + private static final String[] RESOLUTIONS = { + "640x360", "854x480", "1280x720", "1920x1080", "2560x1440", "3840x2160", "Native" + }; + + private static final String REVERSE_RESOLUTION_PREF_STRING = "checkbox_reverse_resolution"; + private static final boolean DEFAULT_REVERSE_RESOLUTION = false; + + private static final String ROTABLE_SCREEN_PREF_STRING = "checkbox_rotable_screen"; + private static final boolean DEFAULT_ROTABLE_SCREEN = false; + + // 画面位置常量 + private static final String SCREEN_POSITION_PREF_STRING = "list_screen_position"; + private static final String SCREEN_OFFSET_X_PREF_STRING = "seekbar_screen_offset_x"; + private static final String SCREEN_OFFSET_Y_PREF_STRING = "seekbar_screen_offset_y"; + + // 默认值 + private static final String DEFAULT_SCREEN_POSITION = "center"; // 居中 + private static final int DEFAULT_SCREEN_OFFSET_X = 0; + private static final int DEFAULT_SCREEN_OFFSET_Y = 0; + + // 位置枚举 + public enum ScreenPosition { + TOP_LEFT, + TOP_CENTER, + TOP_RIGHT, + CENTER_LEFT, + CENTER, + CENTER_RIGHT, + BOTTOM_LEFT, + BOTTOM_CENTER, + BOTTOM_RIGHT + } + + public int width, height, fps, resolutionScale; public int bitrate; + public int longPressflatRegionPixels; //Assigned to NativeTouchContext.INTIAL_ZONE_PIXELS + public boolean syncTouchEventWithDisplay; // if true, view.requestUnbufferedDispatch(event) will be disabled + public boolean enableEnhancedTouch; //Assigned to NativeTouchContext.ENABLE_ENHANCED_TOUCH + public boolean enhancedTouchOnWhichSide; //Assigned to NativeTouchContext.ENHANCED_TOUCH_ON_RIGHT + public int enhanceTouchZoneDivider; //Assigned to NativeTouchContext.ENHANCED_TOUCH_ZONE_DIVIDER + public float pointerVelocityFactor; //Assigned to NativeTouchContext.POINTER_VELOCITY_FACTOR + // public float pointerFixedXVelocity; //Assigned to NativeTouchContext.POINTER_FIXED_X_VELOCITY + public int nativeTouchFingersToToggleKeyboard; // Number of fingers to tap to toggle local on-screen keyboard in native touch mode. + + + public FormatOption videoFormat; public int deadzonePercentage; public int oscOpacity; @@ -131,12 +291,22 @@ public enum AnalogStickForScrolling { public String language; public boolean smallIconMode, multiController, usbDriver, flipFaceButtons; public boolean onscreenController; + public boolean onscreenKeyboard; public boolean onlyL3R3; public boolean showGuideButton; + public boolean halfHeightOscPortrait; public boolean enableHdr; public boolean enablePip; public boolean enablePerfOverlay; + public boolean perfOverlayLocked; + public PerfOverlayOrientation perfOverlayOrientation; + public PerfOverlayPosition perfOverlayPosition; + public boolean enableSimplifyPerfOverlay; public boolean enableLatencyToast; + public boolean enableStun; + public int screenCombinationMode; + public boolean lockScreenAfterDisconnect; + public boolean swapQuitAndDisconnect; public boolean bindAllUsb; public boolean mouseEmulation; public AnalogStickForScrolling analogStickForScrolling; @@ -149,35 +319,54 @@ public enum AnalogStickForScrolling { public MoonBridge.AudioConfiguration audioConfiguration; public int framePacing; public boolean absoluteMouseMode; + public boolean enableNativeMousePointer; public boolean enableAudioFx; + public boolean enableSpatializer; public boolean reduceRefreshRate; public boolean fullRange; public boolean gamepadMotionSensors; public boolean gamepadTouchpadAsMouse; public boolean gamepadMotionSensorsFallbackToDevice; + public boolean reverseResolution; + public boolean rotableScreen; + // Runtime-only: enable mapping gyroscope motion to right analog stick + public boolean gyroToRightStick; + // Runtime-only: sensitivity in deg/s for full stick deflection + public float gyroFullDeflectionDps; + // Persistent: sensitivity multiplier (higher -> faster) + public float gyroSensitivityMultiplier; + // Persistent: activation keycode to hold (Android keycode); 0 means LT analog, 1 means RT analog, otherwise Android key + public int gyroActivationKeyCode; + // Persistent: invert X-axis direction for gyro input + public boolean gyroInvertXAxis; + // Persistent: invert Y-axis direction for gyro input + public boolean gyroInvertYAxis; + // Card visibility + public boolean showBitrateCard; + public boolean showGyroCard; + public boolean showQuickKeyCard; - public static boolean isNativeResolution(int width, int height) { - // It's not a native resolution if it matches an existing resolution option - if (width == 640 && height == 360) { - return false; - } - else if (width == 854 && height == 480) { - return false; - } - else if (width == 1280 && height == 720) { - return false; - } - else if (width == 1920 && height == 1080) { - return false; - } - else if (width == 2560 && height == 1440) { - return false; - } - else if (width == 3840 && height == 2160) { - return false; - } + // 麦克风设置 + public boolean enableMic; + public int micBitrate; + public String micIconColor; + + // ESC菜单设置 + public boolean enableEscMenu; + + // 控制流only模式设置 + public boolean controlOnly; - return true; + public ScreenPosition screenPosition; + public int screenOffsetX; + public int screenOffsetY; + + public boolean useExternalDisplay; + + public static boolean isNativeResolution(int width, int height) { + // 使用集合检查是否为原生分辨率 + Set resolutionSet = new HashSet<>(Arrays.asList(RESOLUTIONS)); + return !resolutionSet.contains(width + "x" + height); } // If we have a screen that has semi-square dimensions, we may want to change our behavior @@ -226,34 +415,37 @@ else if (resString.equalsIgnoreCase("4K")) { } else { // Should be unreachable - return RES_720P; + return RES_1080P; } } private static int getWidthFromResolutionString(String resString) { - return Integer.parseInt(resString.split("x")[0]); + try { + return Integer.parseInt(resString.split("x")[0]); + } catch (Exception e) { + // 如果解析失败,返回默认宽度 + return Integer.parseInt(DEFAULT_RESOLUTION.split("x")[0]); + } } private static int getHeightFromResolutionString(String resString) { - return Integer.parseInt(resString.split("x")[1]); + try { + return Integer.parseInt(resString.split("x")[1]); + } catch (Exception e) { + // 如果解析失败,返回默认高度 + return Integer.parseInt(DEFAULT_RESOLUTION.split("x")[1]); + } } private static String getResolutionString(int width, int height) { - switch (height) { - case 360: - return RES_360P; - case 480: - return RES_480P; - default: - case 720: - return RES_720P; - case 1080: - return RES_1080P; - case 1440: - return RES_1440P; - case 2160: - return RES_4K; + // 使用数组简化分辨率获取 + for (String res : RESOLUTIONS) { + String[] dimensions = res.split("x"); + if (height == Integer.parseInt(dimensions[1])) { + return res; + } } + return RES_1080P; // 默认返回1080P } public static int getDefaultBitrate(String resString, String fpsString) { @@ -348,26 +540,34 @@ public static int getDefaultBitrate(Context context) { private static FormatOption getVideoFormatValue(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, DEFAULT_VIDEO_FORMAT); - if (str.equals("auto")) { - return FormatOption.AUTO; - } - else if (str.equals("forceav1")) { + String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, VIDEO_FORMAT_AUTO); + if (str.equals(VIDEO_FORMAT_AV1)) { return FormatOption.FORCE_AV1; - } - else if (str.equals("forceh265")) { + } else if (str.equals(VIDEO_FORMAT_HEVC)) { return FormatOption.FORCE_HEVC; - } - else if (str.equals("neverh265")) { + } else if (str.equals(VIDEO_FORMAT_H264)) { return FormatOption.FORCE_H264; } else { - // Should never get here return FormatOption.AUTO; } } + private static String getVideoFormatPreferenceString(FormatOption format) { + switch (format) { + case AUTO: + return VIDEO_FORMAT_AUTO; + case FORCE_AV1: + return VIDEO_FORMAT_AV1; + case FORCE_HEVC: + return VIDEO_FORMAT_HEVC; + case FORCE_H264: + return VIDEO_FORMAT_H264; + default: + return VIDEO_FORMAT_AUTO; + } + } + private static int getFramePacingValue(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); @@ -393,6 +593,12 @@ else if (str.equals("cap-fps")) { else if (str.equals("smoothness")) { return FRAME_PACING_MAX_SMOOTHNESS; } + else if (str.equals("experimental-low-latency")) { + return FRAME_PACING_EXPERIMENTAL_LOW_LATENCY; + } + else if (str.equals("surface-flinger-raw")) { + return FRAME_PACING_SURFACE_FLINGER_RAW; + } else { // Should never get here return FRAME_PACING_MIN_LATENCY; @@ -420,6 +626,7 @@ public static void resetStreamingSettings(Context context) { prefs.edit() .remove(BITRATE_PREF_STRING) .remove(BITRATE_PREF_OLD_STRING) + .remove(HOST_SCALE_PREF_STRING) .remove(LEGACY_RES_FPS_PREF_STRING) .remove(RESOLUTION_PREF_STRING) .remove(FPS_PREF_STRING) @@ -457,74 +664,37 @@ public static PreferenceConfiguration readPreferences(Context context) { } } - String str = prefs.getString(LEGACY_RES_FPS_PREF_STRING, null); - if (str != null) { - if (str.equals("360p30")) { - config.width = 640; - config.height = 360; - config.fps = 30; - } - else if (str.equals("360p60")) { - config.width = 640; - config.height = 360; - config.fps = 60; - } - else if (str.equals("720p30")) { - config.width = 1280; - config.height = 720; - config.fps = 30; - } - else if (str.equals("720p60")) { - config.width = 1280; - config.height = 720; - config.fps = 60; - } - else if (str.equals("1080p30")) { - config.width = 1920; - config.height = 1080; - config.fps = 30; - } - else if (str.equals("1080p60")) { - config.width = 1920; - config.height = 1080; - config.fps = 60; - } - else if (str.equals("4K30")) { - config.width = 3840; - config.height = 2160; - config.fps = 30; - } - else if (str.equals("4K60")) { - config.width = 3840; - config.height = 2160; - config.fps = 60; - } - else { - // Should never get here - config.width = 1280; - config.height = 720; - config.fps = 60; - } - - prefs.edit() - .remove(LEGACY_RES_FPS_PREF_STRING) - .putString(RESOLUTION_PREF_STRING, getResolutionString(config.width, config.height)) - .putString(FPS_PREF_STRING, ""+config.fps) - .apply(); - } - else { - // Use the new preference location - String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); + String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); - // Convert legacy resolution strings to the new style - if (!resStr.contains("x")) { - resStr = PreferenceConfiguration.convertFromLegacyResolutionString(resStr); - prefs.edit().putString(RESOLUTION_PREF_STRING, resStr).apply(); + // 添加Native分辨率支持 + if (resStr.equals(RES_NATIVE)) { + // 获取设备原生分辨率 + Display display = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + Point size = new Point(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + display.getRealSize(size); // 需要API 17+ + } else { + display.getSize(size); // 兼容旧版本 } - + config.width = size.x; + config.height = size.y; + } else { + // 原有解析逻辑 config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr); config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr); - config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS)); + } + + // 处理新旧数据类型兼容 + Object fpsValue = prefs.getAll().get(FPS_PREF_STRING); + if (fpsValue instanceof String) { + config.fps = Integer.parseInt((String) fpsValue); + } else if (fpsValue instanceof Integer) { + // 迁移旧整型值为字符串 + config.fps = (Integer) fpsValue; + prefs.edit().putString(FPS_PREF_STRING, String.valueOf(config.fps)).apply(); + } else { + // 默认值处理 + config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, DEFAULT_FPS)); } if (!prefs.contains(SMALL_ICONS_PREF_STRING)) { @@ -548,6 +718,23 @@ else if (str.equals("4K60")) { config.bitrate = getDefaultBitrate(context); } + config.resolutionScale = prefs.getInt(HOST_SCALE_PREF_STRING, 100); + config.longPressflatRegionPixels = prefs.getInt(LONG_PRESS_FLAT_REGION_PIXELS_PREF_STRING, 0); // define a flat region to suppress coordinates jitter. This is a simulation of iOS behavior since it only send 1 touch event during long press, which feels better in some cases. + config.syncTouchEventWithDisplay = prefs.getBoolean(SYNC_TOUCH_EVENT_WITH_DISPLAY_PREF_STRING, false); // set true to disable "requestUnbufferedDispatch", feels better in some cases. + if(prefs.getBoolean(ENABLE_KEYBOARD_TOGGLE_IN_NATIVE_TOUCH, true)) { + config.nativeTouchFingersToToggleKeyboard = prefs.getInt(NATIVE_TOUCH_FINGERS_TO_TOGGLE_KEYBOARD_PREF_STRING, 3); // least fingers of tap to toggle local keyboard, configurable from 3 to 10 in menu. + } + else{ + config.nativeTouchFingersToToggleKeyboard = -1; // completely disable keyboard toggle in multi-point touch + } + + // Enhance touch settings + config.enableEnhancedTouch = prefs.getBoolean(ENABLE_ENHANCED_TOUCH_PREF_STRING, false); + config.enhancedTouchOnWhichSide = prefs.getBoolean(ENHANCED_TOUCH_ON_RIGHT_PREF_STRING, true); // by default, enhanced touch zone is on the right side. + config.enhanceTouchZoneDivider = prefs.getInt(ENHANCED_TOUCH_ZONE_DIVIDER_PREF_STRING,50); // decides where to divide native touch zone & enhance touch zone by a vertical line. + config.pointerVelocityFactor = prefs.getInt(POINTER_VELOCITY_FACTOR_PREF_STRING,100); // set pointer velocity faster or slower within enhance touch zone, useful in some games for tweaking view rotation sensitivity. + // config.pointerFixedXVelocity = prefs.getInt(POINTER_FIXED_X_VELOCITY_PREF_STRING,0); + String audioConfig = prefs.getString(AUDIO_CONFIG_PREF_STRING, DEFAULT_AUDIO_CONFIG); if (audioConfig.equals("71")) { config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_71_SURROUND; @@ -572,6 +759,8 @@ else if (audioConfig.equals("51")) { // Checkbox preferences config.disableWarnings = prefs.getBoolean(DISABLE_TOASTS_PREF_STRING, DEFAULT_DISABLE_TOASTS); + config.enableDoubleClickDrag = prefs.getBoolean(ENABLE_DOUBLE_CLICK_DRAG_PREF_STRING, DEFAULT_ENABLE_DOUBLE_CLICK_DRAG); + config.enableLocalCursorRendering = prefs.getBoolean(ENABLE_LOCAL_CURSOR_RENDERING_PREF_STRING, DEFAULT_ENABLE_LOCAL_CURSOR_RENDERING); config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS); config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH); config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO); @@ -579,11 +768,45 @@ else if (audioConfig.equals("51")) { config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER); config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT); + config.onscreenKeyboard = prefs.getBoolean(ONSCREEN_KEYBOARD_PREF_STRING, ONSCREEN_KEYBOARD_DEFAULT); config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT); config.showGuideButton = prefs.getBoolean(SHOW_GUIDE_BUTTON_PREF_STRING, SHOW_GUIDE_BUTTON_DEFAULT); + config.halfHeightOscPortrait = prefs.getBoolean(HALF_HEIGHT_OSC_PORTRAIT_PREF_STRING, HALF_HEIGHT_OSC_PORTRAIT_DEFAULT); config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR) && !isShieldAtvFirmwareWithBrokenHdr(); config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP); config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY); + config.perfOverlayLocked = prefs.getBoolean(PERF_OVERLAY_LOCKED_STRING, DEFAULT_PERF_OVERLAY_LOCKED); + + // 读取性能覆盖层方向和位置设置 + String perfOverlayOrientation = prefs.getString(PERF_OVERLAY_ORIENTATION_STRING, DEFAULT_PERF_OVERLAY_ORIENTATION); + if ("vertical".equals(perfOverlayOrientation)) { + config.perfOverlayOrientation = PerfOverlayOrientation.VERTICAL; + } else { + config.perfOverlayOrientation = PerfOverlayOrientation.HORIZONTAL; + } + + String perfOverlayPosition = prefs.getString(PERF_OVERLAY_POSITION_STRING, DEFAULT_PERF_OVERLAY_POSITION); + switch (perfOverlayPosition) { + case "bottom": + config.perfOverlayPosition = PerfOverlayPosition.BOTTOM; + break; + case "top_left": + config.perfOverlayPosition = PerfOverlayPosition.TOP_LEFT; + break; + case "top_right": + config.perfOverlayPosition = PerfOverlayPosition.TOP_RIGHT; + break; + case "bottom_left": + config.perfOverlayPosition = PerfOverlayPosition.BOTTOM_LEFT; + break; + case "bottom_right": + config.perfOverlayPosition = PerfOverlayPosition.BOTTOM_RIGHT; + break; + default: + config.perfOverlayPosition = PerfOverlayPosition.TOP; + break; + } + config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB); config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION); config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS); @@ -594,14 +817,242 @@ else if (audioConfig.equals("51")) { config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS); config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD); config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST); + config.enableStun = prefs.getBoolean(ENABLE_STUN_PREF_STRING, DEFAULT_ENABLE_STUN); + + String screenModeString = prefs.getString(SCREEN_COMBINATION_MODE_PREF_STRING, DEFAULT_SCREEN_COMBINATION_MODE); + try { + config.screenCombinationMode = Integer.parseInt(screenModeString); + } catch (NumberFormatException e) { + config.screenCombinationMode = -1; + } + + config.lockScreenAfterDisconnect = prefs.getBoolean(LOCK_SCREEN_AFTER_DISCONNECT_PREF_STRING, DEFAULT_LATENCY_TOAST); + config.swapQuitAndDisconnect = prefs.getBoolean(SWAP_QUIT_AND_DISCONNECT_PERF_STRING, DEFAULT_LATENCY_TOAST); config.absoluteMouseMode = prefs.getBoolean(ABSOLUTE_MOUSE_MODE_PREF_STRING, DEFAULT_ABSOLUTE_MOUSE_MODE); + + // 对于没有触摸屏的设备,默认启用本地鼠标指针 + boolean hasTouchscreen = context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + boolean defaultNativeMouse = hasTouchscreen ? DEFAULT_ENABLE_NATIVE_MOUSE_POINTER : true; + config.enableNativeMousePointer = prefs.getBoolean(ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, defaultNativeMouse); + + // 如果没有触摸屏,强制设置为本地鼠标指针模式 + if (!hasTouchscreen) { + config.enableNativeMousePointer = true; + config.enableEnhancedTouch = false; + config.touchscreenTrackpad = false; + } config.enableAudioFx = prefs.getBoolean(ENABLE_AUDIO_FX_PREF_STRING, DEFAULT_ENABLE_AUDIO_FX); + config.enableSpatializer = prefs.getBoolean(ENABLE_SPATIALIZER_PREF_STRING, DEFAULT_ENABLE_SPATIALIZER); config.reduceRefreshRate = prefs.getBoolean(REDUCE_REFRESH_RATE_PREF_STRING, DEFAULT_REDUCE_REFRESH_RATE); config.fullRange = prefs.getBoolean(FULL_RANGE_PREF_STRING, DEFAULT_FULL_RANGE); config.gamepadTouchpadAsMouse = prefs.getBoolean(GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING, DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE); config.gamepadMotionSensors = prefs.getBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, DEFAULT_GAMEPAD_MOTION_SENSORS); config.gamepadMotionSensorsFallbackToDevice = prefs.getBoolean(GAMEPAD_MOTION_FALLBACK_PREF_STRING, DEFAULT_GAMEPAD_MOTION_FALLBACK); + config.enableSimplifyPerfOverlay = false; + + // 加载陀螺仪偏好设置 + config.gyroSensitivityMultiplier = prefs.getFloat(GYRO_SENSITIVITY_MULTIPLIER_PREF_STRING, DEFAULT_GYRO_SENSITIVITY_MULTIPLIER); + config.gyroInvertXAxis = prefs.getBoolean(GYRO_INVERT_X_AXIS_PREF_STRING, DEFAULT_GYRO_INVERT_X_AXIS); + config.gyroInvertYAxis = prefs.getBoolean(GYRO_INVERT_Y_AXIS_PREF_STRING, DEFAULT_GYRO_INVERT_Y_AXIS); + config.gyroActivationKeyCode = prefs.getInt(GYRO_ACTIVATION_KEY_CODE_PREF_STRING, DEFAULT_GYRO_ACTIVATION_KEY_CODE); + + // Cards visibility (defaults to true) + config.showBitrateCard = prefs.getBoolean(SHOW_BITRATE_CARD_PREF_STRING, true); + config.showGyroCard = prefs.getBoolean(SHOW_GYRO_CARD_PREF_STRING, true); + config.showQuickKeyCard = prefs.getBoolean(SHOW_QuickKeyCard, true); + + // 读取麦克风设置 + config.enableMic = prefs.getBoolean(ENABLE_MIC_PREF_STRING, DEFAULT_ENABLE_MIC); + config.micBitrate = prefs.getInt(MIC_BITRATE_PREF_STRING, DEFAULT_MIC_BITRATE); + config.micIconColor = prefs.getString(MIC_ICON_COLOR_PREF_STRING, DEFAULT_MIC_ICON_COLOR); + + // 读取ESC菜单设置 + config.enableEscMenu = prefs.getBoolean(ENABLE_ESC_MENU_PREF_STRING, DEFAULT_ENABLE_ESC_MENU); + + // 读取控制流only模式设置 + config.controlOnly = prefs.getBoolean(CONTROL_ONLY_PREF_STRING, DEFAULT_CONTROL_ONLY); + + config.reverseResolution = prefs.getBoolean(REVERSE_RESOLUTION_PREF_STRING, DEFAULT_REVERSE_RESOLUTION); + config.rotableScreen = prefs.getBoolean(ROTABLE_SCREEN_PREF_STRING, DEFAULT_ROTABLE_SCREEN); + + // 如果启用了分辨率反转,则交换宽度和高度 + if (config.reverseResolution) { + int temp = config.width; + config.width = config.height; + config.height = temp; + } + + // 读取画面位置设置 + String posString = prefs.getString(SCREEN_POSITION_PREF_STRING, DEFAULT_SCREEN_POSITION); + switch (posString) { + case "top_left": + config.screenPosition = ScreenPosition.TOP_LEFT; + break; + case "top_center": + config.screenPosition = ScreenPosition.TOP_CENTER; + break; + case "top_right": + config.screenPosition = ScreenPosition.TOP_RIGHT; + break; + case "center_left": + config.screenPosition = ScreenPosition.CENTER_LEFT; + break; + case "center_right": + config.screenPosition = ScreenPosition.CENTER_RIGHT; + break; + case "bottom_left": + config.screenPosition = ScreenPosition.BOTTOM_LEFT; + break; + case "bottom_center": + config.screenPosition = ScreenPosition.BOTTOM_CENTER; + break; + case "bottom_right": + config.screenPosition = ScreenPosition.BOTTOM_RIGHT; + break; + default: + config.screenPosition = ScreenPosition.CENTER; + break; + } + + // 读取偏移百分比 + config.screenOffsetX = prefs.getInt(SCREEN_OFFSET_X_PREF_STRING, DEFAULT_SCREEN_OFFSET_X); + config.screenOffsetY = prefs.getInt(SCREEN_OFFSET_Y_PREF_STRING, DEFAULT_SCREEN_OFFSET_Y); + + config.useExternalDisplay = prefs.getBoolean("use_external_display", false); + + // Runtime-only defaults; controlled via in-stream GameMenu + config.gyroToRightStick = false; + config.gyroFullDeflectionDps = 180.0f; return config; } + + public boolean writePreferences(Context context) { + return writePreferences(context, false); + } + + /** + * 写入设置到SharedPreferences + * @param context 上下文 + * @param synchronous 是否同步写入(true使用commit,false使用apply) + * @return 是否成功 + */ + public boolean writePreferences(Context context, boolean synchronous) { + if (context == null) { + return false; + } + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + try { + // 转换枚举为字符串 + String positionString; + switch (screenPosition) { + case TOP_LEFT: + positionString = "top_left"; + break; + case TOP_CENTER: + positionString = "top_center"; + break; + case TOP_RIGHT: + positionString = "top_right"; + break; + case CENTER_LEFT: + positionString = "center_left"; + break; + case CENTER_RIGHT: + positionString = "center_right"; + break; + case BOTTOM_LEFT: + positionString = "bottom_left"; + break; + case BOTTOM_CENTER: + positionString = "bottom_center"; + break; + case BOTTOM_RIGHT: + positionString = "bottom_right"; + break; + default: + positionString = "center"; + break; + } + + SharedPreferences.Editor editor = prefs.edit() + .putString(RESOLUTION_PREF_STRING, width + "x" + height) + .putString(FPS_PREF_STRING, String.valueOf(fps)) + .putInt(BITRATE_PREF_STRING, bitrate) + .putString(VIDEO_FORMAT_PREF_STRING, getVideoFormatPreferenceString(videoFormat)) + .putBoolean(ENABLE_HDR_PREF_STRING, enableHdr) + .putBoolean(ENABLE_PERF_OVERLAY_STRING, enablePerfOverlay) + .putBoolean(PERF_OVERLAY_LOCKED_STRING, perfOverlayLocked) + .putBoolean(REVERSE_RESOLUTION_PREF_STRING, reverseResolution) + .putBoolean(ROTABLE_SCREEN_PREF_STRING, rotableScreen) + .putBoolean(SHOW_BITRATE_CARD_PREF_STRING, showBitrateCard) + .putBoolean(SHOW_GYRO_CARD_PREF_STRING, showGyroCard) + .putBoolean(SHOW_QuickKeyCard, showQuickKeyCard) + .putString(SCREEN_POSITION_PREF_STRING, positionString) + .putInt(SCREEN_OFFSET_X_PREF_STRING, screenOffsetX) + .putInt(SCREEN_OFFSET_Y_PREF_STRING, screenOffsetY) + .putBoolean("use_external_display", useExternalDisplay) + .putBoolean(ENABLE_MIC_PREF_STRING, enableMic) + .putInt(MIC_BITRATE_PREF_STRING, micBitrate) + .putString(MIC_ICON_COLOR_PREF_STRING, micIconColor) + .putBoolean(ENABLE_ESC_MENU_PREF_STRING, enableEscMenu) + .putBoolean(CONTROL_ONLY_PREF_STRING, controlOnly) + .putBoolean(ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, enableNativeMousePointer) + .putBoolean(ENABLE_DOUBLE_CLICK_DRAG_PREF_STRING, enableDoubleClickDrag) + .putBoolean(ENABLE_LOCAL_CURSOR_RENDERING_PREF_STRING, enableLocalCursorRendering) + .putFloat(GYRO_SENSITIVITY_MULTIPLIER_PREF_STRING, gyroSensitivityMultiplier) + .putBoolean(GYRO_INVERT_X_AXIS_PREF_STRING, gyroInvertXAxis) + .putBoolean(GYRO_INVERT_Y_AXIS_PREF_STRING, gyroInvertYAxis) + .putInt(GYRO_ACTIVATION_KEY_CODE_PREF_STRING, gyroActivationKeyCode); + + if (synchronous) { + return editor.commit(); + } else { + editor.apply(); + return true; + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + public PreferenceConfiguration copy() { + PreferenceConfiguration copy = new PreferenceConfiguration(); + copy.width = this.width; + copy.height = this.height; + copy.fps = this.fps; + copy.bitrate = this.bitrate; + copy.videoFormat = this.videoFormat; + copy.enableHdr = this.enableHdr; + copy.enablePerfOverlay = this.enablePerfOverlay; + copy.perfOverlayLocked = this.perfOverlayLocked; + copy.perfOverlayOrientation = this.perfOverlayOrientation; + copy.perfOverlayPosition = this.perfOverlayPosition; + copy.reverseResolution = this.reverseResolution; + copy.rotableScreen = this.rotableScreen; + copy.screenPosition = this.screenPosition; + copy.screenOffsetX = this.screenOffsetX; + copy.screenOffsetY = this.screenOffsetY; + copy.useExternalDisplay = this.useExternalDisplay; + copy.enableMic = this.enableMic; + copy.controlOnly = this.controlOnly; + copy.micBitrate = this.micBitrate; + copy.micIconColor = this.micIconColor; + copy.enableEscMenu = this.enableEscMenu; + copy.enableNativeMousePointer = this.enableNativeMousePointer; + copy.enableDoubleClickDrag = this.enableDoubleClickDrag; + copy.enableLocalCursorRendering = this.enableLocalCursorRendering; + copy.gyroToRightStick = this.gyroToRightStick; + copy.gyroFullDeflectionDps = this.gyroFullDeflectionDps; + copy.gyroSensitivityMultiplier = this.gyroSensitivityMultiplier; + copy.gyroActivationKeyCode = this.gyroActivationKeyCode; + copy.gyroInvertXAxis = this.gyroInvertXAxis; + copy.gyroInvertYAxis = this.gyroInvertYAxis; + copy.showBitrateCard = this.showBitrateCard; + copy.showGyroCard = this.showGyroCard; + copy.showQuickKeyCard = this.showQuickKeyCard; + return copy; + } } diff --git a/app/src/main/java/com/limelight/preferences/ResetPerfOverlayPositionPreference.java b/app/src/main/java/com/limelight/preferences/ResetPerfOverlayPositionPreference.java new file mode 100644 index 0000000000..c77d966a77 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/ResetPerfOverlayPositionPreference.java @@ -0,0 +1,39 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.limelight.R; + +public class ResetPerfOverlayPositionPreference extends Preference { + + public ResetPerfOverlayPositionPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public ResetPerfOverlayPositionPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ResetPerfOverlayPositionPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ResetPerfOverlayPositionPreference(Context context) { + super(context); + } + + @Override + protected void onClick() { + super.onClick(); + + // 清除自定义位置设置 + SharedPreferences prefs = getContext().getSharedPreferences("performance_overlay", Context.MODE_PRIVATE); + prefs.edit().clear().apply(); + + Toast.makeText(getContext(), "性能统计位置已重置", Toast.LENGTH_SHORT).show(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java index 360ef29c0a..b65feee8e3 100644 --- a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java +++ b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java @@ -9,10 +9,13 @@ import android.view.Gravity; import android.view.View; import android.view.View.OnClickListener; +import android.view.MotionEvent; import android.widget.Button; import android.widget.LinearLayout; import android.widget.SeekBar; import android.widget.TextView; +import android.os.Handler; +import android.os.Looper; import java.util.Locale; @@ -21,10 +24,13 @@ public class SeekBarPreference extends DialogPreference { private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android"; private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar"; + private static final String TAG = "SeekBarPreference"; private SeekBar seekBar; private TextView valueText; private final Context context; + private static final int LONG_PRESS_DELAY = 400; // 长按延迟 400ms + private static final int LONG_PRESS_INTERVAL = 80; // 长按后每 80ms 触发一次 private final String dialogMessage; private final String suffix; @@ -35,6 +41,7 @@ public class SeekBarPreference extends DialogPreference private final int keyStepSize; private final int divisor; private int currentValue; + private boolean isLogarithmic = false; public SeekBarPreference(Context context, AttributeSet attrs) { super(context, attrs); @@ -65,6 +72,47 @@ public SeekBarPreference(Context context, AttributeSet attrs) { stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1); divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1); keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0); + + // 检查是否为码率设置 + String key = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "key"); + if (key != null && key.equals(PreferenceConfiguration.BITRATE_PREF_STRING)) { + isLogarithmic = true; + } + } + + // 将线性滑块值转换为对数刻度值 + private int linearToLog(int linearValue) { + if (linearValue <= minValue) return minValue; + + double minLog = Math.log(minValue); + double maxLog = Math.log(maxValue); + double normalizedValue = (linearValue - minValue) / (double)(maxValue - minValue); + double logValue = Math.exp(minLog + normalizedValue * (maxLog - minLog)); + int result = (int) Math.round(logValue); + result = Math.max(minValue, Math.min(maxValue, result)); + + // 使用四舍五入取整到步长倍数(避免向上取整导致累积增加) + return Math.round((float)result / stepSize) * stepSize; + } + + // 将对数刻度值转换回线性滑块值 + private int logToLinear(int logValue) { + if (logValue <= minValue) return minValue; + double minLog = Math.log(minValue); + double maxLog = Math.log(maxValue); + double normalizedValue = (Math.log(logValue) - minLog) / (maxLog - minLog); + double linearValue = minValue + normalizedValue * (maxValue - minValue); + return (int) Math.round(linearValue); + } + + // 格式化显示值 + private String formatDisplayValue(int value) { + if (divisor != 1) { + double displayValue = value / (double)divisor; + return String.format((Locale)null, "%.1f", displayValue); + } else { + return String.valueOf(value); + } } @Override @@ -82,15 +130,69 @@ protected View onCreateDialogView() { } layout.addView(splashText); + // 创建水平布局容器,包含数值文本和加减号按钮 + LinearLayout valueContainer = new LinearLayout(context); + valueContainer.setOrientation(LinearLayout.HORIZONTAL); + valueContainer.setGravity(Gravity.CENTER_HORIZONTAL); + valueContainer.setPadding(0, 10, 0, 10); + + // 数值文本 valueText = new TextView(context); - valueText.setGravity(Gravity.CENTER_HORIZONTAL); + valueText.setGravity(Gravity.CENTER); valueText.setTextSize(32); + if (isLogarithmic) { + valueText.setMinWidth(dpToPx(120)); + } // Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0% valueText.setText("0%"); + LinearLayout.LayoutParams valueParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1.0f); // 占据剩余空间 + valueText.setLayoutParams(valueParams); + valueContainer.addView(valueText); + + Button minusButton = null; + Button plusButton = null; + + // 如果是码率设置,添加加号减号按钮 + if (isLogarithmic) { + // 减号按钮 + minusButton = new Button(context); + minusButton.setText("−"); + minusButton.setTextSize(24); + minusButton.setGravity(Gravity.CENTER); + minusButton.setPadding(0, 0, 0, 0); + LinearLayout.LayoutParams minusParams = new LinearLayout.LayoutParams( + dpToPx(48), + dpToPx(48)); + minusParams.rightMargin = dpToPx(8); + minusParams.gravity = Gravity.CENTER_VERTICAL; + minusButton.setLayoutParams(minusParams); + setupLongPressButton(minusButton, -1); + + // 加号按钮 + plusButton = new Button(context); + plusButton.setText("+"); + plusButton.setTextSize(24); + plusButton.setGravity(Gravity.CENTER); + plusButton.setPadding(0, 0, 0, 0); + LinearLayout.LayoutParams plusParams = new LinearLayout.LayoutParams( + dpToPx(48), + dpToPx(48)); + plusParams.gravity = Gravity.CENTER_VERTICAL; + plusButton.setLayoutParams(plusParams); + setupLongPressButton(plusButton, 1); + + valueContainer.addView(minusButton); + valueContainer.addView(plusButton); + } + + // 将容器添加到主布局 params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - layout.addView(valueText, params); + layout.addView(valueContainer, params); seekBar = new SeekBar(context); seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @@ -101,20 +203,25 @@ public void onProgressChanged(SeekBar seekBar, int value, boolean b) { return; } - int roundedValue = ((value + (stepSize - 1))/stepSize)*stepSize; - if (roundedValue != value) { - seekBar.setProgress(roundedValue); - return; + // 对于码率设置,不对线性值取整(线性值只是中间值,取整会影响码率精度) + // 对于非码率设置,需要取整到步长倍数 + if (!isLogarithmic) { + int roundedValue = Math.round((float)value / stepSize) * stepSize; + roundedValue = Math.max(minValue, roundedValue); + if (roundedValue != value) { + seekBar.setProgress(roundedValue); + return; + } } - - String t; - if (divisor != 1) { - float floatValue = roundedValue / (float)divisor; - t = String.format((Locale)null, "%.1f", floatValue); - } - else { - t = String.valueOf(value); + + // 如果是码率设置,应用对数变换 + int displayValue = value; + if (isLogarithmic) { + displayValue = linearToLog(value); } + + // 使用优化的格式化方法 + String t = formatDisplayValue(displayValue); valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix)); } @@ -135,7 +242,13 @@ public void onStopTrackingTouch(SeekBar seekBar) {} if (keyStepSize != 0) { seekBar.setKeyProgressIncrement(keyStepSize); } - seekBar.setProgress(currentValue); + + // 如果是码率设置,将对数值转换为线性值显示 + if (isLogarithmic && currentValue > 0) { + seekBar.setProgress(logToLinear(currentValue)); + } else { + seekBar.setProgress(currentValue); + } return layout; } @@ -147,7 +260,13 @@ protected void onBindDialogView(View v) { if (keyStepSize != 0) { seekBar.setKeyProgressIncrement(keyStepSize); } - seekBar.setProgress(currentValue); + + // 如果是码率设置,将对数值转换为线性值显示 + if (isLogarithmic && currentValue > 0) { + seekBar.setProgress(logToLinear(currentValue)); + } else { + seekBar.setProgress(currentValue); + } } @Override @@ -165,29 +284,132 @@ protected void onSetInitialValue(boolean restore, Object defaultValue) public void setProgress(int progress) { this.currentValue = progress; if (seekBar != null) { - seekBar.setProgress(progress); + if (isLogarithmic && progress > 0) { + seekBar.setProgress(logToLinear(progress)); + } else { + seekBar.setProgress(progress); + } } } + public int getProgress() { return currentValue; } + + // dp 转 px 辅助方法 + private int dpToPx(int dp) { + float density = context.getResources().getDisplayMetrics().density; + return Math.round(dp * density); + } + + // 设置长按按钮(每个按钮独立的 Handler 和状态) + private void setupLongPressButton(Button button, int direction) { + // 使用数组包装,使其在 lambda 中可修改 + final Handler handler = new Handler(Looper.getMainLooper()); + final boolean[] isLongPressing = {false}; + final Runnable[] repeatRunnable = {null}; + + // 重复执行的 Runnable + repeatRunnable[0] = new Runnable() { + @Override + public void run() { + if (isLongPressing[0]) { + adjustValue(direction); + handler.postDelayed(this, LONG_PRESS_INTERVAL); + } + } + }; + + button.setOnClickListener(v -> { + // 单击:仅在非长按时触发 + if (!isLongPressing[0]) { + adjustValue(direction); + } + }); + + button.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isLongPressing[0] = false; + // 延迟后开始长按,并立即执行第一次 + handler.postDelayed(() -> { + isLongPressing[0] = true; + adjustValue(direction); // 长按触发后立即执行第一次 + handler.postDelayed(repeatRunnable[0], LONG_PRESS_INTERVAL); + }, LONG_PRESS_DELAY); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // 停止长按 + handler.removeCallbacksAndMessages(null); + isLongPressing[0] = false; + break; + } + return false; // 返回 false 以允许 onClick 事件 + }); + } + + // 调整数值(+1 或 -1) + private void adjustValue(int direction) { + if (seekBar == null) return; + + int currentProgress = seekBar.getProgress(); + int newProgress; + + if (isLogarithmic) { + // 对于码率设置,需要先转换为实际码率值,调整后再转回线性值 + int currentBitrate = linearToLog(currentProgress); + + // 计算调整步长(根据当前码率值动态调整) + int adjustStep = stepSize; + if (currentBitrate > 50000) { + // 高码率时使用更大的步长 + adjustStep = stepSize * 2; + } + + int newBitrate = currentBitrate + (direction * adjustStep); + newBitrate = Math.max(minValue, Math.min(maxValue, newBitrate)); + + // 转回线性值 + newProgress = logToLinear(newBitrate); + } else { + // 非码率设置,直接调整线性值 + newProgress = currentProgress + (direction * stepSize); + newProgress = Math.max(minValue, Math.min(maxValue, newProgress)); + } + + // 更新滑块位置(这会触发 onProgressChanged,自动更新显示) + // 注意:对于码率设置,onProgressChanged 中不会对线性值取整, + // 所以不会触发额外的调整,只是更新显示 + seekBar.setProgress(newProgress); + } @Override public void showDialog(Bundle state) { super.showDialog(state); - Button positiveButton = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); - positiveButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View view) { - if (shouldPersist()) { - currentValue = seekBar.getProgress(); - persistInt(seekBar.getProgress()); - callChangeListener(seekBar.getProgress()); - } + AlertDialog dialog = (AlertDialog) getDialog(); + if (dialog != null) { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + if (shouldPersist()) { + int valueToSave = seekBar.getProgress(); + + // 如果是码率设置,保存对数变换后的值 + if (isLogarithmic) { + valueToSave = linearToLog(valueToSave); + } + + currentValue = valueToSave; + persistInt(valueToSave); + callChangeListener(valueToSave); + } - getDialog().dismiss(); - } - }); + getDialog().dismiss(); + } + }); + } } } diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java index 7070104168..cf7420684a 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.java +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java @@ -6,6 +6,7 @@ import android.content.pm.PackageManager; import android.content.res.Configuration; import android.media.MediaCodecInfo; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.app.Activity; @@ -26,20 +27,56 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.Toast; +import android.graphics.Color; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.graphics.drawable.GradientDrawable; +import android.util.TypedValue; +import android.widget.ListView; +import android.preference.PreferenceGroup; +import android.widget.HorizontalScrollView; +import android.widget.ScrollView; +import com.google.android.flexbox.FlexboxLayout; +import com.google.android.flexbox.FlexWrap; +import com.google.android.flexbox.FlexDirection; +import com.google.android.flexbox.JustifyContent; + +import androidx.annotation.NonNull; import com.limelight.LimeLog; import com.limelight.PcView; import com.limelight.R; +import com.limelight.ExternalDisplayManager; +import com.limelight.binding.input.advance_setting.config.PageConfigController; +import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper; import com.limelight.binding.video.MediaCodecHelper; +import com.limelight.utils.AspectRatioConverter; import com.limelight.utils.Dialog; import com.limelight.utils.UiHelper; +import com.limelight.utils.UpdateManager; -import java.lang.reflect.Method; -import java.util.Arrays; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.*; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; +import jp.wasabeef.glide.transformations.BlurTransformation; +import jp.wasabeef.glide.transformations.ColorFilterTransformation; public class StreamSettings extends Activity { + + + private PreferenceConfiguration previousPrefs; private int previousDisplayPixelCount; + private ExternalDisplayManager externalDisplayManager; // HACK for Android 9 static DisplayCutout displayCutoutP; @@ -49,22 +86,39 @@ void reloadSettings() { Display.Mode mode = getWindowManager().getDefaultDisplay().getMode(); previousDisplayPixelCount = mode.getPhysicalWidth() * mode.getPhysicalHeight(); } - getFragmentManager().beginTransaction().replace( - R.id.stream_settings, new SettingsFragment() - ).commitAllowingStateLoss(); + getFragmentManager().beginTransaction().replace( + R.id.preference_container, new SettingsFragment() + ).commitAllowingStateLoss(); } @Override protected void onCreate(Bundle savedInstanceState) { + // 应用带阴影的主题 + getTheme().applyStyle(R.style.PreferenceThemeWithShadow, true); + super.onCreate(savedInstanceState); previousPrefs = PreferenceConfiguration.readPreferences(this); + // 初始化外接显示器管理器 + if (previousPrefs.useExternalDisplay) { + externalDisplayManager = new ExternalDisplayManager(this, previousPrefs, null, null, null, null); + externalDisplayManager.initialize(); + } + UiHelper.setLocale(this); + // 设置自定义布局 setContentView(R.layout.activity_stream_settings); + + // 确保状态栏透明 + getWindow().setStatusBarColor(Color.TRANSPARENT); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); UiHelper.notifyNewRootView(this); + + // 加载背景图片 + loadBackgroundImage(); } @Override @@ -86,7 +140,7 @@ public void onAttachedToWindow() { } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -103,6 +157,17 @@ public void onConfigurationChanged(Configuration newConfig) { } } + @Override + protected void onDestroy() { + super.onDestroy(); + + // 清理外接显示器管理器 + if (externalDisplayManager != null) { + externalDisplayManager.cleanup(); + externalDisplayManager = null; + } + } + @Override // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true" public void onBackPressed() { @@ -122,9 +187,23 @@ public void onBackPressed() { } public static class SettingsFragment extends PreferenceFragment { + private int nativeResolutionStartIndex = Integer.MAX_VALUE; private boolean nativeFramerateShown = false; + private String exportConfigString = null; + + /** + * 获取目标显示器(优先使用外接显示器) + */ + private Display getTargetDisplay() { + StreamSettings settingsActivity = (StreamSettings) getActivity(); + if (settingsActivity != null && settingsActivity.externalDisplayManager != null) { + return settingsActivity.externalDisplayManager.getTargetDisplay(); + } + return getActivity().getWindowManager().getDefaultDisplay(); + } + private void setValue(String preferenceKey, String value) { ListPreference pref = (ListPreference) findPreference(preferenceKey); @@ -188,6 +267,55 @@ private void addNativeResolutionEntries(int nativeWidth, int nativeHeight, boole } addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false); } + private void addCustomResolutionsEntries() { + SharedPreferences storage = this.getActivity().getSharedPreferences(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_FILE, Context.MODE_PRIVATE); + Set stored = storage.getStringSet(CustomResolutionsConsts.CUSTOM_RESOLUTIONS_KEY, null); + ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING); + + List preferencesList = Arrays.asList(pref.getEntryValues()); + + if(stored == null || stored.isEmpty()) { + return; + } + + Comparator lengthComparator = (s1, s2) -> { + String[] s1Size = s1.split("x"); + String[] s2Size = s2.split("x"); + + int w1 = Integer.parseInt(s1Size[0]); + int w2 = Integer.parseInt(s2Size[0]); + + int h1 = Integer.parseInt(s1Size[1]); + int h2 = Integer.parseInt(s2Size[1]); + + if (w1 == w2) { + return Integer.compare(h1, h2); + } + return Integer.compare(w1, w2); + }; + + ArrayList list = new ArrayList<>(stored); + Collections.sort(list, lengthComparator); + + for (String storedResolution : list) { + if(preferencesList.contains(storedResolution)){ + continue; + } + String[] resolution = storedResolution.split("x"); + int width = Integer.parseInt(resolution[0]); + int height = Integer.parseInt(resolution[1]); + String aspectRatio = AspectRatioConverter.getAspectRatio(width,height); + String displayText = "Custom "; + + if(aspectRatio != null){ + displayText+=aspectRatio+" "; + } + + displayText+="("+storedResolution+")"; + + appendPreferenceEntry(pref, displayText, storedResolution); + } + } private void addNativeFrameRateEntry(float framerate) { int frameRateRounded = Math.round(framerate); @@ -266,16 +394,224 @@ private void resetBitrateToDefault(SharedPreferences prefs, String res, String f @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = super.onCreateView(inflater, container, savedInstanceState); + if (view != null) { + // 确保列表背景透明 + view.setBackgroundColor(Color.TRANSPARENT); + + // 减少顶部间距,让设置内容更贴近导航栏 + int topPadding = view.getPaddingTop(); + int reducedPadding = Math.max(0, topPadding - (int) (16 * getResources().getDisplayMetrics().density)); + view.setPadding(view.getPaddingLeft(), reducedPadding, + view.getPaddingRight(), view.getPaddingBottom()); + } UiHelper.applyStatusBarPadding(view); return view; } + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + Activity activity = getActivity(); + if (activity == null) { + return; + } + + // 1. 获取视图组件 + LinearLayout navContainer = activity.findViewById(R.id.settings_nav_container); + FlexboxLayout navGridContainer = activity.findViewById(R.id.settings_nav_grid_container); + HorizontalScrollView navScroll = activity.findViewById(R.id.settings_nav_scroll); + ScrollView navGridScroll = activity.findViewById(R.id.settings_nav_grid_scroll); + ImageView toggleButton = activity.findViewById(R.id.settings_nav_toggle); + + // 2. 安全检查 + if (navContainer == null || navGridContainer == null || navScroll == null || + navGridScroll == null || toggleButton == null) { + return; + } + + // 3. 配置 Flexbox 自动换行 + navGridContainer.setFlexWrap(FlexWrap.WRAP); + navGridContainer.setFlexDirection(FlexDirection.ROW); + navGridContainer.setJustifyContent(JustifyContent.FLEX_START); + + // 4. 清空视图 + navContainer.removeAllViews(); + navGridContainer.removeAllViews(); + + PreferenceScreen screen = getPreferenceScreen(); + if (screen == null) { + return; + } + + final ImageView collapseBtn = new ImageView(activity); + collapseBtn.setImageResource(R.drawable.ic_list_view); + collapseBtn.setPadding(dpToPx(12), dpToPx(6), dpToPx(12), dpToPx(6)); + collapseBtn.setScaleType(ImageView.ScaleType.FIT_CENTER); + collapseBtn.setMinimumHeight(dpToPx(28)); + + GradientDrawable bgCollapse = new GradientDrawable(); + bgCollapse.setColor(Color.parseColor("#33FFFFFF")); + bgCollapse.setCornerRadius(dpToPx(16)); + collapseBtn.setBackground(bgCollapse); + + FlexboxLayout.LayoutParams lpCollapse = new FlexboxLayout.LayoutParams( + FlexboxLayout.LayoutParams.WRAP_CONTENT, + FlexboxLayout.LayoutParams.WRAP_CONTENT + ); + int margin = dpToPx(6); + lpCollapse.setMargins(margin, margin, margin, margin); + collapseBtn.setLayoutParams(lpCollapse); + + // 点击内部收起按钮 -> 切换回水平模式 + collapseBtn.setOnClickListener(v -> { + navScroll.setVisibility(View.VISIBLE); + toggleButton.setVisibility(View.VISIBLE); + navGridScroll.setVisibility(View.GONE); + }); + + // 添加到网格的第一个位置 + navGridContainer.addView(collapseBtn); + + // 5. 循环添加普通分类按钮 + for (int i = 0; i < screen.getPreferenceCount(); i++) { + Preference pref = screen.getPreference(i); + if (pref instanceof PreferenceCategory) { + PreferenceCategory category = (PreferenceCategory) pref; + if (category.getTitle() == null) { + continue; + } + + // --- 水平模式 Tab --- + final TextView tabHorizontal = createTab(activity, category.getTitle().toString()); + LinearLayout.LayoutParams lpHorizontal = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + lpHorizontal.rightMargin = dpToPx(12); + tabHorizontal.setLayoutParams(lpHorizontal); + tabHorizontal.setOnClickListener(v -> scrollToCategory(category)); + navContainer.addView(tabHorizontal); + + // --- 网格模式 Tab --- + final TextView tabGrid = createTab(activity, category.getTitle().toString()); + FlexboxLayout.LayoutParams lpFlex = new FlexboxLayout.LayoutParams( + FlexboxLayout.LayoutParams.WRAP_CONTENT, + FlexboxLayout.LayoutParams.WRAP_CONTENT + ); + lpFlex.setMargins(margin, margin, margin, margin); + tabGrid.setLayoutParams(lpFlex); + tabGrid.setOnClickListener(v -> scrollToCategory(category)); + navGridContainer.addView(tabGrid); + } + } + + // 6. 左侧固定按钮点击事件(只负责展开) + toggleButton.setOnClickListener(v -> { + // 切换到网格模式 + navScroll.setVisibility(View.GONE); + toggleButton.setVisibility(View.GONE); + navGridScroll.setVisibility(View.VISIBLE); + }); + } + + // 辅助方法:创建统一样式的 Tab (避免代码重复) + private TextView createTab(Activity activity, String text) { + TextView tab = new TextView(activity); + tab.setText(text); + tab.setTextColor(Color.WHITE); + tab.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + tab.setSingleLine(true); + tab.setPadding(dpToPx(12), dpToPx(6), dpToPx(12), dpToPx(6)); + + GradientDrawable bg = new GradientDrawable(); + bg.setColor(Color.parseColor("#33FFFFFF")); + bg.setCornerRadius(dpToPx(16)); + tab.setBackground(bg); + return tab; + } + + // 辅助方法:跳转 + private void scrollToCategory(PreferenceCategory category) { + int position = findAdapterPositionForPreference(category); + if (position >= 0) { + ListView listView = null; + View fragmentView = getView(); + if (fragmentView != null) { + listView = fragmentView.findViewById(android.R.id.list); + } else { + Activity act = getActivity(); + if (act != null) { + listView = act.findViewById(android.R.id.list); + } + } + if (listView != null) { + listView.smoothScrollToPositionFromTop(position, dpToPx(8)); + } + } + } + + private int dpToPx(int dp) { + float density = getResources().getDisplayMetrics().density; + return Math.round(dp * density); + } + + private static class PositionCounter { + int position = 0; + boolean found = false; + } + + private int findAdapterPositionForPreference(Preference target) { + PreferenceScreen screen = getPreferenceScreen(); + if (screen == null || target == null) { + return -1; + } + + PositionCounter counter = new PositionCounter(); + computePosition(screen, target, counter, true); + return counter.found ? counter.position : -1; + } + + private void computePosition(PreferenceGroup group, Preference target, PositionCounter counter, boolean isRoot) { + if (counter.found) { + return; + } + + final int count = group.getPreferenceCount(); + for (int i = 0; i < count; i++) { + Preference pref = group.getPreference(i); + + // 适配器包含每个 Preference 自身 + counter.position++; + if (pref == target) { + counter.found = true; + return; + } + + if (pref instanceof PreferenceGroup) { + computePosition((PreferenceGroup) pref, target, counter, false); + if (counter.found) { + return; + } + } + } + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - + + // 添加阴影主题 + getActivity().getTheme().applyStyle(R.style.PreferenceThemeWithShadow, true); + addPreferencesFromResource(R.xml.preferences); PreferenceScreen screen = getPreferenceScreen(); + + // 为 LocalImagePickerPreference 设置 Fragment 实例,确保 onActivityResult 回调正确 + LocalImagePickerPreference localImagePicker = (LocalImagePickerPreference) findPreference("local_image_picker"); + if (localImagePicker != null) { + localImagePicker.setFragment(this); + } // hide on-screen controls category on non touch screen devices if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { @@ -351,7 +687,8 @@ else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength")); } - Display display = getActivity().getWindowManager().getDefaultDisplay(); + // 获取目标显示器(优先使用外接显示器) + Display display = getTargetDisplay(); float maxSupportedFps = display.getRefreshRate(); // Hide non-supported resolution/FPS combinations @@ -477,24 +814,18 @@ else if (maxSupportedResW < 1280) { if (maxSupportedResW != 0) { if (maxSupportedResW < 3840) { // 4K is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); - resetBitrateToDefault(prefs, null, null); - } + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); + resetBitrateToDefault(prefs, null, null); }); } if (maxSupportedResW < 2560) { // 1440p is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); - resetBitrateToDefault(prefs, null, null); - } + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); + resetBitrateToDefault(prefs, null, null); }); } if (maxSupportedResW < 1920) { @@ -523,25 +854,33 @@ public void run() { if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) { // We give some extra room in case the FPS is rounded down + if (maxSupportedFps < 162) { + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "165", () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "144"); + resetBitrateToDefault(prefs, null, null); + }); + } + if (maxSupportedFps < 141) { + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "144", () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "120"); + resetBitrateToDefault(prefs, null, null); + }); + } if (maxSupportedFps < 118) { - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); - resetBitrateToDefault(prefs, null, null); - } + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); + resetBitrateToDefault(prefs, null, null); }); } if (maxSupportedFps < 88) { // 1080p is unsupported - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); - resetBitrateToDefault(prefs, null, null); - } + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", () -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); + resetBitrateToDefault(prefs, null, null); }); } // Never remove 30 FPS or 60 FPS @@ -550,26 +889,20 @@ public void run() { // Android L introduces the drop duplicate behavior of releaseOutputBuffer() // that the unlock FPS option relies on to not massively increase latency. - findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // HACK: We need to let the preference change succeed before reinitializing to ensure - // it's reflected in the new layout. - final Handler h = new Handler(); - h.postDelayed(new Runnable() { - @Override - public void run() { - // Ensure the activity is still open when this timeout expires - StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); - if (settingsActivity != null) { - settingsActivity.reloadSettings(); - } - } - }, 500); + findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener((preference, newValue) -> { + // HACK: We need to let the preference change succeed before reinitializing to ensure + // it's reflected in the new layout. + final Handler h = new Handler(); + h.postDelayed(() -> { + // Ensure the activity is still open when this timeout expires + StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); + if (settingsActivity != null) { + settingsActivity.reloadSettings(); + } + }, 500); - // Allow the original preference change to take place - return true; - } + // Allow the original preference change to take place + return true; }); // Remove HDR preference for devices below Nougat @@ -580,7 +913,9 @@ public void run() { category.removePreference(findPreference("checkbox_enable_hdr")); } else { - Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + // 获取目标显示器的 HDR 能力(优先使用外接显示器) + Display targetDisplay = getTargetDisplay(); + Display.HdrCapabilities hdrCaps = targetDisplay.getHdrCapabilities(); // We must now ensure our display is compatible with HDR10 boolean foundHdr10 = false; @@ -613,60 +948,331 @@ else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) { // Add a listener to the FPS and resolution preference // so the bitrate can be auto-adjusted - findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // Detect if this value is the native resolution option - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - boolean isNativeRes = true; - for (int i = 0; i < values.length; i++) { - // Look for a match prior to the start of the native resolution entries - if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { - isNativeRes = false; - break; - } + findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener((preference, newValue) -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // Detect if this value is the native resolution option + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + boolean isNativeRes = true; + for (int i = 0; i < values.length; i++) { + // Look for a match prior to the start of the native resolution entries + if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { + isNativeRes = false; + break; } + } - // If this is native resolution, show the warning dialog - if (isNativeRes) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_res_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } + // If this is native resolution, show the warning dialog + if (isNativeRes) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_res_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); + } - // Write the new bitrate value - resetBitrateToDefault(prefs, valueStr, null); + // Write the new bitrate value + resetBitrateToDefault(prefs, valueStr, null); - // Allow the original preference change to take place - return true; + // Allow the original preference change to take place + return true; + }); + findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener((preference, newValue) -> { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // If this is native frame rate, show the warning dialog + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_fps_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); } + + // Write the new bitrate value + resetBitrateToDefault(prefs, null, valueStr); + + // Allow the original preference change to take place + return true; + }); + findPreference(PreferenceConfiguration.IMPORT_CONFIG_STRING).setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, 2); + return false; + }); + + + + ListPreference exportPreference = (ListPreference) findPreference(PreferenceConfiguration.EXPORT_CONFIG_STRING); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + SuperConfigDatabaseHelper superConfigDatabaseHelper = new SuperConfigDatabaseHelper(getContext()); + List configIdList = superConfigDatabaseHelper.queryAllConfigIds(); + Map configMap = new HashMap<>(); + for (Long configId : configIdList){ + String configName = (String) superConfigDatabaseHelper.queryConfigAttribute(configId, PageConfigController.COLUMN_STRING_CONFIG_NAME,"default"); + String configIdString = String.valueOf(configId); + configMap.put(configIdString,configName); + } + CharSequence[] nameEntries = configMap.values().toArray(new String[0]); + CharSequence[] nameEntryValues = configMap.keySet().toArray(new String[0]); + exportPreference.setEntries(nameEntries); + exportPreference.setEntryValues(nameEntryValues); + + exportPreference.setOnPreferenceChangeListener((preference, newValue) -> { + exportConfigString = superConfigDatabaseHelper.exportConfig(Long.parseLong((String) newValue)); + String fileName = configMap.get(newValue); + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_TITLE, fileName + ".mdat"); + startActivityForResult(intent, 1); + return false; + }); + + } + + addCustomResolutionsEntries(); + ListPreference mergePreference = (ListPreference) findPreference(PreferenceConfiguration.MERGE_CONFIG_STRING); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + SuperConfigDatabaseHelper superConfigDatabaseHelper = new SuperConfigDatabaseHelper(getContext()); + List configIdList = superConfigDatabaseHelper.queryAllConfigIds(); + Map configMap = new HashMap<>(); + for (Long configId : configIdList){ + String configName = (String) superConfigDatabaseHelper.queryConfigAttribute(configId, PageConfigController.COLUMN_STRING_CONFIG_NAME,"default"); + String configIdString = String.valueOf(configId); + configMap.put(configIdString,configName); + } + CharSequence[] nameEntries = configMap.values().toArray(new String[0]); + CharSequence[] nameEntryValues = configMap.keySet().toArray(new String[0]); + mergePreference.setEntries(nameEntries); + mergePreference.setEntryValues(nameEntryValues); + + mergePreference.setOnPreferenceChangeListener((preference, newValue) -> { + exportConfigString = (String) newValue; + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, 3); + return false; + }); + + } + + findPreference(PreferenceConfiguration.ABOUT_AUTHOR).setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.author_web))); + startActivity(intent); + return true; + }); + + // 添加检查更新选项的点击事件 + findPreference("check_for_updates").setOnPreferenceClickListener(preference -> { + UpdateManager.checkForUpdates(getActivity(), true); + return true; }); - findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // If this is native frame rate, show the warning dialog - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_fps_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } - // Write the new bitrate value - resetBitrateToDefault(prefs, null, valueStr); + // 对于没有触摸屏的设备,只提供本地鼠标指针选项 + ListPreference mouseModePresetPref = (ListPreference) findPreference(PreferenceConfiguration.NATIVE_MOUSE_MODE_PRESET_PREF_STRING); + if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + // 只显示本地鼠标指针选项 + mouseModePresetPref.setEntries(new CharSequence[]{getString(R.string.native_mouse_mode_preset_native)}); + mouseModePresetPref.setEntryValues(new CharSequence[]{"native"}); + mouseModePresetPref.setValue("native"); + + // 强制设置为本地鼠标指针模式 + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PreferenceConfiguration.ENABLE_ENHANCED_TOUCH_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, true); + editor.apply(); + } - // Allow the original preference change to take place - return true; + // 添加本地鼠标模式预设选择监听器 + mouseModePresetPref.setOnPreferenceChangeListener((preference, newValue) -> { + String preset = (String) newValue; + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + SharedPreferences.Editor editor = prefs.edit(); + + // 根据预设值自动设置相关配置 + switch (preset) { + case "enhanced": + // 增强式多点触控 + editor.putBoolean(PreferenceConfiguration.ENABLE_ENHANCED_TOUCH_PREF_STRING, true); + editor.putBoolean(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, false); + break; + case "classic": + // 经典鼠标模式 + editor.putBoolean(PreferenceConfiguration.ENABLE_ENHANCED_TOUCH_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, false); + break; + case "trackpad": + // 触控板模式 + editor.putBoolean(PreferenceConfiguration.ENABLE_ENHANCED_TOUCH_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING, true); + editor.putBoolean(PreferenceConfiguration.ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, false); + break; + case "native": + // 本地鼠标指针 + editor.putBoolean(PreferenceConfiguration.ENABLE_ENHANCED_TOUCH_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING, false); + editor.putBoolean(PreferenceConfiguration.ENABLE_NATIVE_MOUSE_POINTER_PREF_STRING, true); + break; + } + editor.apply(); + + // 显示提示信息 + String presetName = ""; + switch (preset) { + case "enhanced": + presetName = getString(R.string.native_mouse_mode_preset_enhanced); + break; + case "classic": + presetName = getString(R.string.native_mouse_mode_preset_classic); + break; + case "trackpad": + presetName = getString(R.string.native_mouse_mode_preset_trackpad); + break; + case "native": + presetName = getString(R.string.native_mouse_mode_preset_native); + break; } + Toast.makeText(getActivity(), + getString(R.string.toast_preset_applied, presetName), + Toast.LENGTH_SHORT).show(); + + return true; }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + //导出配置文件 + if (requestCode == 1 && resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + try { + // 将字符串写入文件 + OutputStream outputStream = getContext().getContentResolver().openOutputStream(uri); + if (outputStream != null) { + outputStream.write(exportConfigString.getBytes()); + outputStream.close(); + Toast.makeText(getContext(),"导出配置文件成功",Toast.LENGTH_SHORT).show(); + } + } catch (IOException e) { + Toast.makeText(getContext(),"导出配置文件失败",Toast.LENGTH_SHORT).show(); + } + } + + } + //导入配置文件 + if (requestCode == 2 && resultCode == Activity.RESULT_OK) { + Uri importUri = data.getData(); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + try (InputStream inputStream = getContext().getContentResolver().openInputStream(importUri); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line).append("\n"); + } + String fileContent = stringBuilder.toString(); + SuperConfigDatabaseHelper superConfigDatabaseHelper = new SuperConfigDatabaseHelper(getContext()); + int errorCode = superConfigDatabaseHelper.importConfig(fileContent); + switch (errorCode){ + case 0: + Toast.makeText(getContext(),"导入配置文件成功",Toast.LENGTH_SHORT).show(); + //更新导出配置文件列表 + ListPreference exportPreference = (ListPreference) findPreference(PreferenceConfiguration.EXPORT_CONFIG_STRING); + List configIdList = superConfigDatabaseHelper.queryAllConfigIds(); + Map configMap = new HashMap<>(); + for (Long configId : configIdList){ + String configName = (String) superConfigDatabaseHelper.queryConfigAttribute(configId, PageConfigController.COLUMN_STRING_CONFIG_NAME,"default"); + String configIdString = String.valueOf(configId); + configMap.put(configIdString,configName); + } + CharSequence[] nameEntries = configMap.values().toArray(new String[0]); + CharSequence[] nameEntryValues = configMap.keySet().toArray(new String[0]); + exportPreference.setEntries(nameEntries); + exportPreference.setEntryValues(nameEntryValues); + break; + case -1: + case -2: + Toast.makeText(getContext(),"读取配置文件失败",Toast.LENGTH_SHORT).show(); + break; + case -3: + Toast.makeText(getContext(),"配置文件版本不匹配",Toast.LENGTH_SHORT).show(); + break; + } + + } catch (IOException e) { + Toast.makeText(getContext(),"读取配置文件失败",Toast.LENGTH_SHORT).show(); + } + } + } + + if (requestCode == 3 && resultCode == Activity.RESULT_OK) { + Uri importUri = data.getData(); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + try (InputStream inputStream = getContext().getContentResolver().openInputStream(importUri); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line).append("\n"); + } + String fileContent = stringBuilder.toString(); + SuperConfigDatabaseHelper superConfigDatabaseHelper = new SuperConfigDatabaseHelper(getContext()); + int errorCode = superConfigDatabaseHelper.mergeConfig(fileContent,Long.parseLong(exportConfigString)); + switch (errorCode){ + case 0: + Toast.makeText(getContext(),"合并配置文件成功",Toast.LENGTH_SHORT).show(); + break; + case -1: + case -2: + Toast.makeText(getContext(),"读取配置文件失败",Toast.LENGTH_SHORT).show(); + break; + case -3: + Toast.makeText(getContext(),"配置文件版本不匹配",Toast.LENGTH_SHORT).show(); + break; + } + + } catch (IOException e) { + Toast.makeText(getContext(),"读取配置文件失败",Toast.LENGTH_SHORT).show(); + } + } + } + + // 处理本地图片选择 + if (requestCode == LocalImagePickerPreference.PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK) { + LocalImagePickerPreference pickerPreference = LocalImagePickerPreference.getInstance(); + if (pickerPreference != null) { + pickerPreference.handleImagePickerResult(data); + } + } + + } + + } + + private void loadBackgroundImage() { + ImageView imageView = findViewById(R.id.settingsBackgroundImage); + + runOnUiThread(() -> Glide.with(this) + .load("https://raw.gitmirror.com/qiin2333/qiin.github.io/assets/img/moonlight-bg2.webp") + .apply(RequestOptions.bitmapTransform(new BlurTransformation(2, 3))) + .transform(new ColorFilterTransformation(Color.argb(120, 0, 0, 0))) + .into(imageView)); } } + + diff --git a/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.java b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.java new file mode 100644 index 0000000000..8a26420ca2 --- /dev/null +++ b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.java @@ -0,0 +1,136 @@ +package com.limelight.services; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.util.Log; +import android.view.KeyEvent; +import android.view.accessibility.AccessibilityEvent; + +/** + * 一个无障碍服务,用于在系统级别拦截硬件键盘事件。 + * 主要目的是捕获像 Win 键、Alt+Tab 等被 Android 系统默认行为占用的按键, + * 并将它们转发给应用(如 Moonlight 的 Game Activity),以提供完整的 PC 游戏体验。 + * + *

重要:此服务需要用户在系统设置中手动授权。

+ */ +public class KeyboardAccessibilityService extends AccessibilityService { + + private static final String TAG = "KeyboardService"; + + // 使用静态实例,方便 Activity 在其生命周期内获取服务引用。 + private static KeyboardAccessibilityService instance; + + // 一个标志位,用于控制服务是否应该拦截按键事件。 + // 必须由外部(如 Game Activity)在 onResume/onPause 中进行控制。 + private static boolean interceptingEnabled = false; + + /** + * 回调接口,用于将捕获到的按键事件发送给注册的监听者(通常是 Game Activity)。 + */ + public interface KeyEventCallback { + /** + * 当无障碍服务捕获到一个按键事件时调用。 + * @param event 被捕获的按键事件对象。 + */ + void onKeyEvent(KeyEvent event); + } + + private KeyEventCallback keyEventCallback; + + /** + * 获取当前正在运行的服务实例。 + * @return 如果服务正在运行,则返回服务实例;否则返回 null。 + */ + public static KeyboardAccessibilityService getInstance() { + return instance; + } + + /** + * 设置一个监听器来接收按键事件回调。 + * @param callback 实现 KeyEventCallback 接口的对象。 + */ + public void setKeyEventCallback(KeyEventCallback callback) { + this.keyEventCallback = callback; + } + + /** + * 控制是否开始或停止拦截键盘事件。 + * 这个方法应该由 Activity 在 onResume() 中设置为 true,在 onPause() 中设置为 false。 + * @param enabled true 表示开始拦截,false 表示停止拦截。 + */ + public static void setIntercepting(boolean enabled) { + Log.d(TAG, "Setting interception to: " + enabled); + interceptingEnabled = enabled; + } + + @Override + protected void onServiceConnected() { + super.onServiceConnected(); + instance = this; + Log.i(TAG, "Accessibility Service connected."); + } + + /** + * 核心方法!所有(可被过滤的)硬件键盘事件在被系统或其他应用处理之前,都会先到达这里。 + * @param event 按键事件对象。 + * @return 如果返回 true,表示事件已被“消费”,系统将不再处理它(Win键返回桌面的行为被阻止)。 + * 如果返回 false 或调用 super.onKeyEvent(event),事件将继续传递给系统。 + */ + @Override + protected boolean onKeyEvent(KeyEvent event) { + // 在进行任何拦截之前,我们首先检查这个按键是否是手机导航所必需的。 + // 如果是,我们必须立即返回 false,将事件交还给系统正常处理。 + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_HOME: + case KeyEvent.KEYCODE_APP_SWITCH: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_VOLUME_MUTE: + case KeyEvent.KEYCODE_POWER: + // 这些是系统级的关键按键,我们永远不应该拦截它们。 + // 返回 false 意味着:“这个事件我不处理,请系统继续执行默认操作”。 + return false; + } + // 仅在拦截标志位为 true 时处理事件 + if (interceptingEnabled) { + // 如果有注册的回调监听器,则将事件传递出去 + if (keyEventCallback != null) { + keyEventCallback.onKeyEvent(event); + } + + // 返回 true,告诉系统我们已经处理了这个事件。 + // 这是阻止 Win 键、Alt+Tab 等系统级快捷键触发默认行为的关键。 + return true; + } + + // 如果不处于拦截状态,则让系统正常处理按键 + return super.onKeyEvent(event); + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event) { + // 对于只过滤按键事件的场景,我们通常不需要在这里做什么。 + } + + @Override + public void onInterrupt() { + Log.w(TAG, "Accessibility Service interrupted."); + // 当服务被系统打断时调用(例如,弹出一个需要更高权限的窗口时)。 + } + + public KeyEventCallback getKeyEventCallback() { + return this.keyEventCallback; + } + + + + @Override + public void onDestroy() { + super.onDestroy(); + // 清理静态引用,防止内存泄漏。 + instance = null; + interceptingEnabled = false; // 确保在服务销毁时停止拦截 + Log.i(TAG, "Accessibility Service destroyed."); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/ui/AdapterFragment.java b/app/src/main/java/com/limelight/ui/AdapterFragment.java index 8e1c7b9182..3dcdb43a88 100644 --- a/app/src/main/java/com/limelight/ui/AdapterFragment.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragment.java @@ -30,6 +30,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); + // Pass the view (could be a GridView or RecyclerView) to the activity + callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); } } + diff --git a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java index 8a6db396df..1e93921fe8 100644 --- a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java @@ -1,8 +1,10 @@ package com.limelight.ui; -import android.widget.AbsListView; +import android.view.View; public interface AdapterFragmentCallbacks { int getAdapterFragmentLayoutId(); - void receiveAbsListView(AbsListView gridView); + // Generalized to accept any View (RecyclerView or AbsListView). Implementations + // should check the runtime type if necessary. + void receiveAbsListView(View gridView); } diff --git a/app/src/main/java/com/limelight/ui/AdapterRecyclerBridge.java b/app/src/main/java/com/limelight/ui/AdapterRecyclerBridge.java new file mode 100644 index 0000000000..f053e616e3 --- /dev/null +++ b/app/src/main/java/com/limelight/ui/AdapterRecyclerBridge.java @@ -0,0 +1,220 @@ +package com.limelight.ui; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +/** + * A small bridge to reuse existing BaseAdapter implementations (like GenericGridAdapter) + * inside a RecyclerView. It will call BaseAdapter.getView() and attach the returned + * view into the ViewHolder container. + */ +public class AdapterRecyclerBridge extends RecyclerView.Adapter { + private final BaseAdapter baseAdapter; + private final Context context; + private OnItemClickListener onItemClickListener; + private OnItemKeyListener onItemKeyListener; + private OnItemLongClickListener onItemLongClickListener; + + + // A键长按检测相关 + private long aKeyDownTime = 0; + private static final long LONG_PRESS_DURATION = 1000; // 1秒长按 + private final android.os.Handler longPressHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + private Runnable longPressRunnable; + + public AdapterRecyclerBridge(Context context, BaseAdapter baseAdapter) { + this.context = context; + this.baseAdapter = baseAdapter; + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + // Let baseAdapter create a view by calling getView with null convertView to inflate layout + View v = baseAdapter.getView(0, null, parent); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + // 优化:避免重复设置相同的监听器 + boolean needsListenerSetup = holder.container.getTag() == null; + + // Ask the base adapter to populate the provided convertView (holder.container) + View convert = holder.container; + View populated = baseAdapter.getView(position, convert, (ViewGroup) convert.getParent()); + + if (populated != convert) { + // Replace child views if the base adapter returned a different view instance + ViewGroup parent = (ViewGroup) convert.getParent(); + if (parent != null) { + int index = parent.indexOfChild(convert); + parent.removeViewAt(index); + parent.addView(populated, index); + holder.container = populated; + needsListenerSetup = true; // 新view需要设置监听器 + } + } + + // 只在需要时设置焦点和监听器 + if (needsListenerSetup) { + holder.container.setFocusable(true); + holder.container.setClickable(true); + + // 标记已设置监听器 + holder.container.setTag("listeners_set"); + } + + // 只在需要时设置监听器,避免重复设置 + if (needsListenerSetup) { + // 设置硬件加速层以提高性能 + holder.container.setLayerType(View.LAYER_TYPE_HARDWARE, null); + + // 设置点击监听器(通过OnItemClickListener统一处理) + // 注意:使用 holder.getAdapterPosition() 而不是捕获 position 参数,以支持RecyclerView的回收机制 + holder.container.setOnClickListener(v -> { + if (onItemClickListener != null) { + int adapterPosition = holder.getAdapterPosition(); + if (adapterPosition != RecyclerView.NO_POSITION) { + onItemClickListener.onItemClick(adapterPosition, baseAdapter.getItem(adapterPosition)); + } + } + }); + + // 设置按键监听器 + holder.container.setOnKeyListener((v, keyCode, event) -> { + // 获取当前adapter位置,而不是捕获的position + int adapterPosition = holder.getAdapterPosition(); + if (adapterPosition == RecyclerView.NO_POSITION) { + return false; + } + + if (onItemKeyListener != null) { + return onItemKeyListener.onItemKey(adapterPosition, baseAdapter.getItem(adapterPosition), keyCode, event); + } + + // 适配器内部处理A键长按检测 + if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == android.view.KeyEvent.KEYCODE_BUTTON_A) { + + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + // A键按下 - 开始长按检测 + aKeyDownTime = System.currentTimeMillis(); + longPressRunnable = () -> { + // 长按触发 - 再次获取当前位置 + int longPressPosition = holder.getAdapterPosition(); + if (longPressPosition != RecyclerView.NO_POSITION && onItemLongClickListener != null) { + onItemLongClickListener.onItemLongClick(longPressPosition, baseAdapter.getItem(longPressPosition)); + } + }; + longPressHandler.postDelayed(longPressRunnable, LONG_PRESS_DURATION); + return true; + + } else if (event.getAction() == android.view.KeyEvent.ACTION_UP) { + // A键释放 - 检查是否为短按 + long pressDuration = System.currentTimeMillis() - aKeyDownTime; + + // 取消长按检测 + if (longPressRunnable != null) { + longPressHandler.removeCallbacks(longPressRunnable); + longPressRunnable = null; + } + + // 如果按下时间小于长按阈值,执行短按操作 + if (pressDuration < LONG_PRESS_DURATION) { + if (onItemClickListener != null && adapterPosition != RecyclerView.NO_POSITION) { + onItemClickListener.onItemClick(adapterPosition, baseAdapter.getItem(adapterPosition)); + } + } + return true; + } + } + + return false; + }); + + // 设置长按监听器 + holder.container.setOnLongClickListener(v -> { + if (onItemLongClickListener != null) { + int adapterPosition = holder.getAdapterPosition(); + if (adapterPosition != RecyclerView.NO_POSITION) { + boolean result = onItemLongClickListener.onItemLongClick(adapterPosition, baseAdapter.getItem(adapterPosition)); + return result; + } + } + return false; + }); + } + } + + @Override + public int getItemCount() { + return baseAdapter.getCount(); + } + + /** + * 设置item点击监听器 + */ + public void setOnItemClickListener(OnItemClickListener listener) { + this.onItemClickListener = listener; + } + + /** + * 设置item按键监听器 + */ + public void setOnItemKeyListener(OnItemKeyListener listener) { + this.onItemKeyListener = listener; + } + + /** + * 设置item长按监听器 + */ + public void setOnItemLongClickListener(OnItemLongClickListener listener) { + this.onItemLongClickListener = listener; + } + + /** + * Item点击监听器接口 + */ + public interface OnItemClickListener { + void onItemClick(int position, Object item); + } + + /** + * Item按键监听器接口 + */ + public interface OnItemKeyListener { + boolean onItemKey(int position, Object item, int keyCode, android.view.KeyEvent event); + } + + /** + * Item长按监听器接口 + */ + public interface OnItemLongClickListener { + boolean onItemLongClick(int position, Object item); + } + + /** + * 清理资源 + */ + public void cleanup() { + // 清理长按检测 + if (longPressRunnable != null) { + longPressHandler.removeCallbacks(longPressRunnable); + longPressRunnable = null; + } + } + + public static class VH extends RecyclerView.ViewHolder { + public View container; + + public VH(@NonNull View itemView) { + super(itemView); + this.container = itemView; + } + } +} diff --git a/app/src/main/java/com/limelight/ui/CursorView.java b/app/src/main/java/com/limelight/ui/CursorView.java new file mode 100644 index 0000000000..83187b2da0 --- /dev/null +++ b/app/src/main/java/com/limelight/ui/CursorView.java @@ -0,0 +1,123 @@ +package com.limelight.ui; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import androidx.core.content.ContextCompat; +import com.limelight.R; + +public class CursorView extends View { + + // 网络接收到的光标 + private Bitmap cursorBitmap; + private float pivotX; + private float pivotY; + + // === 兜底方案 (默认光标) === + private Bitmap defaultCursorBitmap; + private float defaultPivotX; + private float defaultPivotY; + + // 状态 + private float cursorX = -100; + private float cursorY = -100; + private boolean isVisible = false; + private Paint paint = new Paint(); + + // 默认光标大小 (像素) + private static final int DEFAULT_SIZE = 24; + + public CursorView(Context context) { + super(context); + init(context); + } + + public CursorView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + setElevation(100f); + setWillNotDraw(false); + + // === 加载本地 SVG 作为兜底 === + Drawable vectorDrawable = ContextCompat.getDrawable(context, R.drawable.arrow); + if (vectorDrawable != null) { + // 将 VectorDrawable 转为 Bitmap + defaultCursorBitmap = Bitmap.createBitmap(DEFAULT_SIZE, DEFAULT_SIZE, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(defaultCursorBitmap); + vectorDrawable.setBounds(0, 0, DEFAULT_SIZE, DEFAULT_SIZE); + vectorDrawable.draw(canvas); + + // 设置默认热点 (箭头尖端: 6/24, 3/24) + defaultPivotX = DEFAULT_SIZE * (6f / 24f); + defaultPivotY = DEFAULT_SIZE * (3f / 24f); + } + } + + /** + * 设置网络光标 (收到 UDP 包时调用) + */ + public void setCursorBitmap(Bitmap bitmap, int hotX, int hotY) { + this.cursorBitmap = bitmap; + this.pivotX = hotX; + this.pivotY = hotY; + invalidate(); + } + + /** + * 重置为默认光标 (断连或初始化时调用) + */ + public void resetToDefault() { + this.cursorBitmap = null; // 清空网络图片,触发 onDraw 里的回退逻辑 + invalidate(); + } + + public void updateCursorPosition(float x, float y) { + this.cursorX = x; + this.cursorY = y; + invalidate(); + } + + public void show() { + isVisible = true; + setVisibility(View.VISIBLE); + } + + public void hide() { + isVisible = false; + setVisibility(View.GONE); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (!isVisible) return; + + Bitmap bmpToDraw; + float pX, pY; + + // === 核心逻辑:优先用网络图,没有就用兜底图 === + if (cursorBitmap != null) { + bmpToDraw = cursorBitmap; + pX = pivotX; + pY = pivotY; + } else { + // 兜底方案 + bmpToDraw = defaultCursorBitmap; + pX = defaultPivotX; + pY = defaultPivotY; + } + + if (bmpToDraw != null) { + float left = cursorX - pX; + float top = cursorY - pY; + canvas.drawBitmap(bmpToDraw, left, top, paint); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/ui/GameGestures.java b/app/src/main/java/com/limelight/ui/GameGestures.java index 74dd7b056f..a4197b29bd 100644 --- a/app/src/main/java/com/limelight/ui/GameGestures.java +++ b/app/src/main/java/com/limelight/ui/GameGestures.java @@ -1,5 +1,9 @@ package com.limelight.ui; +import com.limelight.binding.input.GameInputDevice; + public interface GameGestures { void toggleKeyboard(); + + void showGameMenu(GameInputDevice device); } diff --git a/app/src/main/java/com/limelight/ui/SelectionIndicatorAnimator.java b/app/src/main/java/com/limelight/ui/SelectionIndicatorAnimator.java new file mode 100644 index 0000000000..280a548fa7 --- /dev/null +++ b/app/src/main/java/com/limelight/ui/SelectionIndicatorAnimator.java @@ -0,0 +1,285 @@ +package com.limelight.ui; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.recyclerview.widget.RecyclerView; + +import com.limelight.AppView; +import com.limelight.grid.GenericGridAdapter; + +/** + * Selection Indicator Animator + * Manages the animation and position calculation of the selection indicator + */ +public class SelectionIndicatorAnimator { + + private View selectionIndicator; + private RecyclerView recyclerView; + private GenericGridAdapter adapter; + private View rootView; + + // Animation configuration + private static final int NORMAL_ANIMATION_DURATION = 200; + private static final int SCALE_ANIMATION_DURATION = 150; + private static final int SCROLL_WAIT_DELAY = 50; + private static final int RETRY_DELAY = 100; + + public SelectionIndicatorAnimator(View selectionIndicator, RecyclerView recyclerView, + GenericGridAdapter adapter, View rootView) { + this.selectionIndicator = selectionIndicator; + this.recyclerView = recyclerView; + this.adapter = adapter; + this.rootView = rootView; + } + + /** + * Update RecyclerView and Adapter references + */ + public void updateReferences(RecyclerView recyclerView, GenericGridAdapter adapter) { + this.recyclerView = recyclerView; + this.adapter = adapter; + } + + /** + * Move selection indicator to specified position + * + * @param position Target position + * @param isFirstFocus Whether this is the first focus (starting from position 0) + */ + public void moveToPosition(int position, boolean isFirstFocus) { + if (!isValidPosition(position)) { + return; + } + + RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(position); + if (viewHolder != null) { + if (isFirstFocus) { + // First focus, position directly without animation + setIndicatorPosition(viewHolder.itemView, false); + } else { + // Normal case: item is in visible area, use animation + animateToView(viewHolder.itemView); + } + } else { + // Edge case: item is not in visible area, need to scroll + scrollToPositionAndAnimate(position); + } + } + + /** + * Move selection indicator to specified position (with animation by default) + * + * @param position Target position + */ + public void moveToPosition(int position) { + moveToPosition(position, false); + } + + /** + * Update selection indicator position (called during scrolling) + * + * @param position Current selected position + * @return true if position was successfully updated, false if item is not visible + */ + public boolean updatePosition(int position) { + if (!isValidPosition(position)) { + return false; + } + + RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(position); + if (viewHolder != null) { + setIndicatorPositionFast(viewHolder.itemView); + return true; + } + + // Item is not visible (scrolled out of screen) + return false; + } + + /** + * Fast set indicator position - dedicated for scroll updates, minimizing calculations + * + * @param targetView Target View + */ + private void setIndicatorPositionFast(View targetView) { + // Use getLocationInWindow to get absolute position relative to window + int[] targetLocation = new int[2]; + targetView.getLocationInWindow(targetLocation); + + // Get root layout position as reference point + int[] rootLocation = new int[2]; + rootView.getLocationInWindow(rootLocation); + + // Calculate position relative to root layout + float targetX = targetLocation[0] - rootLocation[0]; + float targetY = targetLocation[1] - rootLocation[1]; + + // Set position directly without complex size checks + selectionIndicator.setTranslationX(targetX); + selectionIndicator.setTranslationY(targetY); + selectionIndicator.setVisibility(View.VISIBLE); + } + + /** + * Check if position is valid + */ + private boolean isValidPosition(int position) { + return selectionIndicator != null && + recyclerView != null && + adapter != null && + position >= 0 && + position < adapter.getCount(); + } + + /** + * Animate to specified View + */ + private void animateToView(View targetView) { + // Check if another animation is in progress + if (recyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { + // If RecyclerView is scrolling, wait for scroll to complete + recyclerView.postDelayed(() -> { + // Recalculate position as scrolling may have changed it + RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(getCurrentPosition()); + if (viewHolder != null) { + animateToView(viewHolder.itemView); + } + }, RETRY_DELAY); + } else { + // Smoothly move to new position + setIndicatorPosition(targetView, true); + } + } + + /** + * Scroll to specified position and execute animation + */ + private void scrollToPositionAndAnimate(int position) { + // Temporarily hide indicator to avoid showing wrong position during scroll + selectionIndicator.setVisibility(View.INVISIBLE); + + // Smooth scroll to specified position + recyclerView.smoothScrollToPosition(position); + + // Use OnScrollListener to detect scroll completion + RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + // When scrolling stops, execute indicator animation + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + // Remove this temporary scroll listener + recyclerView.removeOnScrollListener(this); + + // Delay briefly to ensure scroll is completely stopped, then execute indicator animation + recyclerView.postDelayed(() -> { + RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(position); + if (viewHolder != null) { + setIndicatorPosition(viewHolder.itemView, true); + // Add scale emphasis effect + addScaleAnimation(); + } + }, SCROLL_WAIT_DELAY); + } + } + }; + + // Add temporary scroll listener + recyclerView.addOnScrollListener(scrollListener); + } + + /** + * Set indicator position + * + * @param targetView Target View + * @param withAnimation Whether to use animation + */ + private void setIndicatorPosition(View targetView, boolean withAnimation) { + // Use getLocationInWindow to get absolute position relative to window + int[] targetLocation = new int[2]; + targetView.getLocationInWindow(targetLocation); + + // Get root layout position as reference point + int[] rootLocation = new int[2]; + rootView.getLocationInWindow(rootLocation); + + // Calculate position relative to root layout + float targetX = targetLocation[0] - rootLocation[0]; + float targetY = targetLocation[1] - rootLocation[1]; + + // Cache dimensions to avoid repeated settings + int targetWidth = targetView.getWidth(); + int targetHeight = targetView.getHeight(); + + // Only update LayoutParams when dimensions change + ViewGroup.LayoutParams params = selectionIndicator.getLayoutParams(); + if (params.width != targetWidth || params.height != targetHeight) { + params.width = targetWidth; + params.height = targetHeight; + selectionIndicator.setLayoutParams(params); + } + + // Show indicator + selectionIndicator.setVisibility(View.VISIBLE); + + if (withAnimation) { + // Use faster animation method + selectionIndicator.animate() + .translationX(targetX) + .translationY(targetY) + .setDuration(Math.min(NORMAL_ANIMATION_DURATION, 120)) // Further reduce animation time + .setInterpolator(new android.view.animation.DecelerateInterpolator(1.5f)) // Use faster interpolator + .start(); + } else { + // Set position directly, use translationX/Y for better performance + selectionIndicator.setTranslationX(targetX); + selectionIndicator.setTranslationY(targetY); + } + } + + /** + * Add scale emphasis animation + */ + private void addScaleAnimation() { + selectionIndicator.setScaleX(0.8f); + selectionIndicator.setScaleY(0.8f); + selectionIndicator.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .setDuration(SCALE_ANIMATION_DURATION) + .start(); + } + + /** + * Interface for current selected position provider + */ + public interface PositionProvider { + int getCurrentPosition(); + } + + private PositionProvider positionProvider; + + public void setPositionProvider(PositionProvider provider) { + this.positionProvider = provider; + } + + public void hideIndicator() { + selectionIndicator.setVisibility(View.INVISIBLE); + } + + public void showIndicator() { + selectionIndicator.setVisibility(View.VISIBLE); + } + + /** + * Get current selected position + */ + private int getCurrentPosition() { + if (positionProvider != null) { + return positionProvider.getCurrentPosition(); + } + return -1; + } +} diff --git a/app/src/main/java/com/limelight/utils/AnalyticsManager.java b/app/src/main/java/com/limelight/utils/AnalyticsManager.java new file mode 100644 index 0000000000..cf9aa5d844 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AnalyticsManager.java @@ -0,0 +1,266 @@ +package com.limelight.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; + +import com.google.firebase.analytics.FirebaseAnalytics; +import com.limelight.BuildConfig; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 统计分析管理器 + * 负责记录应用使用时长等统计事件 + */ +public class AnalyticsManager { + private static final String TAG = "AnalyticsManager"; + + private static AnalyticsManager instance; + private FirebaseAnalytics firebaseAnalytics; + private Context applicationContext; + private ScheduledExecutorService scheduler; + private long sessionStartTime; + private boolean isSessionActive = false; + + private AnalyticsManager(Context context) { + // 在debug版本中不初始化Firebase Analytics + if (BuildConfig.DEBUG) { + Log.d(TAG, "Analytics disabled in debug build"); + return; + } + + try { + this.applicationContext = context.getApplicationContext(); + firebaseAnalytics = FirebaseAnalytics.getInstance(context); + scheduler = Executors.newScheduledThreadPool(1); + } catch (Exception e) { + Log.w(TAG, "Failed to initialize Firebase Analytics: " + e.getMessage()); + } + } + + public static synchronized AnalyticsManager getInstance(Context context) { + if (instance == null) { + instance = new AnalyticsManager(context.getApplicationContext()); + } + return instance; + } + + /** + * 检查是否可以执行Analytics操作 + * @return true如果可以在release版本中执行,false如果在debug版本中或未初始化 + */ + private boolean canExecuteAnalytics() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Analytics disabled in debug build"); + return false; + } + + if (firebaseAnalytics == null) { + Log.w(TAG, "Firebase Analytics not initialized"); + return false; + } + + // 检查用户是否启用了统计 + try { + if (applicationContext != null) { + android.content.SharedPreferences prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(applicationContext); + boolean analyticsEnabled = prefs.getBoolean("checkbox_enable_analytics", true); + if (!analyticsEnabled) { + Log.d(TAG, "Analytics disabled by user preference"); + return false; + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to check analytics preference: " + e.getMessage()); + } + + return true; + } + + /** + * 开始记录使用时长 + */ + @SuppressLint("InvalidAnalyticsName") + public void startUsageTracking() { + if (!canExecuteAnalytics()) { + return; + } + + if (isSessionActive) { + Log.w(TAG, "Usage tracking already active"); + return; + } + + sessionStartTime = System.currentTimeMillis(); + isSessionActive = true; + + // 记录会话开始事件 + Bundle bundle = new Bundle(); + bundle.putString("session_type", "app_usage"); + firebaseAnalytics.logEvent("session_start", bundle); + + Log.d(TAG, "Usage tracking started"); + } + + /** + * 停止记录使用时长 + */ + public void stopUsageTracking() { + if (!canExecuteAnalytics()) { + return; + } + + if (!isSessionActive) { + Log.w(TAG, "Usage tracking not active"); + return; + } + + long sessionDuration = System.currentTimeMillis() - sessionStartTime; + isSessionActive = false; + + // 记录会话结束事件和使用时长 + Bundle bundle = new Bundle(); + bundle.putString("session_type", "app_usage"); + bundle.putLong("session_duration_ms", sessionDuration); + bundle.putLong("session_duration_minutes", sessionDuration / (1000 * 60)); + firebaseAnalytics.logEvent("session_end", bundle); + + Log.d(TAG, "Usage tracking stopped, duration: " + (sessionDuration / 1000) + " seconds"); + } + + /** + * 记录游戏流媒体开始事件 + */ + public void logGameStreamStart(String computerName, String appName) { + if (!canExecuteAnalytics()) { + Log.d(TAG, "Game stream start disabled: " + computerName + ", app: " + appName); + return; + } + + Bundle bundle = new Bundle(); + bundle.putString("computer_name", computerName); + bundle.putString("app_name", appName != null ? appName : "unknown"); + bundle.putString("stream_type", "game"); + firebaseAnalytics.logEvent("game_stream_start", bundle); + + Log.d(TAG, "Game stream started for: " + computerName + ", app: " + appName); + } + + /** + * 记录游戏流媒体结束事件 + */ + public void logGameStreamEnd(String computerName, String appName, long durationMs) { + if (!canExecuteAnalytics()) { + Log.d(TAG, "Game stream end disabled: " + computerName + ", app: " + appName + ", duration: " + (durationMs / 1000) + " seconds"); + return; + } + + Bundle bundle = new Bundle(); + bundle.putString("computer_name", computerName); + bundle.putString("app_name", appName != null ? appName : "unknown"); + bundle.putString("stream_type", "game"); + bundle.putLong("stream_duration_ms", durationMs); + bundle.putLong("stream_duration_minutes", durationMs / (1000 * 60)); + firebaseAnalytics.logEvent("game_stream_end", bundle); + + Log.d(TAG, "Game stream ended for: " + computerName + ", app: " + appName + ", duration: " + (durationMs / 1000) + " seconds"); + } + + /** + * 记录游戏流媒体结束事件(包含性能数据) + */ + public void logGameStreamEnd(String computerName, String appName, long durationMs, + String decoderMessage, int resolutionWidth, int resolutionHeight, + int averageEndToEndLatency, int averageDecoderLatency) { + if (!canExecuteAnalytics()) { + Log.d(TAG, "Game stream end disabled: " + computerName + ", app: " + appName + ", duration: " + (durationMs / 1000) + " seconds"); + return; + } + + Bundle bundle = new Bundle(); + bundle.putString("computer_name", computerName); + bundle.putString("app_name", appName != null ? appName : "unknown"); + bundle.putString("stream_type", "game"); + bundle.putLong("stream_duration_ms", durationMs); + bundle.putLong("stream_duration_minutes", durationMs / (1000 * 60)); + + // 性能数据 + bundle.putString("decoder", decoderMessage != null ? decoderMessage : "unknown"); + bundle.putString("resolution", resolutionWidth + "x" + resolutionHeight); + bundle.putInt("average_end_to_end_latency_ms", averageEndToEndLatency); + bundle.putInt("average_decoder_latency_ms", averageDecoderLatency); + + firebaseAnalytics.logEvent("game_stream_end", bundle); + } + + /** + * 记录应用启动事件 + */ + public void logAppLaunch() { + if (!canExecuteAnalytics()) { + Log.d(TAG, "App launch disabled"); + return; + } + + Bundle bundle = new Bundle(); + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.APP_OPEN, bundle); + + Log.d(TAG, "App launch logged"); + } + + /** + * 记录自定义事件 + */ + public void logCustomEvent(String eventName, Bundle parameters) { + if (!canExecuteAnalytics()) { + Log.d(TAG, "Custom event disabled: " + eventName); + return; + } + + firebaseAnalytics.logEvent(eventName, parameters); + Log.d(TAG, "Custom event logged: " + eventName); + } + + /** + * 设置用户属性 + */ + public void setUserProperty(String propertyName, String propertyValue) { + if (!canExecuteAnalytics()) { + Log.d(TAG, "User property disabled: " + propertyName + " = " + propertyValue); + return; + } + + firebaseAnalytics.setUserProperty(propertyName, propertyValue); + Log.d(TAG, "User property set: " + propertyName + " = " + propertyValue); + } + + /** + * 获取当前会话是否活跃 + */ + public boolean isSessionActive() { + return isSessionActive; + } + + /** + * 获取当前会话时长(毫秒) + */ + public long getCurrentSessionDuration() { + if (!isSessionActive) { + return 0; + } + return System.currentTimeMillis() - sessionStartTime; + } + + /** + * 清理资源 + */ + public void cleanup() { + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdown(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/AppCacheKeys.java b/app/src/main/java/com/limelight/utils/AppCacheKeys.java new file mode 100644 index 0000000000..390307e72e --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AppCacheKeys.java @@ -0,0 +1,103 @@ +package com.limelight.utils; + +/** + * 应用缓存相关的SharedPreferences键名管理类 + */ +public class AppCacheKeys { + + // 应用缓存的基础前缀 + private static final String APP_CACHE_PREFIX = "app_cache_"; + + // 应用信息的各个字段 + public static final String APP_NAME_SUFFIX = "_name"; + public static final String APP_CMD_SUFFIX = "_cmd"; + public static final String APP_HDR_SUFFIX = "_hdr"; + + /** + * 生成应用名称的缓存key + * @param pcUuid PC的UUID + * @param appId 应用ID + * @return 应用名称的缓存key + */ + public static String getAppNameKey(String pcUuid, int appId) { + return APP_CACHE_PREFIX + pcUuid + "_" + appId + APP_NAME_SUFFIX; + } + + /** + * 生成应用命令列表的缓存key + * @param pcUuid PC的UUID + * @param appId 应用ID + * @return 应用命令列表的缓存key + */ + public static String getAppCmdKey(String pcUuid, int appId) { + return APP_CACHE_PREFIX + pcUuid + "_" + appId + APP_CMD_SUFFIX; + } + + /** + * 生成应用HDR支持的缓存key + * @param pcUuid PC的UUID + * @param appId 应用ID + * @return 应用HDR支持的缓存key + */ + public static String getAppHdrKey(String pcUuid, int appId) { + return APP_CACHE_PREFIX + pcUuid + "_" + appId + APP_HDR_SUFFIX; + } + + /** + * 生成应用信息的基础key(不包含具体字段) + * @param pcUuid PC的UUID + * @param appId 应用ID + * @return 应用信息的基础key + */ + public static String getAppBaseKey(String pcUuid, int appId) { + return APP_CACHE_PREFIX + pcUuid + "_" + appId; + } + + /** + * 检查key是否属于应用缓存 + * @param key 要检查的key + * @return 如果是应用缓存key则返回true + */ + public static boolean isAppCacheKey(String key) { + return key != null && key.startsWith(APP_CACHE_PREFIX); + } + + /** + * 从key中提取PC UUID和应用ID + * @param key 应用缓存key + * @return 包含pcUuid和appId的数组,如果解析失败则返回null + */ + public static String[] parseAppCacheKey(String key) { + if (!isAppCacheKey(key)) { + return null; + } + + try { + // 移除前缀 + String withoutPrefix = key.substring(APP_CACHE_PREFIX.length()); + + // 移除后缀 + String withoutSuffix = withoutPrefix; + if (withoutSuffix.endsWith(APP_NAME_SUFFIX)) { + withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length() - APP_NAME_SUFFIX.length()); + } else if (withoutSuffix.endsWith(APP_CMD_SUFFIX)) { + withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length() - APP_CMD_SUFFIX.length()); + } else if (withoutSuffix.endsWith(APP_HDR_SUFFIX)) { + withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length() - APP_HDR_SUFFIX.length()); + } + + // 分割UUID和appId + int lastUnderscoreIndex = withoutSuffix.lastIndexOf('_'); + if (lastUnderscoreIndex == -1) { + return null; + } + + String pcUuid = withoutSuffix.substring(0, lastUnderscoreIndex); + String appId = withoutSuffix.substring(lastUnderscoreIndex + 1); + + return new String[]{pcUuid, appId}; + } catch (Exception e) { + return null; + } + } +} diff --git a/app/src/main/java/com/limelight/utils/AppCacheManager.java b/app/src/main/java/com/limelight/utils/AppCacheManager.java new file mode 100644 index 0000000000..9744a2d8a6 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AppCacheManager.java @@ -0,0 +1,213 @@ +package com.limelight.utils; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.limelight.nvstream.http.NvApp; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 应用缓存管理器 + * 提供统一的应用信息缓存管理功能 + */ +public class AppCacheManager { + + private static final String PREFERENCE_NAME = "app_cache"; + + private final SharedPreferences preferences; + + public AppCacheManager(Context context) { + this.preferences = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); + } + + /** + * 保存完整的应用信息到缓存 + * @param pcUuid PC的UUID + * @param app 应用对象 + */ + public void saveAppInfo(String pcUuid, NvApp app) { + if (pcUuid == null || app == null) { + return; + } + + try { + String nameKey = AppCacheKeys.getAppNameKey(pcUuid, app.getAppId()); + String cmdKey = AppCacheKeys.getAppCmdKey(pcUuid, app.getAppId()); + String hdrKey = AppCacheKeys.getAppHdrKey(pcUuid, app.getAppId()); + + String cmdList = null; + if (app.getCmdList() != null) { + cmdList = app.getCmdList().toString(); + } + + preferences.edit() + .putString(nameKey, app.getAppName()) + .putString(cmdKey, cmdList) + .putBoolean(hdrKey, app.isHdrSupported()) + .apply(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * 从缓存中获取完整的应用信息 + * @param pcUuid PC的UUID + * @param appId 应用ID + * @return 完整的NvApp对象,如果找不到则返回null + */ + public NvApp getAppInfo(String pcUuid, int appId) { + if (pcUuid == null) { + return null; + } + + try { + String nameKey = AppCacheKeys.getAppNameKey(pcUuid, appId); + String cmdKey = AppCacheKeys.getAppCmdKey(pcUuid, appId); + String hdrKey = AppCacheKeys.getAppHdrKey(pcUuid, appId); + + String appName = preferences.getString(nameKey, null); + if (appName == null) { + return null; + } + + String cmdList = preferences.getString(cmdKey, null); + boolean hdrSupported = preferences.getBoolean(hdrKey, false); + + NvApp app = new NvApp(appName, appId, hdrSupported); + if (cmdList != null && !cmdList.isEmpty()) { + app.setCmdList(cmdList); + } + + return app; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 获取指定PC的所有缓存应用ID列表 + * @param pcUuid PC的UUID + * @return 应用ID列表 + */ + public List getCachedAppIds(String pcUuid) { + List appIds = new ArrayList<>(); + + if (pcUuid == null) { + return appIds; + } + + try { + Map allPrefs = preferences.getAll(); + String baseKey = AppCacheKeys.getAppBaseKey(pcUuid, 0).replace("_0", "_"); + + for (String key : allPrefs.keySet()) { + if (key.startsWith(baseKey) && key.endsWith(AppCacheKeys.APP_NAME_SUFFIX)) { + try { + String appIdStr = key.substring(baseKey.length(), key.length() - AppCacheKeys.APP_NAME_SUFFIX.length()); + int appId = Integer.parseInt(appIdStr); + appIds.add(appId); + } catch (NumberFormatException e) { + // 忽略无效的appId + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + return appIds; + } + + /** + * 清除指定PC的所有应用缓存 + * @param pcUuid PC的UUID + */ + public void clearPcCache(String pcUuid) { + if (pcUuid == null) { + return; + } + + try { + List appIds = getCachedAppIds(pcUuid); + SharedPreferences.Editor editor = preferences.edit(); + + for (int appId : appIds) { + String nameKey = AppCacheKeys.getAppNameKey(pcUuid, appId); + String cmdKey = AppCacheKeys.getAppCmdKey(pcUuid, appId); + String hdrKey = AppCacheKeys.getAppHdrKey(pcUuid, appId); + + editor.remove(nameKey) + .remove(cmdKey) + .remove(hdrKey); + } + + editor.apply(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * 清除指定应用的所有缓存 + * @param pcUuid PC的UUID + * @param appId 应用ID + */ + public void clearAppCache(String pcUuid, int appId) { + if (pcUuid == null) { + return; + } + + try { + String nameKey = AppCacheKeys.getAppNameKey(pcUuid, appId); + String cmdKey = AppCacheKeys.getAppCmdKey(pcUuid, appId); + String hdrKey = AppCacheKeys.getAppHdrKey(pcUuid, appId); + + preferences.edit() + .remove(nameKey) + .remove(cmdKey) + .remove(hdrKey) + .apply(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * 清除所有应用缓存 + */ + public void clearAllCache() { + try { + preferences.edit().clear().apply(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * 获取缓存统计信息 + * @return 缓存统计信息字符串 + */ + public String getCacheStats() { + try { + Map allPrefs = preferences.getAll(); + int totalKeys = allPrefs.size(); + int appCacheKeys = 0; + + for (String key : allPrefs.keySet()) { + if (AppCacheKeys.isAppCacheKey(key)) { + appCacheKeys++; + } + } + + return String.format("总键数: %d, 应用缓存键数: %d", totalKeys, appCacheKeys); + } catch (Exception e) { + e.printStackTrace(); + return "获取统计信息失败"; + } + } +} diff --git a/app/src/main/java/com/limelight/utils/AppIconCache.java b/app/src/main/java/com/limelight/utils/AppIconCache.java new file mode 100644 index 0000000000..0562edf6b1 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AppIconCache.java @@ -0,0 +1,80 @@ +package com.limelight.utils; + +import android.graphics.Bitmap; +import android.util.LruCache; + +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +/** + * 全局App Icon缓存管理器 + */ +public class AppIconCache { + private static AppIconCache instance; + private final LruCache iconCache; + + private AppIconCache() { + // 获取应用可用内存的1/8作为缓存大小 + final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSize = maxMemory / 8; + + iconCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + // 返回bitmap占用的内存大小(KB) + return bitmap.getByteCount() / 1024; + } + }; + } + + public static AppIconCache getInstance() { + if (instance == null) { + instance = new AppIconCache(); + } + return instance; + } + + /** + * 生成缓存键 + */ + private String generateKey(ComputerDetails computer, NvApp app) { + return computer.uuid + "_" + app.getAppId(); + } + + /** + * 存储app icon + */ + public void putIcon(ComputerDetails computer, NvApp app, Bitmap icon) { + if (computer != null && app != null && icon != null) { + String key = generateKey(computer, app); + iconCache.put(key, icon); + } + } + + /** + * 获取app icon + */ + public Bitmap getIcon(ComputerDetails computer, NvApp app) { + if (computer != null && app != null) { + String key = generateKey(computer, app); + return iconCache.get(key); + } + return null; + } + + /** + * 清除缓存 + */ + public void clear() { + iconCache.evictAll(); + } + + /** + * 清除特定电脑的缓存 + */ + public void clearForComputer(String computerUuid) { + // 由于LruCache没有提供按前缀删除的方法,我们只能清除所有缓存 + // 在实际应用中,可以考虑使用更复杂的缓存实现 + clear(); + } +} diff --git a/app/src/main/java/com/limelight/utils/AppSettingsManager.java b/app/src/main/java/com/limelight/utils/AppSettingsManager.java new file mode 100644 index 0000000000..3e01de7bbb --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AppSettingsManager.java @@ -0,0 +1,526 @@ +package com.limelight.utils; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import com.limelight.R; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.preferences.PreferenceConfiguration; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * App Settings Manager + * Used to store and retrieve the last streaming settings for each app + */ +public class AppSettingsManager { + + private static final String PREF_FILE_NAME = "app_last_settings"; + private static final String KEY_USE_LAST_SETTINGS = "use_last_settings"; + + // Intent constants + private static final String INTENT_USE_LAST_SETTINGS = "UseLastSettings"; + private static final String INTENT_LAST_SETTINGS_WIDTH = "LastSettingsWidth"; + private static final String INTENT_LAST_SETTINGS_HEIGHT = "LastSettingsHeight"; + private static final String INTENT_LAST_SETTINGS_FPS = "LastSettingsFps"; + private static final String INTENT_LAST_SETTINGS_BITRATE = "LastSettingsBitrate"; + private static final String INTENT_LAST_SETTINGS_RESOLUTION_SCALE = "LastSettingsResolutionScale"; + private static final String INTENT_LAST_SETTINGS_VIDEO_FORMAT = "LastSettingsVideoFormat"; + private static final String INTENT_LAST_SETTINGS_ENABLE_HDR = "LastSettingsEnableHdr"; + private static final String INTENT_LAST_SETTINGS_ENABLE_MIC = "LastSettingsEnableMic"; + private static final String INTENT_LAST_SETTINGS_MIC_BITRATE = "LastSettingsMicBitrate"; + private static final String INTENT_LAST_SETTINGS_ENABLE_NATIVE_MOUSE = "LastSettingsEnableNativeMouse"; + private static final String INTENT_LAST_SETTINGS_GYRO_SENSITIVITY = "LastSettingsGyroSensitivity"; + private static final String INTENT_LAST_SETTINGS_GYRO_INVERT_X = "LastSettingsGyroInvertX"; + private static final String INTENT_LAST_SETTINGS_GYRO_INVERT_Y = "LastSettingsGyroInvertY"; + private static final String INTENT_LAST_SETTINGS_GYRO_ACTIVATION_KEY = "LastSettingsGyroActivationKey"; + private static final String INTENT_LAST_SETTINGS_SHOW_BITRATE_CARD = "LastSettingsShowBitrateCard"; + private static final String INTENT_LAST_SETTINGS_SHOW_GYRO_CARD = "LastSettingsShowGyroCard"; + private static final String INTENT_LAST_SETTINGS_SHOW_QuickKeyCard = "LastSettingsshowQuickKeyCard"; + + private final Context context; + private final SharedPreferences preferences; + + public AppSettingsManager(Context context) { + this.context = context; + this.preferences = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE); + } + + /** + * Save the last settings for an app + * + * @param computerUuid Computer UUID + * @param app App object + * @param settings Settings configuration + */ + public void saveAppLastSettings(String computerUuid, NvApp app, PreferenceConfiguration settings) { + if (app == null || settings == null) { + return; + } + + String key = generateKey(computerUuid, app.getAppId()); + + try { + JSONObject settingsJson = settingsToJson(settings); + preferences.edit() + .putString(key, settingsJson.toString()) + .commit(); // Use commit to ensure synchronous save + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + /** + * Retrieve the last settings for an app + * + * @param computerUuid Computer UUID + * @param app App object + * @return Last settings configuration, or null if none exists + */ + public PreferenceConfiguration getAppLastSettings(String computerUuid, NvApp app) { + if (app == null) { + return null; + } + + String key = generateKey(computerUuid, app.getAppId()); + String settingsJsonString = preferences.getString(key, null); + + if (settingsJsonString == null) { + return null; + } + + try { + JSONObject settingsJson = new JSONObject(settingsJsonString); + return jsonToSettings(settingsJson); + + } catch (JSONException e) { + e.printStackTrace(); + return null; + } + } + + /** + * Get whether the "use last settings" toggle is enabled + * + * @return true if use last settings is enabled, false otherwise + */ + public boolean isUseLastSettingsEnabled() { + return preferences.getBoolean(KEY_USE_LAST_SETTINGS, false); + } + + /** + * Set whether the "use last settings" toggle is enabled + * + * @param enabled true to enable use last settings, false to disable + */ + public void setUseLastSettingsEnabled(boolean enabled) { + preferences.edit() + .putBoolean(KEY_USE_LAST_SETTINGS, enabled) + .apply(); + } + + /** + * Get the timestamp of the last settings for an app + * + * @param computerUuid Computer UUID + * @param app App object + * @return Timestamp in milliseconds + */ + public long getAppLastSettingsTimestamp(String computerUuid, NvApp app) { + if (app == null) { + return 0; + } + + String key = generateKey(computerUuid, app.getAppId()); + String settingsJsonString = preferences.getString(key, null); + + if (settingsJsonString == null) { + return 0; + } + + try { + JSONObject settingsJson = new JSONObject(settingsJsonString); + return settingsJson.optLong("timestamp", 0); + } catch (JSONException e) { + e.printStackTrace(); + return 0; + } + } + + /** + * Clear the last settings for an app + * + * @param computerUuid Computer UUID + * @param app App object + */ + public void clearAppLastSettings(String computerUuid, NvApp app) { + if (app == null) { + return; + } + + String key = generateKey(computerUuid, app.getAppId()); + preferences.edit() + .remove(key) + .apply(); + } + + /** + * Clear all last settings for all apps + */ + public void clearAllAppLastSettings() { + preferences.edit() + .clear() + .apply(); + } + + /** + * Generate storage key + * + * @param computerUuid Computer UUID + * @param appId App ID + * @return Storage key + */ + private String generateKey(String computerUuid, int appId) { + return computerUuid + "_" + appId; + } + + /** + * Get settings summary information + * + * @param computerUuid Computer UUID + * @param app App object + * @return Settings summary string + */ + public String getSettingsSummary(String computerUuid, NvApp app) { + PreferenceConfiguration settings = getAppLastSettings(computerUuid, app); + if (settings == null) { + return context.getString(R.string.app_last_settings_none); + } + + long timestamp = getAppLastSettingsTimestamp(computerUuid, app); + String timeStr = formatTimestamp(timestamp); + + // Build detailed settings information + StringBuilder summary = new StringBuilder(); + + // Basic video settings + summary.append(context.getString(R.string.setting_resolution, settings.width, settings.height)); + summary.append(" | ").append(context.getString(R.string.setting_fps, settings.fps)); + summary.append(" | ").append(context.getString(R.string.setting_bitrate, settings.bitrate)); + + // Resolution scale + if (settings.resolutionScale != 100) { + summary.append(" | ").append(context.getString(R.string.setting_scale, settings.resolutionScale)); + } + + // Video format + String videoFormatStr = settings.videoFormat.toString(); + summary.append(" | ").append(context.getString(R.string.setting_format, videoFormatStr)); + + // HDR settings + if (settings.enableHdr) { + summary.append(" | ").append(context.getString(R.string.setting_hdr_enabled)); + } + + // Microphone settings + if (settings.enableMic) { + summary.append(" | ").append(context.getString(R.string.setting_mic_enabled, settings.micBitrate)); + } + + // Native mouse pointer + if (settings.enableNativeMousePointer) { + summary.append(" | ").append(context.getString(R.string.setting_native_mouse_enabled)); + } + + // Card configuration + if (settings.showBitrateCard) { + summary.append(" | ").append(context.getString(R.string.setting_bitrate_card_enabled)); + } + if (settings.showGyroCard) { + summary.append(" | ").append(context.getString(R.string.setting_gyro_card_enabled)); + } + if (settings.showQuickKeyCard) { + summary.append(" | ").append(context.getString(R.string.setting_QuickKey_card_enabled)); + } + + // Add time information + summary.append(" (").append(timeStr).append(")"); + + return summary.toString(); + } + + /** + * Format timestamp to readable string + * + * @param timestamp Timestamp in milliseconds + * @return Formatted time string + */ + private String formatTimestamp(long timestamp) { + if (timestamp == 0) { + return context.getString(R.string.time_unknown); + } + + long now = System.currentTimeMillis(); + long diff = now - timestamp; + + if (diff < 60000) { // Within 1 minute + return context.getString(R.string.time_just_now); + } else if (diff < 3600000) { // Within 1 hour + return context.getString(R.string.time_minutes_ago, diff / 60000); + } else if (diff < 86400000) { // Within 1 day + return context.getString(R.string.time_hours_ago, diff / 3600000); + } else { // More than 1 day + return context.getString(R.string.time_days_ago, diff / 86400000); + } + } + + /** + * Get video format preference string + * Copied from PreferenceConfiguration method + */ + private String getVideoFormatPreferenceString(PreferenceConfiguration.FormatOption format) { + switch (format) { + case AUTO: + return "auto"; + case FORCE_H264: + return "h264"; + case FORCE_HEVC: + return "hevc"; + case FORCE_AV1: + return "av1"; + default: + return "auto"; + } + } + + /** + * Parse video format string to enum + * Unified video format parsing logic + */ + private PreferenceConfiguration.FormatOption parseVideoFormat(String videoFormatStr) { + if (videoFormatStr == null) { + return PreferenceConfiguration.FormatOption.AUTO; + } + + switch (videoFormatStr.toLowerCase()) { + case "h264": + case "force_h264": + return PreferenceConfiguration.FormatOption.FORCE_H264; + case "hevc": + case "force_hevc": + return PreferenceConfiguration.FormatOption.FORCE_HEVC; + case "av1": + case "force_av1": + return PreferenceConfiguration.FormatOption.FORCE_AV1; + case "auto": + default: + return PreferenceConfiguration.FormatOption.AUTO; + } + } + + /** + * Convert PreferenceConfiguration to JSONObject + */ + private JSONObject settingsToJson(PreferenceConfiguration settings) throws JSONException { + JSONObject settingsJson = new JSONObject(); + settingsJson.put("resolution", settings.width + "x" + settings.height); + settingsJson.put("fps", String.valueOf(settings.fps)); + settingsJson.put("bitrate", settings.bitrate); + settingsJson.put("resolutionScale", settings.resolutionScale); + settingsJson.put("videoFormat", getVideoFormatPreferenceString(settings.videoFormat)); + settingsJson.put("enableHdr", settings.enableHdr); + settingsJson.put("enableMic", settings.enableMic); + settingsJson.put("micBitrate", settings.micBitrate); + settingsJson.put("enableNativeMousePointer", settings.enableNativeMousePointer); + settingsJson.put("gyroSensitivityMultiplier", settings.gyroSensitivityMultiplier); + settingsJson.put("gyroInvertXAxis", settings.gyroInvertXAxis); + settingsJson.put("gyroInvertYAxis", settings.gyroInvertYAxis); + settingsJson.put("gyroActivationKeyCode", settings.gyroActivationKeyCode); + settingsJson.put("showBitrateCard", settings.showBitrateCard); + settingsJson.put("showGyroCard", settings.showGyroCard); + settingsJson.put("showQuickKeyCard", settings.showQuickKeyCard); + settingsJson.put("timestamp", System.currentTimeMillis()); + return settingsJson; + } + + /** + * Create PreferenceConfiguration from JSONObject + */ + private PreferenceConfiguration jsonToSettings(JSONObject settingsJson) throws JSONException { + PreferenceConfiguration settings = new PreferenceConfiguration(); + + // Initialize all necessary fields to avoid NullPointerException + settings.screenPosition = PreferenceConfiguration.ScreenPosition.CENTER; + settings.screenOffsetX = 0; + settings.screenOffsetY = 0; + settings.useExternalDisplay = false; + settings.enablePerfOverlay = false; + settings.reverseResolution = false; + settings.rotableScreen = false; + settings.showBitrateCard = false; + settings.showGyroCard = false; + settings.showQuickKeyCard = false; + + // Parse resolution string format "1920x1080" + String resolutionStr = settingsJson.optString("resolution", "1920x1080"); + String[] resolutionParts = resolutionStr.split("x"); + if (resolutionParts.length == 2) { + settings.width = Integer.parseInt(resolutionParts[0]); + settings.height = Integer.parseInt(resolutionParts[1]); + } else { + settings.width = 1920; + settings.height = 1080; + } + + settings.fps = Integer.parseInt(settingsJson.optString("fps", "60")); + settings.bitrate = settingsJson.optInt("bitrate", PreferenceConfiguration.getDefaultBitrate(context)); + settings.resolutionScale = settingsJson.optInt("resolutionScale", 100); + settings.videoFormat = parseVideoFormat(settingsJson.optString("videoFormat", "auto")); + + settings.enableHdr = settingsJson.optBoolean("enableHdr", false); + settings.enableMic = settingsJson.optBoolean("enableMic", false); + settings.micBitrate = settingsJson.optInt("micBitrate", 96); + settings.enableNativeMousePointer = settingsJson.optBoolean("enableNativeMousePointer", false); + settings.gyroSensitivityMultiplier = (float) settingsJson.optDouble("gyroSensitivityMultiplier", 1.0f); + settings.gyroInvertXAxis = settingsJson.optBoolean("gyroInvertXAxis", false); + settings.gyroInvertYAxis = settingsJson.optBoolean("gyroInvertYAxis", false); + settings.gyroActivationKeyCode = settingsJson.optInt("gyroActivationKeyCode", android.view.KeyEvent.KEYCODE_BUTTON_L2); + settings.showBitrateCard = settingsJson.optBoolean("showBitrateCard", true); + settings.showGyroCard = settingsJson.optBoolean("showGyroCard", true); + settings.showQuickKeyCard = settingsJson.optBoolean("showQuickKeyCard", true); + + return settings; + } + + /** + * Create start Intent with last settings if enabled + * + * @param parent Parent Activity + * @param app App object + * @param computer Computer details + * @param managerBinder Computer manager binder + * @return Start Intent, includes last settings parameters if "use last settings" is enabled and last settings exist + */ + public Intent createStartIntentWithLastSettingsIfEnabled(Activity parent, NvApp app, + ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder) { + // Check if use last settings is enabled + boolean useLastSettingsEnabled = isUseLastSettingsEnabled(); + + if (useLastSettingsEnabled && computer != null) { + PreferenceConfiguration lastSettings = getAppLastSettings(computer.uuid, app); + + if (lastSettings != null) { + // Create Intent with last settings + return ServerHelper.createStartIntent(parent, app, computer, managerBinder, lastSettings); + } + } + + // Create Intent with default settings + return ServerHelper.createStartIntent(parent, app, computer, managerBinder); + } + + /** + * Add last settings to Intent + * + * @param intent Intent to add settings to + * @param lastSettings Last settings configuration + */ + public static void addLastSettingsToIntent(Intent intent, PreferenceConfiguration lastSettings) { + if (intent == null || lastSettings == null) { + return; + } + + intent.putExtra(INTENT_USE_LAST_SETTINGS, true); + intent.putExtra(INTENT_LAST_SETTINGS_WIDTH, lastSettings.width); + intent.putExtra(INTENT_LAST_SETTINGS_HEIGHT, lastSettings.height); + intent.putExtra(INTENT_LAST_SETTINGS_FPS, lastSettings.fps); + intent.putExtra(INTENT_LAST_SETTINGS_BITRATE, lastSettings.bitrate); + intent.putExtra(INTENT_LAST_SETTINGS_RESOLUTION_SCALE, lastSettings.resolutionScale); + intent.putExtra(INTENT_LAST_SETTINGS_VIDEO_FORMAT, lastSettings.videoFormat.toString()); + intent.putExtra(INTENT_LAST_SETTINGS_ENABLE_HDR, lastSettings.enableHdr); + intent.putExtra(INTENT_LAST_SETTINGS_ENABLE_MIC, lastSettings.enableMic); + intent.putExtra(INTENT_LAST_SETTINGS_MIC_BITRATE, lastSettings.micBitrate); + intent.putExtra(INTENT_LAST_SETTINGS_ENABLE_NATIVE_MOUSE, lastSettings.enableNativeMousePointer); + intent.putExtra(INTENT_LAST_SETTINGS_GYRO_SENSITIVITY, lastSettings.gyroSensitivityMultiplier); + intent.putExtra(INTENT_LAST_SETTINGS_GYRO_INVERT_X, lastSettings.gyroInvertXAxis); + intent.putExtra(INTENT_LAST_SETTINGS_GYRO_INVERT_Y, lastSettings.gyroInvertYAxis); + intent.putExtra(INTENT_LAST_SETTINGS_GYRO_ACTIVATION_KEY, lastSettings.gyroActivationKeyCode); + intent.putExtra(INTENT_LAST_SETTINGS_SHOW_BITRATE_CARD, lastSettings.showBitrateCard); + intent.putExtra(INTENT_LAST_SETTINGS_SHOW_GYRO_CARD, lastSettings.showGyroCard); + intent.putExtra(INTENT_LAST_SETTINGS_SHOW_QuickKeyCard, lastSettings.showQuickKeyCard); + } + + /** + * Read last settings from Intent and apply to PreferenceConfiguration + * + * @param intent Intent containing last settings + * @param prefConfig PreferenceConfiguration object to apply settings to + * @return true if last settings were successfully applied, false otherwise + */ + public boolean applyLastSettingsFromIntent(Intent intent, PreferenceConfiguration prefConfig) { + if (intent == null || prefConfig == null) { + return false; + } + + try { + // Check if Intent contains last settings + boolean useLastSettings = intent.getBooleanExtra(INTENT_USE_LAST_SETTINGS, false); + if (!useLastSettings) { + return false; + } + + // Read last settings from Intent + prefConfig.width = intent.getIntExtra(INTENT_LAST_SETTINGS_WIDTH, prefConfig.width); + prefConfig.height = intent.getIntExtra(INTENT_LAST_SETTINGS_HEIGHT, prefConfig.height); + prefConfig.fps = intent.getIntExtra(INTENT_LAST_SETTINGS_FPS, prefConfig.fps); + prefConfig.bitrate = intent.getIntExtra(INTENT_LAST_SETTINGS_BITRATE, prefConfig.bitrate); + prefConfig.resolutionScale = intent.getIntExtra(INTENT_LAST_SETTINGS_RESOLUTION_SCALE, prefConfig.resolutionScale); + prefConfig.enableHdr = intent.getBooleanExtra(INTENT_LAST_SETTINGS_ENABLE_HDR, prefConfig.enableHdr); + prefConfig.enableMic = intent.getBooleanExtra(INTENT_LAST_SETTINGS_ENABLE_MIC, prefConfig.enableMic); + prefConfig.micBitrate = intent.getIntExtra(INTENT_LAST_SETTINGS_MIC_BITRATE, prefConfig.micBitrate); + prefConfig.enableNativeMousePointer = intent.getBooleanExtra(INTENT_LAST_SETTINGS_ENABLE_NATIVE_MOUSE, prefConfig.enableNativeMousePointer); + prefConfig.gyroSensitivityMultiplier = intent.getFloatExtra(INTENT_LAST_SETTINGS_GYRO_SENSITIVITY, prefConfig.gyroSensitivityMultiplier); + prefConfig.gyroInvertXAxis = intent.getBooleanExtra(INTENT_LAST_SETTINGS_GYRO_INVERT_X, prefConfig.gyroInvertXAxis); + prefConfig.gyroInvertYAxis = intent.getBooleanExtra(INTENT_LAST_SETTINGS_GYRO_INVERT_Y, prefConfig.gyroInvertYAxis); + prefConfig.gyroActivationKeyCode = intent.getIntExtra(INTENT_LAST_SETTINGS_GYRO_ACTIVATION_KEY, prefConfig.gyroActivationKeyCode); + prefConfig.showBitrateCard = intent.getBooleanExtra(INTENT_LAST_SETTINGS_SHOW_BITRATE_CARD, prefConfig.showBitrateCard); + prefConfig.showGyroCard = intent.getBooleanExtra(INTENT_LAST_SETTINGS_SHOW_GYRO_CARD, prefConfig.showGyroCard); + prefConfig.showQuickKeyCard = intent.getBooleanExtra(INTENT_LAST_SETTINGS_SHOW_QuickKeyCard, prefConfig.showQuickKeyCard); + + // Parse video format + String videoFormatStr = intent.getStringExtra(INTENT_LAST_SETTINGS_VIDEO_FORMAT); + prefConfig.videoFormat = parseVideoFormat(videoFormatStr); + + return true; + + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * Check if the specified app has last settings + * + * @param computerUuid Computer UUID + * @param app App object + * @return true if last settings exist, false otherwise + */ + public boolean hasLastSettings(String computerUuid, NvApp app) { + if (app == null) { + return false; + } + + String key = generateKey(computerUuid, app.getAppId()); + String settingsJsonString = preferences.getString(key, null); + + return settingsJsonString != null && !settingsJsonString.isEmpty(); + } +} diff --git a/app/src/main/java/com/limelight/utils/AspectRatioConverter.java b/app/src/main/java/com/limelight/utils/AspectRatioConverter.java new file mode 100644 index 0000000000..061711bb18 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/AspectRatioConverter.java @@ -0,0 +1,24 @@ +package com.limelight.utils; + +import android.util.Log; + +public class AspectRatioConverter { + public static String getAspectRatio(int width, int height) { + float ratio = (float) width / height; + float truncatedValue = (float) (Math.floor(ratio * 100) / 100); + + if (truncatedValue == 1.25f) return "5:4"; + if (truncatedValue == 1.33f) return "4:3"; + if (truncatedValue == 1.50f) return "3:2"; + if (truncatedValue == 1.60f) return "16:10"; + if (truncatedValue == 1.77f) return "16:9"; + if (truncatedValue == 1.85f) return "1.85:1"; + if (truncatedValue == 2.22f) return "20:9"; + if (truncatedValue >= 2.37f && truncatedValue <= 2.44f) return "21:9"; + if (truncatedValue == 2.76f) return "2.76:1"; + if (truncatedValue == 3.20f) return "32:10"; + if (truncatedValue == 3.55f) return "32:9"; + + return null; + } +} diff --git a/app/src/main/java/com/limelight/utils/BackgroundImageManager.java b/app/src/main/java/com/limelight/utils/BackgroundImageManager.java new file mode 100644 index 0000000000..bc9a7f4831 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/BackgroundImageManager.java @@ -0,0 +1,102 @@ +package com.limelight.utils; + +import android.content.Context; +import android.graphics.Bitmap; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.ImageView; + +import com.limelight.R; + +/** + * 背景图片管理器,用于处理AppView背景图片的平滑切换 + */ +public class BackgroundImageManager { + private final Context context; + private final ImageView backgroundImageView; + private Bitmap currentBackground; + + public BackgroundImageManager(Context context, ImageView backgroundImageView) { + this.context = context; + this.backgroundImageView = backgroundImageView; + } + + /** + * 平滑地切换到新的背景图片 + * @param newBackground 新的背景图片 + */ + public void setBackgroundSmoothly(Bitmap newBackground) { + if (newBackground == null) { + return; + } + + // 如果当前没有背景图片,直接设置 + if (currentBackground == null) { + currentBackground = newBackground; + backgroundImageView.setImageBitmap(newBackground); + backgroundImageView.startAnimation( + AnimationUtils.loadAnimation(context, R.anim.background_fadein) + ); + return; + } + + // 如果背景图片相同,不需要切换 + if (currentBackground.equals(newBackground)) { + return; + } + + // 执行平滑切换动画 + Animation fadeOutAnimation = AnimationUtils.loadAnimation(context, R.anim.background_fadeout); + fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + // 淡出完成后,设置新图片并淡入 + currentBackground = newBackground; + backgroundImageView.setImageBitmap(newBackground); + backgroundImageView.startAnimation( + AnimationUtils.loadAnimation(context, R.anim.background_fadein) + ); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + backgroundImageView.startAnimation(fadeOutAnimation); + } + + /** + * 清除背景图片 + */ + public void clearBackground() { + if (currentBackground != null) { + Animation fadeOutAnimation = AnimationUtils.loadAnimation(context, R.anim.background_fadeout); + fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + backgroundImageView.setImageBitmap(null); + currentBackground = null; + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + backgroundImageView.startAnimation(fadeOutAnimation); + } + } + + /** + * 获取当前背景图片 + */ + public Bitmap getCurrentBackground() { + return currentBackground; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/ColorPickerDialog.java b/app/src/main/java/com/limelight/utils/ColorPickerDialog.java new file mode 100644 index 0000000000..c15de39b5d --- /dev/null +++ b/app/src/main/java/com/limelight/utils/ColorPickerDialog.java @@ -0,0 +1,530 @@ +package com.limelight.utils; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Shader; +import android.graphics.ComposeShader; +import android.graphics.PorterDuff; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.graphics.Canvas; + +import androidx.annotation.NonNull; + +/** + * 一个功能强大的颜色选择器对话框,模仿了桌面应用中的常见样式。 + */ +public class ColorPickerDialog extends Dialog { + + public interface OnColorSelectedListener { + void onColorSelected(int color); + } + + private final int initialColor; + private final boolean showAlphaSlider; + private final OnColorSelectedListener listener; + + private SaturationValueView svView; + private HueSlider hueSlider; + private View colorPreview; + private EditText hexInput; + private SeekBar alphaSeekBar, redSeekBar, greenSeekBar, blueSeekBar; + private EditText alphaValue, redValue, greenValue, blueValue; + + private float[] currentHsv = new float[3]; + private int currentAlpha; + private boolean isUpdatingFromInput = false; + + public ColorPickerDialog(@NonNull Context context, int initialColor, boolean showAlphaSlider, OnColorSelectedListener listener) { + super(context); + this.initialColor = initialColor; + this.showAlphaSlider = showAlphaSlider; + this.listener = listener; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + Color.colorToHSV(initialColor, currentHsv); + currentAlpha = Color.alpha(initialColor); + + // --- 主布局:水平方向 --- + LinearLayout masterLayout = new LinearLayout(getContext()); + masterLayout.setOrientation(LinearLayout.HORIZONTAL); + masterLayout.setPadding(dpToPx(16), dpToPx(16), dpToPx(16), dpToPx(16)); + masterLayout.setGravity(Gravity.CENTER_VERTICAL); + + // -- 左侧:颜色选择器核心 -- + LinearLayout pickerLayout = new LinearLayout(getContext()); + pickerLayout.setOrientation(LinearLayout.HORIZONTAL); + svView = new SaturationValueView(getContext()); + hueSlider = new HueSlider(getContext()); + + LinearLayout.LayoutParams svParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f); + svParams.rightMargin = dpToPx(16); + pickerLayout.addView(svView, svParams); + + LinearLayout.LayoutParams hueParams = new LinearLayout.LayoutParams(dpToPx(24), ViewGroup.LayoutParams.MATCH_PARENT); + pickerLayout.addView(hueSlider, hueParams); + + LinearLayout.LayoutParams pickerLayoutParams = new LinearLayout.LayoutParams(0, dpToPx(220), 2f); + pickerLayoutParams.rightMargin = dpToPx(16); + masterLayout.addView(pickerLayout, pickerLayoutParams); + + // -- 右侧:控件和预览 (使用 ScrollView 包裹) -- + ScrollView controlsScrollView = new ScrollView(getContext()); + LinearLayout controlsLayout = new LinearLayout(getContext()); + controlsLayout.setOrientation(LinearLayout.VERTICAL); + + // -- 预览和 Hex 输入 -- + LinearLayout previewHexLayout = new LinearLayout(getContext()); + previewHexLayout.setOrientation(LinearLayout.HORIZONTAL); + previewHexLayout.setGravity(Gravity.CENTER_VERTICAL); + previewHexLayout.setPadding(0, 0, 0, dpToPx(16)); + + colorPreview = new View(getContext()); + LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(dpToPx(40), dpToPx(40)); + previewHexLayout.addView(colorPreview, previewParams); + + LinearLayout hexContainer = new LinearLayout(getContext()); + hexContainer.setOrientation(LinearLayout.VERTICAL); + hexContainer.setPadding(dpToPx(8), 0, 0, 0); + + TextView hexLabel = new TextView(getContext()); + hexLabel.setText("Hex:"); + hexLabel.setTextSize(14); + hexContainer.addView(hexLabel); + + hexInput = new EditText(getContext()); + hexInput.setSingleLine(true); + hexInput.setMinEms(9); + hexContainer.addView(hexInput); + + previewHexLayout.addView(hexContainer); + controlsLayout.addView(previewHexLayout); + + // -- RGBA 滑块 -- + if (showAlphaSlider) { + controlsLayout.addView(createSliderRow("A:", 0, 255)); + } + controlsLayout.addView(createSliderRow("R:", 1, 255)); + controlsLayout.addView(createSliderRow("G:", 2, 255)); + controlsLayout.addView(createSliderRow("B:", 3, 255)); + + // -- 确定/取消按钮 -- + LinearLayout buttonLayout = new LinearLayout(getContext()); + buttonLayout.setOrientation(LinearLayout.HORIZONTAL); + buttonLayout.setGravity(Gravity.END); + buttonLayout.setPadding(0, dpToPx(16), 0, 0); + + Button cancelButton = new Button(getContext()); + cancelButton.setText("取消"); + cancelButton.setOnClickListener(v -> dismiss()); + + Button okButton = new Button(getContext()); + okButton.setText("确定"); + okButton.setOnClickListener(v -> { + if (listener != null) { + listener.onColorSelected(getColor()); + } + dismiss(); + }); + + buttonLayout.addView(cancelButton); + buttonLayout.addView(okButton); + controlsLayout.addView(buttonLayout); + + controlsScrollView.addView(controlsLayout); + + LinearLayout.LayoutParams controlsLayoutParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + masterLayout.addView(controlsScrollView, controlsLayoutParams); + + setContentView(masterLayout); + + setupListeners(); + updateAllComponents(initialColor); + } + + private View createSliderRow(String label, int componentIndex, int max) { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dpToPx(4), 0, dpToPx(4)); + + TextView tvLabel = new TextView(getContext()); + tvLabel.setText(label); + tvLabel.setMinWidth(dpToPx(20)); + row.addView(tvLabel); + + SeekBar seekBar = new SeekBar(getContext()); + seekBar.setMax(max); + LinearLayout.LayoutParams seekParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + seekParams.leftMargin = dpToPx(8); + seekParams.rightMargin = dpToPx(8); + row.addView(seekBar, seekParams); + + EditText valueInput = new EditText(getContext()); + valueInput.setText("0"); + valueInput.setMaxLines(1); + valueInput.setMinWidth(dpToPx(40)); + valueInput.setGravity(Gravity.CENTER); + row.addView(valueInput); + + switch (componentIndex) { + case 0: alphaSeekBar = seekBar; alphaValue = valueInput; break; + case 1: redSeekBar = seekBar; redValue = valueInput; break; + case 2: greenSeekBar = seekBar; greenValue = valueInput; break; + case 3: blueSeekBar = seekBar; blueValue = valueInput; break; + } + return row; + } + + + private void updateFromRgb() { + // 1. 从滑块获取最新的 A, R, G, B 值 + int a = showAlphaSlider ? alphaSeekBar.getProgress() : 255; + int r = redSeekBar.getProgress(); + int g = greenSeekBar.getProgress(); + int b = blueSeekBar.getProgress(); + int color = Color.argb(a, r, g, b); + + // 2. 更新内部状态变量 + currentAlpha = a; + Color.colorToHSV(color, currentHsv); + + updatePreview(color); + + // 更新饱和度/亮度面板 + svView.setHue(currentHsv[0]); + svView.setSatVal(currentHsv[1], currentHsv[2]); + + // 更新色相滑块 + hueSlider.setHue(currentHsv[0]); + + // 更新Hex输入框 + updateHexInput(color); + } + + private void setupListeners() { + svView.setListener(this::updateFromSv); + + hueSlider.setListener(hue -> { + currentHsv[0] = hue; + svView.setHue(hue); + updateFromHsv(); + }); + + hexInput.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + @Override public void afterTextChanged(Editable s) { + if (isUpdatingFromInput) return; + try { + String hexString = s.toString(); + if (!hexString.startsWith("#")) { + hexString = "#" + hexString; + } + int color = Color.parseColor(hexString); + if (s.length() <= 7) { // 6位hex + color = (currentAlpha << 24) | (color & 0x00FFFFFF); + } + updateAllComponents(color); + } catch (IllegalArgumentException e) { + // 无效的 hex + } + } + }); + + setupComponentListeners(redSeekBar, redValue, 1); + setupComponentListeners(greenSeekBar, greenValue, 2); + setupComponentListeners(blueSeekBar, blueValue, 3); + if (showAlphaSlider) { + setupComponentListeners(alphaSeekBar, alphaValue, 0); + } + } + + private void setupComponentListeners(SeekBar seekBar, EditText editText, int componentIndex) { + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + isUpdatingFromInput = true; + editText.setText(String.valueOf(progress)); + editText.setSelection(editText.getText().length()); + updateFromRgb(); + isUpdatingFromInput = false; + } + } + @Override public void onStartTrackingTouch(SeekBar seekBar) {} + @Override public void onStopTrackingTouch(SeekBar seekBar) {} + }); + + editText.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + @Override public void afterTextChanged(Editable s) { + if (isUpdatingFromInput) return; + try { + int value = Integer.parseInt(s.toString()); + if (value >= 0 && value <= 255) { + isUpdatingFromInput = true; + seekBar.setProgress(value); + updateFromRgb(); + isUpdatingFromInput = false; + } + } catch (NumberFormatException e) { + // 无效数字 + } + } + }); + } + + private void updateFromSv(float sat, float val) { + currentHsv[1] = sat; + currentHsv[2] = val; + updateFromHsv(); + } + + private void updateFromHsv() { + int color = Color.HSVToColor(currentAlpha, currentHsv); + if (isUpdatingFromInput) return; + isUpdatingFromInput = true; + updateRgbControls(color); + updateHexInput(color); + updatePreview(color); + isUpdatingFromInput = false; + } + + private void updateAllComponents(int color) { + Color.colorToHSV(color, currentHsv); + currentAlpha = Color.alpha(color); + + isUpdatingFromInput = true; + updateRgbControls(color); + updateHexInput(color); + updatePreview(color); + svView.setHue(currentHsv[0]); + svView.setSatVal(currentHsv[1], currentHsv[2]); + hueSlider.setHue(currentHsv[0]); + isUpdatingFromInput = false; + } + + private void updateRgbControls(int color) { + if (showAlphaSlider) { + alphaSeekBar.setProgress(Color.alpha(color)); + alphaValue.setText(String.valueOf(Color.alpha(color))); + } + redSeekBar.setProgress(Color.red(color)); + redValue.setText(String.valueOf(Color.red(color))); + greenSeekBar.setProgress(Color.green(color)); + greenValue.setText(String.valueOf(Color.green(color))); + blueSeekBar.setProgress(Color.blue(color)); + blueValue.setText(String.valueOf(Color.blue(color))); + } + + private void updateHexInput(int color) { + String hex = showAlphaSlider ? + String.format("#%08x", color) : + String.format("#%06x", (0xFFFFFF & color)); + hexInput.setText(hex); + } + + private void updatePreview(int color) { + colorPreview.setBackgroundColor(color); + } + + private int getColor() { + return Color.HSVToColor(currentAlpha, currentHsv); + } + + private int dpToPx(int dp) { + return (int) (dp * getContext().getResources().getDisplayMetrics().density + 0.5f); + } + + private static class SaturationValueView extends View { + private Paint satValPaint, strokePaint, selectorPaint; + private float hue, saturation, value = 1f; + private float selectorX, selectorY; + private OnSVChangedListener listener; + + interface OnSVChangedListener { + void onColorChanged(float sat, float val); + } + + public SaturationValueView(Context context) { + super(context); + satValPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setColor(Color.WHITE); + strokePaint.setStrokeWidth(dpToPx(2)); + selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + public void setListener(OnSVChangedListener listener) { + this.listener = listener; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateShaders(); + updateSelectorPosition(); + } + + @Override + protected void onDraw(Canvas canvas) { + canvas.drawRect(0, 0, getWidth(), getHeight(), satValPaint); + + selectorPaint.setColor(Color.HSVToColor(new float[]{hue, saturation, value})); + canvas.drawCircle(selectorX, selectorY, dpToPx(8), selectorPaint); + canvas.drawCircle(selectorX, selectorY, dpToPx(8), strokePaint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: + selectorX = Math.max(0, Math.min(getWidth(), event.getX())); + selectorY = Math.max(0, Math.min(getHeight(), event.getY())); + saturation = selectorX / getWidth(); + value = 1 - (selectorY / getHeight()); + if (listener != null) { + listener.onColorChanged(saturation, value); + } + invalidate(); + return true; + } + return super.onTouchEvent(event); + } + + public void setHue(float hue) { + this.hue = hue; + updateShaders(); + invalidate(); + } + + public void setSatVal(float sat, float val) { + this.saturation = sat; + this.value = val; + updateSelectorPosition(); + invalidate(); + } + + private void updateShaders() { + if (getWidth() <= 0 || getHeight() <= 0) return; + int pureColor = Color.HSVToColor(new float[]{hue, 1f, 1f}); + Shader saturationShader = new LinearGradient(0, 0, getWidth(), 0, Color.WHITE, pureColor, Shader.TileMode.CLAMP); + Shader valueShader = new LinearGradient(0, 0, 0, getHeight(), Color.TRANSPARENT, Color.BLACK, Shader.TileMode.CLAMP); + satValPaint.setShader(new ComposeShader(valueShader, saturationShader, PorterDuff.Mode.SRC_OVER)); + } + + private void updateSelectorPosition() { + if (getWidth() <= 0 || getHeight() <= 0) return; + selectorX = saturation * getWidth(); + selectorY = (1 - value) * getHeight(); + } + + private int dpToPx(int dp) { + return (int) (dp * getContext().getResources().getDisplayMetrics().density + 0.5f); + } + } + + private static class HueSlider extends View { + private Paint paint; + private Shader shader; + private float hue; + private float selectorY; + private OnHueChangedListener listener; + + interface OnHueChangedListener { + void onHueChanged(float hue); + } + + public HueSlider(Context context) { + super(context); + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + public void setListener(OnHueChangedListener listener) { + this.listener = listener; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + int[] hueColors = new int[]{Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.RED}; + shader = new LinearGradient(0, 0, 0, h, hueColors, null, Shader.TileMode.CLAMP); + updateSelectorPosition(); + } + + @Override + protected void onDraw(Canvas canvas) { + paint.setShader(shader); + canvas.drawRect(0, 0, getWidth(), getHeight(), paint); + + paint.setShader(null); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(dpToPx(2)); + paint.setColor(Color.WHITE); + + float selectorRadius = getWidth() / 2f * 0.8f; + paint.setStyle(Paint.Style.FILL); + paint.setColor(Color.HSVToColor(new float[]{hue, 1f, 1f})); + canvas.drawCircle(getWidth() / 2f, selectorY, selectorRadius, paint); + paint.setStyle(Paint.Style.STROKE); + paint.setColor(Color.WHITE); + canvas.drawCircle(getWidth() / 2f, selectorY, selectorRadius, paint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: + selectorY = Math.max(0, Math.min(getHeight(), event.getY())); + hue = (selectorY / getHeight()) * 360f; + if (hue >= 360f) hue = 359.9f; + if (listener != null) { + listener.onHueChanged(hue); + } + invalidate(); + return true; + } + return super.onTouchEvent(event); + } + + public void setHue(float hue) { + this.hue = hue; + updateSelectorPosition(); + invalidate(); + } + + private void updateSelectorPosition() { + if (getHeight() > 0) { + selectorY = (hue / 360f) * getHeight(); + } + } + + private int dpToPx(int dp) { + return (int) (dp * getContext().getResources().getDisplayMetrics().density + 0.5f); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/DeviceInfoCollector.java b/app/src/main/java/com/limelight/utils/DeviceInfoCollector.java new file mode 100644 index 0000000000..494e5ebb3a --- /dev/null +++ b/app/src/main/java/com/limelight/utils/DeviceInfoCollector.java @@ -0,0 +1,247 @@ +package com.limelight.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * 设备信息收集器 + * 负责收集设备的SOC、硬件等关键信息用于统计分析 + */ +public class DeviceInfoCollector { + private static final String TAG = "DeviceInfoCollector"; + + /** + * 收集设备基本信息 + */ + public static Map collectBasicDeviceInfo() { + Map deviceInfo = new HashMap<>(); + + try { + // 基本设备信息 + deviceInfo.put("manufacturer", Build.MANUFACTURER); + deviceInfo.put("brand", Build.BRAND); + deviceInfo.put("model", Build.MODEL); + deviceInfo.put("product", Build.PRODUCT); + deviceInfo.put("device", Build.DEVICE); + deviceInfo.put("board", Build.BOARD); + deviceInfo.put("hardware", Build.HARDWARE); + + // Android版本信息 + deviceInfo.put("android_version", Build.VERSION.RELEASE); + deviceInfo.put("api_level", String.valueOf(Build.VERSION.SDK_INT)); + + // CPU架构信息 + deviceInfo.put("cpu_abi", Build.CPU_ABI); + if (Build.CPU_ABI2 != null) { + deviceInfo.put("cpu_abi2", Build.CPU_ABI2); + } + + // SOC信息(Android S及以上) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.SOC_MANUFACTURER != null) { + deviceInfo.put("soc_manufacturer", Build.SOC_MANUFACTURER); + } + if (Build.SOC_MODEL != null) { + deviceInfo.put("soc_model", Build.SOC_MODEL); + } + deviceInfo.put("media_performance_class", String.valueOf(Build.VERSION.MEDIA_PERFORMANCE_CLASS)); + } + + // 屏幕信息 + deviceInfo.put("screen_density", String.valueOf(Build.VERSION.SDK_INT >= Build.VERSION_CODES.DONUT ? + android.content.res.Resources.getSystem().getDisplayMetrics().densityDpi : "unknown")); + + // 内存信息 + android.app.ActivityManager activityManager = (android.app.ActivityManager) + android.app.ActivityManager.class.cast(null); + if (activityManager != null) { + android.app.ActivityManager.MemoryInfo memoryInfo = new android.app.ActivityManager.MemoryInfo(); + activityManager.getMemoryInfo(memoryInfo); + deviceInfo.put("total_memory_mb", String.valueOf(memoryInfo.totalMem / (1024 * 1024))); + } + + } catch (Exception e) { + Log.w(TAG, "Failed to collect basic device info: " + e.getMessage()); + } + + return deviceInfo; + } + + /** + * 收集详细的CPU信息 + */ + public static Map collectCpuInfo() { + Map cpuInfo = new HashMap<>(); + + try { + // 从/proc/cpuinfo读取CPU详细信息 + BufferedReader reader = new BufferedReader(new FileReader("/proc/cpuinfo")); + String line; + int processorCount = 0; + String processorModel = null; + String processorVendor = null; + + while ((line = reader.readLine()) != null) { + if (line.startsWith("processor")) { + processorCount++; + } else if (line.startsWith("model name") && processorModel == null) { + processorModel = line.split(":")[1].trim(); + } else if (line.startsWith("Hardware") && processorVendor == null) { + processorVendor = line.split(":")[1].trim(); + } else if (line.startsWith("CPU architecture") && processorVendor == null) { + processorVendor = line.split(":")[1].trim(); + } + } + reader.close(); + + cpuInfo.put("processor_count", String.valueOf(processorCount)); + if (processorModel != null) { + cpuInfo.put("processor_model", processorModel); + } + if (processorVendor != null) { + cpuInfo.put("processor_vendor", processorVendor); + } + + } catch (IOException e) { + Log.w(TAG, "Failed to read CPU info: " + e.getMessage()); + } + + return cpuInfo; + } + + /** + * 收集GPU信息 + */ + public static Map collectGpuInfo() { + Map gpuInfo = new HashMap<>(); + + try { + // 尝试从系统属性获取GPU信息 + String gpuRenderer = getSystemProperty("ro.hardware.gpu"); + if (gpuRenderer != null) { + gpuInfo.put("gpu_renderer", gpuRenderer); + } + + // 检查是否为特定GPU厂商 + String hardware = Build.HARDWARE.toLowerCase(); + if (hardware.contains("adreno")) { + gpuInfo.put("gpu_type", "adreno"); + } else if (hardware.contains("mali")) { + gpuInfo.put("gpu_type", "mali"); + } else if (hardware.contains("powervr") || hardware.contains("sgx")) { + gpuInfo.put("gpu_type", "powervr"); + } else if (hardware.contains("tegra")) { + gpuInfo.put("gpu_type", "tegra"); + } else { + gpuInfo.put("gpu_type", "unknown"); + } + + } catch (Exception e) { + Log.w(TAG, "Failed to collect GPU info: " + e.getMessage()); + } + + return gpuInfo; + } + + /** + * 收集网络和连接信息 + */ + public static Map collectNetworkInfo(Context context) { + Map networkInfo = new HashMap<>(); + + try { + android.net.ConnectivityManager connectivityManager = + (android.net.ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + + if (connectivityManager != null) { + android.net.NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); + if (activeNetwork != null) { + networkInfo.put("network_type", activeNetwork.getTypeName()); + networkInfo.put("network_subtype", activeNetwork.getSubtypeName()); + networkInfo.put("is_connected", String.valueOf(activeNetwork.isConnected())); + } + } + + // WiFi信息 + android.net.wifi.WifiManager wifiManager = + (android.net.wifi.WifiManager) context.getSystemService(Context.WIFI_SERVICE); + if (wifiManager != null) { + networkInfo.put("wifi_enabled", String.valueOf(wifiManager.isWifiEnabled())); + } + + } catch (Exception e) { + Log.w(TAG, "Failed to collect network info: " + e.getMessage()); + } + + return networkInfo; + } + + /** + * 收集完整的设备信息 + */ + public static Map collectAllDeviceInfo(Context context) { + Map allInfo = new HashMap<>(); + + // 收集各类信息 + allInfo.putAll(collectBasicDeviceInfo()); + allInfo.putAll(collectCpuInfo()); + allInfo.putAll(collectGpuInfo()); + allInfo.putAll(collectNetworkInfo(context)); + + // 添加收集时间戳 + allInfo.put("collection_timestamp", String.valueOf(System.currentTimeMillis())); + + return allInfo; + } + + /** + * 获取系统属性 + */ + private static String getSystemProperty(String key) { + try { + Process process = Runtime.getRuntime().exec("getprop " + key); + BufferedReader reader = new BufferedReader(new java.io.InputStreamReader(process.getInputStream())); + String value = reader.readLine(); + reader.close(); + return value; + } catch (Exception e) { + return null; + } + } + + /** + * 生成设备指纹(用于匿名标识) + */ + public static String generateDeviceFingerprint() { + try { + StringBuilder fingerprint = new StringBuilder(); + fingerprint.append(Build.MANUFACTURER).append("_"); + fingerprint.append(Build.MODEL).append("_"); + fingerprint.append(Build.HARDWARE).append("_"); + fingerprint.append(Build.VERSION.SDK_INT); + + // 添加SOC信息(如果可用) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.SOC_MANUFACTURER != null) { + fingerprint.append("_").append(Build.SOC_MANUFACTURER); + } + if (Build.SOC_MODEL != null) { + fingerprint.append("_").append(Build.SOC_MODEL); + } + } + + return fingerprint.toString().replaceAll("[^a-zA-Z0-9_]", "_"); + } catch (Exception e) { + Log.w(TAG, "Failed to generate device fingerprint: " + e.getMessage()); + return "unknown_device"; + } + } +} diff --git a/app/src/main/java/com/limelight/utils/Dialog.java b/app/src/main/java/com/limelight/utils/Dialog.java index 7b3f9fd7dc..c4d727638c 100644 --- a/app/src/main/java/com/limelight/utils/Dialog.java +++ b/app/src/main/java/com/limelight/utils/Dialog.java @@ -5,6 +5,7 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; +import android.view.WindowManager; import android.widget.Button; import com.limelight.R; @@ -19,12 +20,24 @@ public class Dialog implements Runnable { private static final ArrayList rundownDialogs = new ArrayList<>(); + private boolean isDetailsDialog; + private Dialog(Activity activity, String title, String message, Runnable runOnDismiss) { this.activity = activity; this.title = title; this.message = message; this.runOnDismiss = runOnDismiss; + this.isDetailsDialog = false; + } + + private Dialog(Activity activity, String title, String message, Runnable runOnDismiss, boolean isDetailsDialog) + { + this.activity = activity; + this.title = title; + this.message = message; + this.runOnDismiss = runOnDismiss; + this.isDetailsDialog = isDetailsDialog; } public static void closeDialogs() @@ -52,6 +65,18 @@ public void run() { })); } + public static void displayDetailsDialog(final Activity activity, String title, String message, final boolean endAfterDismiss) + { + activity.runOnUiThread(new Dialog(activity, title, message, new Runnable() { + @Override + public void run() { + if (endAfterDismiss) { + activity.finish(); + } + } + }, true)); + } + public static void displayDialog(Activity activity, String title, String message, Runnable runOnDismiss) { activity.runOnUiThread(new Dialog(activity, title, message, runOnDismiss)); @@ -63,7 +88,15 @@ public void run() { if (activity.isFinishing()) return; - alert = new AlertDialog.Builder(activity).create(); + if (isDetailsDialog) { + createDetailsDialog(); + } else { + createStandardDialog(); + } + } + + private void createStandardDialog() { + alert = new AlertDialog.Builder(activity, R.style.AppDialogStyle).create(); alert.setTitle(title); alert.setMessage(message); @@ -108,6 +141,242 @@ public void onShow(DialogInterface dialog) { rundownDialogs.add(this); alert.show(); } + + // 设置对话框透明度 + if (alert.getWindow() != null) { + WindowManager.LayoutParams layoutParams = alert.getWindow().getAttributes(); + layoutParams.alpha = 0.8f; + // layoutParams.dimAmount = 0.3f; + alert.getWindow().setAttributes(layoutParams); + } + } + + private void createDetailsDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(activity, R.style.AppDialogStyle); + + // 使用自定义布局 + android.view.LayoutInflater inflater = activity.getLayoutInflater(); + android.view.View dialogView = inflater.inflate(R.layout.details_dialog, null); + + // 设置标题和内容 + android.widget.TextView titleView = dialogView.findViewById(R.id.detailsTitle); + android.widget.TextView contentView = dialogView.findViewById(R.id.detailsContent); + android.widget.ImageButton copyButton = dialogView.findViewById(R.id.copyButton); + + titleView.setText(title); + contentView.setText(formatDetailsMessage(message)); + + // 设置复制按钮点击事件 + copyButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + // 复制内容到剪贴板 + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) + activity.getSystemService(android.content.Context.CLIPBOARD_SERVICE); + android.content.ClipData clip = android.content.ClipData.newPlainText( + activity.getString(R.string.copy_details), + contentView.getText().toString() + ); + clipboard.setPrimaryClip(clip); + + // 显示复制成功提示 + android.widget.Toast.makeText(activity, activity.getString(R.string.copy_success), android.widget.Toast.LENGTH_SHORT).show(); + } + }); + + // 设置焦点管理和键盘导航 + copyButton.setFocusable(true); + copyButton.setFocusableInTouchMode(true); + contentView.setFocusable(true); + contentView.setFocusableInTouchMode(true); + + // 设置键盘导航监听器 + copyButton.setOnKeyListener(new android.view.View.OnKeyListener() { + @Override + public boolean onKey(android.view.View v, int keyCode, android.view.KeyEvent event) { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + switch (keyCode) { + case android.view.KeyEvent.KEYCODE_DPAD_CENTER: + case android.view.KeyEvent.KEYCODE_ENTER: + copyButton.performClick(); + return true; + case android.view.KeyEvent.KEYCODE_DPAD_DOWN: + // 向下导航到内容区域 + contentView.requestFocus(); + return true; + case android.view.KeyEvent.KEYCODE_DPAD_UP: + // 向上导航到标题区域 + titleView.requestFocus(); + return true; + case android.view.KeyEvent.KEYCODE_BACK: + case android.view.KeyEvent.KEYCODE_ESCAPE: + // 关闭对话框 + alert.dismiss(); + return true; + } + } + return false; + } + }); + + contentView.setOnKeyListener(new android.view.View.OnKeyListener() { + @Override + public boolean onKey(android.view.View v, int keyCode, android.view.KeyEvent event) { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + switch (keyCode) { + case android.view.KeyEvent.KEYCODE_DPAD_UP: + // 向上导航到复制按钮 + copyButton.requestFocus(); + return true; + case android.view.KeyEvent.KEYCODE_BACK: + case android.view.KeyEvent.KEYCODE_ESCAPE: + // 关闭对话框 + alert.dismiss(); + return true; + } + } + return false; + } + }); + + // 为标题区域也添加键盘导航支持 + titleView.setOnKeyListener(new android.view.View.OnKeyListener() { + @Override + public boolean onKey(android.view.View v, int keyCode, android.view.KeyEvent event) { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + switch (keyCode) { + case android.view.KeyEvent.KEYCODE_DPAD_DOWN: + // 向下导航到复制按钮 + copyButton.requestFocus(); + return true; + case android.view.KeyEvent.KEYCODE_BACK: + case android.view.KeyEvent.KEYCODE_ESCAPE: + // 关闭对话框 + alert.dismiss(); + return true; + } + } + return false; + } + }); + + builder.setView(dialogView); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + synchronized (rundownDialogs) { + rundownDialogs.remove(Dialog.this); + alert.dismiss(); + } + runOnDismiss.run(); + } + }); + + alert = builder.create(); + alert.setCancelable(false); + alert.setCanceledOnTouchOutside(false); + + synchronized (rundownDialogs) { + rundownDialogs.add(this); + alert.show(); + } + + // 设置对话框透明度 + if (alert.getWindow() != null) { + WindowManager.LayoutParams layoutParams = alert.getWindow().getAttributes(); + layoutParams.alpha = 0.8f; + // layoutParams.dimAmount = 0.3f; + alert.getWindow().setAttributes(layoutParams); + } + + // 设置初始焦点到复制按钮 + alert.setOnShowListener(new DialogInterface.OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + copyButton.requestFocus(); + } + }); + + // 为对话框设置键盘事件监听器 + alert.setOnKeyListener(new DialogInterface.OnKeyListener() { + @Override + public boolean onKey(DialogInterface dialog, int keyCode, android.view.KeyEvent event) { + if (event.getAction() == android.view.KeyEvent.ACTION_DOWN) { + switch (keyCode) { + case android.view.KeyEvent.KEYCODE_BACK: + case android.view.KeyEvent.KEYCODE_ESCAPE: + // 关闭对话框 + alert.dismiss(); + return true; + } + } + return false; + } + }); + } + + private String formatDetailsMessage(String message) { + String[] lines = message.split("\n"); + StringBuilder formatted = new StringBuilder(); + + for (String line : lines) { + if (line.trim().isEmpty()) { + formatted.append("\n"); + continue; + } + + if (line.contains(": ")) { + String[] parts = line.split(": ", 2); + if (parts.length == 2) { + String label = parts[0].trim(); + String value = parts[1].trim(); + + // 根据不同的标签使用不同的图标 + String icon = getIconForLabel(label); + formatted.append(icon).append(" ").append(label).append(": ").append(value).append("\n"); + } else { + formatted.append(line).append("\n"); + } + } else { + formatted.append(line).append("\n"); + } + } + + return formatted.toString(); + } + + private String getIconForLabel(String label) { + switch (label.toLowerCase()) { + case "name": + return "📱"; + case "state": + return "🔄"; + case "uuid": + return "🔑"; + case "id": + return "🆔"; + case "address": + case "local address": + case "remote address": + case "ipv6 address": + case "manual address": + case "active address": + return "🌐"; + case "mac address": + return "📡"; + case "pair state": + return "🔗"; + case "running game id": + return "🎮"; + case "https port": + return "🔒"; + case "hdr supported": + return "🎨"; + case "super cmds": + return "⚡"; + default: + return "🔹"; + } } } diff --git a/app/src/main/java/com/limelight/utils/EasyTierController.java b/app/src/main/java/com/limelight/utils/EasyTierController.java new file mode 100644 index 0000000000..3075174bba --- /dev/null +++ b/app/src/main/java/com/limelight/utils/EasyTierController.java @@ -0,0 +1,826 @@ +package com.limelight.utils; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import android.app.AlertDialog; + +import com.easytier.jni.EasyTierManager; +import com.limelight.LimeLog; +import com.limelight.R; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * EasyTier功能控制器 + * 集中管理EasyTier的所有功能:配置、状态、UI对话框、服务控制 + */ +public class EasyTierController { + private static final String TAG = "EasyTierController"; + private static final String EASYTIER_PREFS = "easytier_preferences"; + private static final String KEY_TOML_CONFIG = "toml_config_string"; + + private final Activity activity; + private final VpnPermissionCallback vpnCallback; + private EasyTierManager easyTierManager; + private AlertDialog currentDialog; + + public interface VpnPermissionCallback { + void requestVpnPermission(); + } + + public EasyTierController(Activity activity, VpnPermissionCallback callback) { + this.activity = activity; + this.vpnCallback = callback; + initEasyTierManager(); + } + + // ==================== 初始化和生命周期 ==================== + + private void initEasyTierManager() { + String config = getEasyTierConfig(); + String instanceName = "Default"; + + if (easyTierManager != null && easyTierManager.getLatestNetworkInfoJson() != null) { + easyTierManager.stop(); + } + LimeLog.info("使用的easytier配置为:\n" + config); + easyTierManager = new EasyTierManager(activity, instanceName, config); + LimeLog.info(TAG + ": EasyTierManager initialized with instance: " + instanceName); + } + + public void onDestroy() { + if (easyTierManager != null) { + easyTierManager.stop(); + } + if (currentDialog != null && currentDialog.isShowing()) { + currentDialog.dismiss(); + } + } + + // ==================== 主要公共方法 ==================== + + public void showControlDialog() { + if (easyTierManager == null) { + Toast.makeText(activity, "EasyTier Manager尚未初始化", Toast.LENGTH_SHORT).show(); + return; + } + + createAndShowDialog(); + } + + public void handleVpnPermissionResult(int resultCode) { + if (resultCode == Activity.RESULT_OK) { + LimeLog.info(TAG + ": VPN权限已获取,启动EasyTier Manager。"); + easyTierManager.start(); + Toast.makeText(activity, "EasyTier服务正在启动...", Toast.LENGTH_SHORT).show(); + } else { + LimeLog.warning(TAG + ": VPN权限被拒绝。"); + Toast.makeText(activity, "需要VPN权限才能启动服务。", Toast.LENGTH_LONG).show(); + } + } + + // ==================== 对话框管理 ==================== + + private void createAndShowDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + LayoutInflater inflater = LayoutInflater.from(activity); + View dialogView = inflater.inflate(R.layout.dialog_easytier_panel, null); + builder.setView(dialogView); + builder.setTitle("EasyTier 控制面板"); + + builder.setPositiveButton("启动/停止", null); + builder.setNeutralButton("保存配置", null); + builder.setNegativeButton("关闭", null); + + currentDialog = builder.create(); + currentDialog.setOnShowListener(dialogInterface -> { + setupDialogButtons(dialogView); + initializeTabs(dialogView); + loadConfigurationToUi(dialogView); + setupAdvancedFlags(dialogView); + refreshStatus(dialogView); + }); + + currentDialog.show(); + } + + private void setupDialogButtons(View dialogView) { + final Button positiveButton = currentDialog.getButton(AlertDialog.BUTTON_POSITIVE); + final Button neutralButton = currentDialog.getButton(AlertDialog.BUTTON_NEUTRAL); + + // 刷新按钮 + dialogView.findViewById(R.id.button_refresh_status).setOnClickListener(v -> { + refreshStatus(dialogView); + Toast.makeText(activity, "状态已刷新", Toast.LENGTH_SHORT).show(); + }); + + // 启动/停止按钮 + positiveButton.setOnClickListener(v -> { + if (easyTierManager.getLatestNetworkInfoJson() != null) { + Toast.makeText(activity, "Easytier服务已停止", Toast.LENGTH_SHORT).show(); + easyTierManager.stop(); + currentDialog.dismiss(); + } else { + saveConfigurationFromUi(dialogView, false); + vpnCallback.requestVpnPermission(); + currentDialog.dismiss(); + } + }); + + // 保存配置按钮 + neutralButton.setOnClickListener(v -> { + saveConfigurationFromUi(dialogView, true); + }); + } + + private void initializeTabs(View dialogView) { + Button tabStatusButton = dialogView.findViewById(R.id.tab_button_status); + Button tabConfigButton = dialogView.findViewById(R.id.tab_button_config); + ScrollView statusContent = dialogView.findViewById(R.id.tab_content_status); + ScrollView configContent = dialogView.findViewById(R.id.tab_content_config); + + tabStatusButton.setOnClickListener(v -> { + statusContent.setVisibility(View.VISIBLE); + configContent.setVisibility(View.GONE); + tabStatusButton.setEnabled(false); + tabConfigButton.setEnabled(true); + }); + + tabConfigButton.setOnClickListener(v -> { + statusContent.setVisibility(View.GONE); + configContent.setVisibility(View.VISIBLE); + tabStatusButton.setEnabled(true); + tabConfigButton.setEnabled(false); + }); + + // 默认选中状态页 + tabStatusButton.performClick(); + } + + private void setupAdvancedFlags(View dialogView) { + LinearLayout flagsContainer = dialogView.findViewById(R.id.advanced_flags_container); + ImageView flagsArrow = dialogView.findViewById(R.id.advanced_flags_arrow); + dialogView.findViewById(R.id.advanced_flags_header).setOnClickListener(v -> { + boolean isVisible = flagsContainer.getVisibility() == View.VISIBLE; + flagsContainer.setVisibility(isVisible ? View.GONE : View.VISIBLE); + flagsArrow.setRotation(isVisible ? 0 : 180); + }); + } + + // ==================== 配置管理 ==================== + + private String getEasyTierConfig() { + SharedPreferences prefs = activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE); + String defaultConfig = "instance_name = \"Default\"\n" + + "hostname = \"moonlight-V+\"\n" + + "ipv4 = \"10.0.0.1/24\"\n" + + "dhcp = false\n" + + "listeners = [\"tcp://0.0.0.0:11010\", \"udp://0.0.0.0:11010\", \"wg://0.0.0.0:11011\"]\n" + + "rpc_portal = \"0.0.0.0:0\"\n" + + "\n" + + "[network_identity]\n" + + "network_name = \"easytier\"\n" + + "network_secret = \"\"\n" + + "\n" + + "[[peer]]\n" + + "uri = \"tcp://public.easytier.top:11010\"\n" + + "\n" + + "[flags]\n"; + return prefs.getString(KEY_TOML_CONFIG, defaultConfig); + } + + private void loadConfigurationToUi(View dialogView) { + String currentTomlConfig = getEasyTierConfig(); + + EditText editNetworkName = dialogView.findViewById(R.id.edit_network_name); + EditText editNetworkSecret = dialogView.findViewById(R.id.edit_network_secret); + EditText editIpv4 = dialogView.findViewById(R.id.edit_ipv4); + EditText editListeners = dialogView.findViewById(R.id.edit_listeners); + EditText editPeers = dialogView.findViewById(R.id.edit_peers); + + Switch flagUseSmoltcp = dialogView.findViewById(R.id.flag_use_smoltcp); + Switch flagLatencyFirst = dialogView.findViewById(R.id.flag_latency_first); + Switch flagDisableP2p = dialogView.findViewById(R.id.flag_disable_p2p); + Switch flagPrivateMode = dialogView.findViewById(R.id.flag_private_mode); + Switch flagEnableIpv6 = dialogView.findViewById(R.id.flag_enable_ipv6); + Switch flagEnableKcpProxy = dialogView.findViewById(R.id.flag_enable_kcp_proxy); + Switch flagDisableKcpInput = dialogView.findViewById(R.id.flag_disable_kcp_input); + Switch flagEnableQuicProxy = dialogView.findViewById(R.id.flag_enable_quic_proxy); + Switch flagDisableQuicInput = dialogView.findViewById(R.id.flag_disable_quic_input); + Switch flagProxyForwardBySystem = dialogView.findViewById(R.id.flag_proxy_forward_by_system); + Switch flagEnableEncryption = dialogView.findViewById(R.id.flag_enable_encryption); + Switch flagDisableUdpHolePunching = dialogView.findViewById(R.id.flag_disable_udp_hole_punching); + Switch flagDisableSymHolePunching = dialogView.findViewById(R.id.flag_disable_sym_hole_punching); + + // 加载基本配置 + editNetworkName.setText(extractValue(currentTomlConfig, "network_name", "")); + editNetworkSecret.setText(extractValue(currentTomlConfig, "network_secret", "")); + + String ipv4Full = extractValue(currentTomlConfig, "ipv4", ""); + if (ipv4Full.contains("/")) { + editIpv4.setText(ipv4Full.split("/")[0]); + } else { + editIpv4.setText(ipv4Full); + } + + editListeners.setText(extractListAsString(currentTomlConfig, "listeners")); + editPeers.setText(extractListAsString(currentTomlConfig, "uri")); + + // 加载Flags + flagUseSmoltcp.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "use_smoltcp", "false"))); + flagLatencyFirst.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "latency_first", "false"))); + flagDisableP2p.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "disable_p2p", "false"))); + flagPrivateMode.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "private_mode", "false"))); + + boolean isIpv6Enabled = Boolean.parseBoolean(extractValue(currentTomlConfig, "enable_ipv6", "true")); + flagEnableIpv6.setChecked(!isIpv6Enabled); + + flagEnableKcpProxy.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "enable_kcp_proxy", "false"))); + flagDisableKcpInput.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "disable_kcp_input", "false"))); + flagEnableQuicProxy.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "enable_quic_proxy", "false"))); + flagDisableQuicInput.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "disable_quic_input", "false"))); + flagProxyForwardBySystem.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "proxy_forward_by_system", "false"))); + + boolean isEncryptionEnabled = Boolean.parseBoolean(extractValue(currentTomlConfig, "enable_encryption", "true")); + flagEnableEncryption.setChecked(!isEncryptionEnabled); + + flagDisableUdpHolePunching.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "disable_udp_hole_punching", "false"))); + flagDisableSymHolePunching.setChecked(Boolean.parseBoolean(extractValue(currentTomlConfig, "disable_sym_hole_punching", "false"))); + } + + private void saveConfigurationFromUi(View dialogView, boolean showToast) { + // 获取UI控件 + EditText editNetworkName = dialogView.findViewById(R.id.edit_network_name); + EditText editNetworkSecret = dialogView.findViewById(R.id.edit_network_secret); + EditText editIpv4 = dialogView.findViewById(R.id.edit_ipv4); + EditText editListeners = dialogView.findViewById(R.id.edit_listeners); + EditText editPeers = dialogView.findViewById(R.id.edit_peers); + + Switch flagUseSmoltcp = dialogView.findViewById(R.id.flag_use_smoltcp); + Switch flagLatencyFirst = dialogView.findViewById(R.id.flag_latency_first); + Switch flagDisableP2p = dialogView.findViewById(R.id.flag_disable_p2p); + Switch flagPrivateMode = dialogView.findViewById(R.id.flag_private_mode); + Switch flagEnableIpv6 = dialogView.findViewById(R.id.flag_enable_ipv6); + Switch flagEnableKcpProxy = dialogView.findViewById(R.id.flag_enable_kcp_proxy); + Switch flagDisableKcpInput = dialogView.findViewById(R.id.flag_disable_kcp_input); + Switch flagEnableQuicProxy = dialogView.findViewById(R.id.flag_enable_quic_proxy); + Switch flagDisableQuicInput = dialogView.findViewById(R.id.flag_disable_quic_input); + Switch flagProxyForwardBySystem = dialogView.findViewById(R.id.flag_proxy_forward_by_system); + Switch flagEnableEncryption = dialogView.findViewById(R.id.flag_enable_encryption); + Switch flagDisableUdpHolePunching = dialogView.findViewById(R.id.flag_disable_udp_hole_punching); + Switch flagDisableSymHolePunching = dialogView.findViewById(R.id.flag_disable_sym_hole_punching); + + // 构建新的TOML配置 + String newToml = buildTomlFromUi( + editNetworkName.getText().toString(), + editNetworkSecret.getText().toString(), + editIpv4.getText().toString(), + editListeners.getText().toString(), + editPeers.getText().toString(), + flagUseSmoltcp.isChecked(), + flagLatencyFirst.isChecked(), + flagDisableP2p.isChecked(), + flagPrivateMode.isChecked(), + flagEnableIpv6.isChecked(), + flagEnableKcpProxy.isChecked(), + flagDisableKcpInput.isChecked(), + flagEnableQuicProxy.isChecked(), + flagDisableQuicInput.isChecked(), + flagProxyForwardBySystem.isChecked(), + flagEnableEncryption.isChecked(), + flagDisableUdpHolePunching.isChecked(), + flagDisableSymHolePunching.isChecked() + ); + + // 保存配置 + activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_TOML_CONFIG, newToml) + .apply(); + + // 重新初始化 + initEasyTierManager(); + + // 刷新状态 + refreshStatus(dialogView); + + if (showToast) { + Toast.makeText(activity, "配置已保存,服务已根据新配置重新初始化。", Toast.LENGTH_LONG).show(); + } + } + + private String buildTomlFromUi( + String networkName, String networkSecret, String ipv4, String listeners, String peers, + boolean useSmoltcp, boolean latencyFirst, boolean disableP2p, boolean privateMode, boolean enableIpv6, + boolean enableKcpProxy, boolean disableKcpInput, boolean enableQuicProxy, boolean disableQuicInput, + boolean proxyForwardBySystem, boolean enableEncryption, boolean disableUdpHolePunching, boolean disableSymHolePunching) { + + StringBuilder sb = new StringBuilder(); + sb.append("hostname = \"moonlight-V+\"\n"); + sb.append("instance_name = \"Default\"\n"); + sb.append("dhcp = false\n"); + sb.append("ipv4 = \"").append(ipv4).append("/24\"\n"); + + // 构建listeners + if (!TextUtils.isEmpty(listeners)) { + String[] items = listeners.split("\n"); + List quotedItems = new ArrayList<>(); + for (String item : items) { + if (!item.trim().isEmpty()) quotedItems.add("\"" + item.trim() + "\""); + } + if (!quotedItems.isEmpty()) { + sb.append("listeners = [").append(TextUtils.join(", ", quotedItems)).append("]\n"); + } + } + + sb.append("rpc_portal = \"0.0.0.0:0\"\n"); + sb.append("\n[network_identity]\n"); + + if (!TextUtils.isEmpty(networkName)) { + sb.append("network_name = \"").append(networkName).append("\"\n"); + } + if (!TextUtils.isEmpty(networkSecret)) { + sb.append("network_secret = \"").append(networkSecret).append("\"\n"); + } + + // 构建peers + String[] peerItems = peers.split("\n"); + for (String peer : peerItems) { + if (!peer.trim().isEmpty()) { + sb.append("\n[[peer]]\n"); + sb.append("uri = \"").append(peer.trim()).append("\"\n"); + } + } + + // 构建[flags]部分 + sb.append("\n[flags]\n"); + appendFlagIfNotDefault(sb, "use_smoltcp", useSmoltcp, false); + appendFlagIfNotDefault(sb, "latency_first", latencyFirst, false); + appendFlagIfNotDefault(sb, "disable_p2p", disableP2p, false); + appendFlagIfNotDefault(sb, "private_mode", privateMode, false); + appendFlagIfNotDefault(sb, "enable_ipv6", !enableIpv6, true); + appendFlagIfNotDefault(sb, "enable_kcp_proxy", enableKcpProxy, false); + appendFlagIfNotDefault(sb, "disable_kcp_input", disableKcpInput, false); + appendFlagIfNotDefault(sb, "enable_quic_proxy", enableQuicProxy, false); + appendFlagIfNotDefault(sb, "disable_quic_input", disableQuicInput, false); + appendFlagIfNotDefault(sb, "proxy_forward_by_system", proxyForwardBySystem, false); + appendFlagIfNotDefault(sb, "enable_encryption", !enableEncryption, true); + appendFlagIfNotDefault(sb, "disable_udp_hole_punching", disableUdpHolePunching, false); + appendFlagIfNotDefault(sb, "disable_sym_hole_punching", disableSymHolePunching, false); + + return sb.toString(); + } + + // ==================== 状态管理 ==================== + + private void refreshStatus(View dialogView) { + String json = (easyTierManager != null) ? easyTierManager.getLatestNetworkInfoJson() : null; + LinearLayout statusContainer = dialogView.findViewById(R.id.panel_status_container); + updateStatusUi(statusContainer, json); + + Button positiveButton = currentDialog.getButton(AlertDialog.BUTTON_POSITIVE); + boolean isRunningNow = (json != null && !json.isEmpty()); + positiveButton.setText(isRunningNow ? "停止服务" : "启动服务"); + } + + private void updateStatusUi(LinearLayout container, String json) { + container.removeAllViews(); + + if (json == null || json.isEmpty()) { + TextView placeholder = new TextView(activity); + placeholder.setText("服务未运行或正在连接...\n请点击刷新按钮获取最新状态。"); + placeholder.setGravity(Gravity.CENTER); + int padding = (int) (40 * activity.getResources().getDisplayMetrics().density); + placeholder.setPadding(0, padding, 0, padding); + container.addView(placeholder); + return; + } + + EasyTierDisplayInfo displayInfo = parseNetworkInfoForDialog(json, "Default"); + + // 添加本机信息 + addSectionTitle(container, "本机信息"); + addStatusRow(container, "主机名:", displayInfo.hostname); + addStatusRow(container, "虚拟 IP:", displayInfo.virtualIp); + addStatusRow(container, "公网 IP:", displayInfo.publicIp); + addStatusRow(container, "NAT 类型:", displayInfo.natType); + + // 添加对等节点信息 + addSectionTitle(container, "对等节点 (" + displayInfo.finalPeerList.size() + ")"); + + if (displayInfo.finalPeerList.isEmpty()) { + TextView noPeersText = new TextView(activity); + noPeersText.setText("暂无其他节点"); + int padding = (int) (20 * activity.getResources().getDisplayMetrics().density); + noPeersText.setPadding(padding, padding / 2, 0, padding / 2); + container.addView(noPeersText); + } else { + LayoutInflater inflater = LayoutInflater.from(activity); + for (FinalPeerInfo peer : displayInfo.finalPeerList) { + View peerView = inflater.inflate(R.layout.dialog_peer_info_item, container, false); + + TextView hostname = peerView.findViewById(R.id.peer_hostname); + TextView virtualIp = peerView.findViewById(R.id.peer_value_virtual_ip); + TextView natType = peerView.findViewById(R.id.peer_value_nat_type); + TextView connectionLabel = peerView.findViewById(R.id.peer_label_connection); + TextView connectionValue = peerView.findViewById(R.id.peer_value_connection); + TextView latency = peerView.findViewById(R.id.peer_value_latency); + TextView traffic = peerView.findViewById(R.id.peer_value_traffic); + + // 填充主机名和警告 + String title = peer.hostname; + if (!peer.isInSameSubnet) { + title += " (网段不匹配!)"; + hostname.setTextColor(Color.RED); + } else if (!peer.isDirectConnection) { + title += " (中转)"; + } + hostname.setText(title); + + // 填充详细信息 + virtualIp.setText(peer.virtualIp != null ? peer.virtualIp : "N/A"); + natType.setText(peer.natType != null ? peer.natType : "N/A"); + latency.setText(peer.latency != null ? peer.latency : "N/A"); + traffic.setText(peer.traffic != null ? peer.traffic : "N/A"); + + String connLabelText = peer.isDirectConnection ? "物理地址:" : "下一跳节点:"; + connectionLabel.setText(connLabelText); + connectionValue.setText(peer.connectionDetails != null ? peer.connectionDetails : "N/A"); + + container.addView(peerView); + } + } + } + + private EasyTierDisplayInfo parseNetworkInfoForDialog(String jsonString, String instanceName) { + EasyTierDisplayInfo displayInfo = new EasyTierDisplayInfo(); + try { + JSONObject root = new JSONObject(jsonString); + JSONObject instance = root.getJSONObject("map").getJSONObject(instanceName); + + // 解析本机信息 + JSONObject myNode = instance.getJSONObject("my_node_info"); + String myIp = null; + int myPrefix = 0; + displayInfo.hostname = myNode.getString("hostname"); + displayInfo.version = myNode.getString("version"); + + JSONObject virtualIpv4 = myNode.optJSONObject("virtual_ipv4"); + if (virtualIpv4 != null) { + myPrefix = virtualIpv4.getInt("network_length"); + myIp = ipFromInt(virtualIpv4.getJSONObject("address").getInt("addr")); + displayInfo.virtualIp = myIp + "/" + myPrefix; + } else { + displayInfo.virtualIp = "获取中..."; + } + + JSONObject stunInfo = myNode.getJSONObject("stun_info"); + JSONArray publicIps = stunInfo.optJSONArray("public_ip"); + if (publicIps != null && publicIps.length() > 0) { + StringBuilder ipBuilder = new StringBuilder(); + for (int i = 0; i < publicIps.length(); i++) { + if (i > 0) ipBuilder.append("\n"); + ipBuilder.append(publicIps.getString(i)); + } + displayInfo.publicIp = ipBuilder.toString(); + } else { + displayInfo.publicIp = "N/A"; + } + + displayInfo.natType = parseNatType(stunInfo.getInt("udp_nat_type")); + + // 解析路由和对等连接 + Map routesMap = parseRoutesToJavaMap(instance.getJSONArray("routes")); + Map peersMap = parsePeersToJavaMap(instance.getJSONArray("peers")); + + List finalPeerList = new ArrayList<>(); + for (RouteData route : routesMap.values()) { + boolean inSameSubnet = true; + if (myIp != null && myPrefix > 0 && !route.virtualIp.equals("无")) { + inSameSubnet = isInSameSubnet(myIp, route.virtualIp, myPrefix); + } + + PeerConnectionData peerConn = peersMap.get(route.peerId); + + if (peerConn != null) { + // 直接连接 + finalPeerList.add(new FinalPeerInfo( + route.hostname, + route.virtualIp, + true, + inSameSubnet, + peerConn.physicalAddr, + (peerConn.latencyUs / 1000) + " ms", + formatBytes(peerConn.rxBytes) + " / " + formatBytes(peerConn.txBytes), + route.version, + route.natType, + route.cost, + route.nextHopPeerId, + route.peerId, + route.instId + )); + } else { + // 中继路由 + RouteData nextHop = routesMap.get(route.nextHopPeerId); + String nextHopHostname = (nextHop != null) ? nextHop.hostname : "未知"; + finalPeerList.add(new FinalPeerInfo( + route.hostname, + route.virtualIp, + false, + inSameSubnet, + "通过 " + nextHopHostname, + route.pathLatency + " ms (路径)", + "N/A", + route.version, + route.natType, + route.cost, + route.nextHopPeerId, + route.peerId, + route.instId + )); + } + } + + finalPeerList.sort(Comparator.comparing(p -> p.hostname)); + displayInfo.finalPeerList = finalPeerList; + + } catch (Exception e) { + LimeLog.warning("解析JSON失败:" + e); + displayInfo.hostname = "解析错误"; + displayInfo.version = e.getMessage(); + } + return displayInfo; + } + + // ==================== UI辅助方法 ==================== + + private void addStatusRow(LinearLayout parent, String label, String value) { + LinearLayout rowLayout = new LinearLayout(activity); + rowLayout.setOrientation(LinearLayout.HORIZONTAL); + + int padding = (int) (8 * activity.getResources().getDisplayMetrics().density); + rowLayout.setPadding(0, padding, 0, padding); + + TextView labelView = new TextView(activity); + labelView.setText(label); + labelView.setTypeface(null, android.graphics.Typeface.BOLD); + + LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams( + (int) (120 * activity.getResources().getDisplayMetrics().density), // 120dp + LinearLayout.LayoutParams.WRAP_CONTENT + ); + labelView.setLayoutParams(labelParams); + + TextView valueView = new TextView(activity); + valueView.setText(value != null ? value : "N/A"); + valueView.setTextIsSelectable(true); + + rowLayout.addView(labelView); + rowLayout.addView(valueView); + parent.addView(rowLayout); + } + + private void addSectionTitle(LinearLayout parent, String title) { + TextView titleView = new TextView(activity); + titleView.setText(title); + titleView.setTextSize(16f); + titleView.setTypeface(null, android.graphics.Typeface.BOLD); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + + float density = activity.getResources().getDisplayMetrics().density; + params.setMargins(0, (int) (16 * density), 0, (int) (8 * density)); + titleView.setLayoutParams(params); + + parent.addView(titleView); + } + + // ==================== 工具方法 ==================== + + private String extractValue(String toml, String key, String defaultValue) { + for (String line : toml.split("\n")) { + line = line.trim(); + if (line.startsWith(key + " =")) { + try { + return line.split("=", 2)[1].trim().replace("\"", ""); + } catch (Exception e) { /* ignore */ } + } + } + return defaultValue; + } + + private String extractListAsString(String toml, String key) { + if ("uri".equals(key)) { + StringBuilder peers = new StringBuilder(); + for (String line : toml.split("\n")) { + line = line.trim(); + if (line.startsWith("uri =")) { + if (peers.length() > 0) peers.append("\n"); + peers.append(line.split("=", 2)[1].trim().replace("\"", "")); + } + } + return peers.toString(); + } + for (String line : toml.split("\n")) { + line = line.trim(); + if (line.startsWith(key + " =")) { + try { + String list = line.substring(line.indexOf('[') + 1, line.lastIndexOf(']')); + return list.replace("\"", "").replace(", ", "\n"); + } catch (Exception e) { /* ignore */ } + } + } + return ""; + } + + private void appendFlagIfNotDefault(StringBuilder sb, String key, boolean value, boolean defaultValue) { + if (value != defaultValue) { + sb.append(key).append(" = ").append(value).append("\n"); + } + } + + private String ipFromInt(int addr) { + return ((addr >>> 24) & 0xFF) + "." + ((addr >>> 16) & 0xFF) + "." + ((addr >>> 8) & 0xFF) + "." + (addr & 0xFF); + } + + private String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(1024)); + char pre = "KMGTPE".charAt(exp - 1); + return String.format(java.util.Locale.US, "%.1f %sB", bytes / Math.pow(1024, exp), pre); + } + + private String parseNatType(int typeCode) { + switch (typeCode) { + case 0: return "Unknown (未知类型)"; + case 1: return "Open Internet (开放互联网)"; + case 2: return "No PAT (无端口转换)"; + case 3: return "Full Cone (完全锥形)"; + case 4: return "Restricted Cone (限制锥形)"; + case 5: return "Port Restricted (端口限制锥形)"; + case 6: return "Symmetric (对称型)"; + case 7: return "Symmetric UDP Firewall (对称UDP防火墙)"; + case 8: return "Symmetric Easy Inc (对称型-端口递增)"; + case 9: return "Symmetric Easy Dec (对称型-端口递减)"; + default: return "Other Type (" + typeCode + ")"; + } + } + + private boolean isInSameSubnet(String ip1, String ip2, int prefix) { + try { + int ip1Int = ipToInt(ip1); + int ip2Int = ipToInt(ip2); + int mask = -1 << (32 - prefix); + int network1 = ip1Int & mask; + int network2 = ip2Int & mask; + return network1 == network2; + } catch (Exception e) { + LimeLog.warning("未能检查子网的IP:" + ip1 + ", " + ip2 + e); + return false; + } + } + + private int ipToInt(String ip) { + String[] parts = ip.split("\\."); + return (Integer.parseInt(parts[0]) << 24) | + (Integer.parseInt(parts[1]) << 16) | + (Integer.parseInt(parts[2]) << 8) | + (Integer.parseInt(parts[3])); + } + + private Map parseRoutesToJavaMap(JSONArray routesJson) throws Exception { + Map map = new HashMap<>(); + for (int i = 0; i < routesJson.length(); i++) { + JSONObject route = routesJson.getJSONObject(i); + long peerId = route.getLong("peer_id"); + JSONObject ipv4AddrJson = route.optJSONObject("ipv4_addr"); + String virtualIp = (ipv4AddrJson != null) ? ipFromInt(ipv4AddrJson.getJSONObject("address").getInt("addr")) : "无"; + + map.put(peerId, new RouteData( + peerId, + route.getString("hostname"), + virtualIp, + route.getLong("next_hop_peer_id"), + route.getInt("path_latency"), + route.getInt("cost"), + route.getString("version"), + parseNatType(route.getJSONObject("stun_info").getInt("udp_nat_type")), + route.getString("inst_id") + )); + } + return map; + } + + private Map parsePeersToJavaMap(JSONArray peersJson) throws Exception { + Map map = new HashMap<>(); + for (int i = 0; i < peersJson.length(); i++) { + JSONObject peer = peersJson.getJSONObject(i); + JSONArray conns = peer.getJSONArray("conns"); + if (conns.length() > 0) { + JSONObject conn = conns.getJSONObject(0); + long peerId = conn.getLong("peer_id"); + map.put(peerId, new PeerConnectionData( + peerId, + conn.getJSONObject("tunnel").getJSONObject("remote_addr").getString("url"), + conn.getJSONObject("stats").getLong("latency_us"), + conn.getJSONObject("stats").getLong("rx_bytes"), + conn.getJSONObject("stats").getLong("tx_bytes") + )); + } + } + return map; + } + + // ==================== 内部数据类 ==================== + + private static class EasyTierDisplayInfo { + String hostname; + String version; + String virtualIp; + String publicIp; + String natType; + List finalPeerList = new ArrayList<>(); + } + + private static class FinalPeerInfo { + final String hostname, virtualIp, connectionDetails, latency, traffic, version, natType, instId; + final boolean isDirectConnection; + final boolean isInSameSubnet; + final int routeCost; + final long nextHopPeerId, peerId; + + FinalPeerInfo(String hostname, String virtualIp, boolean isDirectConnection, boolean isInSameSubnet, + String connectionDetails, String latency, String traffic, String version, String natType, + int routeCost, long nextHopPeerId, long peerId, String instId) { + this.hostname = hostname; + this.virtualIp = virtualIp; + this.isDirectConnection = isDirectConnection; + this.isInSameSubnet = isInSameSubnet; + this.connectionDetails = connectionDetails; + this.latency = latency; + this.traffic = traffic; + this.version = version; + this.natType = natType; + this.routeCost = routeCost; + this.nextHopPeerId = nextHopPeerId; + this.peerId = peerId; + this.instId = instId; + } + } + + private static class RouteData { + final long peerId, nextHopPeerId; + final String hostname, virtualIp, version, natType, instId; + final int pathLatency, cost; + + RouteData(long peerId, String hostname, String virtualIp, long nextHopPeerId, int pathLatency, int cost, String version, String natType, String instId) { + this.peerId = peerId; + this.hostname = hostname; + this.virtualIp = virtualIp; + this.nextHopPeerId = nextHopPeerId; + this.pathLatency = pathLatency; + this.cost = cost; + this.version = version; + this.natType = natType; + this.instId = instId; + } + } + + private static class PeerConnectionData { + final long peerId, latencyUs, rxBytes, txBytes; + final String physicalAddr; + + PeerConnectionData(long peerId, String physicalAddr, long latencyUs, long rxBytes, long txBytes) { + this.peerId = peerId; + this.physicalAddr = physicalAddr; + this.latencyUs = latencyUs; + this.rxBytes = rxBytes; + this.txBytes = txBytes; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/FullscreenProgressOverlay.java b/app/src/main/java/com/limelight/utils/FullscreenProgressOverlay.java new file mode 100644 index 0000000000..80b42a1820 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/FullscreenProgressOverlay.java @@ -0,0 +1,231 @@ +package com.limelight.utils; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.util.Random; + +public class FullscreenProgressOverlay { + private final Activity activity; + private final View overlayView; + private final TextView statusText; + private final TextView progressText; + private final TextView randomTip; + private final ImageView appPosterBackground; + private final ProgressBar progressBar; + private final ViewGroup rootView; + private final String[] tips; + private final Random random; + private boolean isShowing = false; + private ComputerDetails computer; + private NvApp app; + + public FullscreenProgressOverlay(Activity activity, NvApp app) { + this.activity = activity; + this.app = app; + + this.random = new Random(); + + // 初始化提示数组 + this.tips = new String[]{ + activity.getString(R.string.tip_esc_exit), + activity.getString(R.string.tip_double_tap_mouse), + activity.getString(R.string.tip_long_press_controller), + activity.getString(R.string.tip_volume_keys), + activity.getString(R.string.tip_wallpaper_change), + activity.getString(R.string.tip_5ghz_wifi), + activity.getString(R.string.tip_close_apps), + activity.getString(R.string.tip_home_saves), + activity.getString(R.string.tip_hdr_colors), + activity.getString(R.string.tip_touch_modes), + activity.getString(R.string.tip_custom_keys), + activity.getString(R.string.tip_performance_overlay), + activity.getString(R.string.tip_audio_config), + activity.getString(R.string.tip_external_display), + activity.getString(R.string.tip_virtual_display), + activity.getString(R.string.tip_dynamic_bitrate), + activity.getString(R.string.tip_cards_show) + }; + + // 获取根视图 + rootView = (ViewGroup) activity.findViewById(android.R.id.content); + + // 创建覆盖层视图 + LayoutInflater inflater = LayoutInflater.from(activity); + overlayView = inflater.inflate(R.layout.fullscreen_progress_overlay, rootView, false); + + // 初始化视图组件 + statusText = overlayView.findViewById(R.id.statusText); + progressText = overlayView.findViewById(R.id.progressText); + randomTip = overlayView.findViewById(R.id.randomTip); + appPosterBackground = overlayView.findViewById(R.id.appPosterBackground); + progressBar = overlayView.findViewById(R.id.progressBar); + + // 设置初始状态 + overlayView.setVisibility(View.GONE); + } + + public void show(String title, String message) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (!isShowing) { + // 设置状态文字 + statusText.setText(title); + progressText.setText(message); + + // 设置随机提示 + String tip = tips[random.nextInt(tips.length)]; + randomTip.setText(tip); + + // 添加到根视图 + if (overlayView.getParent() == null) { + rootView.addView(overlayView); + } + + overlayView.setVisibility(View.VISIBLE); + isShowing = true; + + loadAppImage(); + } + }); + } + + public void setMessage(String message) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (isShowing) { + progressText.setText(message); + } + }); + } + + public void setStatus(String status) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (isShowing) { + statusText.setText(status); + } + }); + } + + public void setAppPoster(Bitmap poster) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (poster != null) { + appPosterBackground.setImageBitmap(poster); + } else { + // 设置默认背景 + appPosterBackground.setImageResource(R.drawable.no_app_image); + } + }); + } + + public void setAppPoster(Drawable poster) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (poster != null) { + appPosterBackground.setImageDrawable(poster); + } else { + // 设置默认背景 + appPosterBackground.setImageResource(R.drawable.no_app_image); + } + }); + } + + public void setProgress(int progress) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (isShowing) { + progressBar.setIndeterminate(false); + progressBar.setProgress(progress); + } + }); + } + + public void setIndeterminate(boolean indeterminate) { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (isShowing) { + progressBar.setIndeterminate(indeterminate); + } + }); + } + + public void dismiss() { + if (activity.isFinishing()) { + return; + } + + activity.runOnUiThread(() -> { + if (isShowing) { + overlayView.setVisibility(View.GONE); + if (overlayView.getParent() != null) { + rootView.removeView(overlayView); + } + isShowing = false; + } + }); + } + + public boolean isShowing() { + return isShowing; + } + + /** + * 设置computer信息 + */ + public void setComputer(ComputerDetails computer) { + this.computer = computer; + } + + + + private void loadAppImage() { + if (app != null) { + // 从全局缓存获取app icon + Bitmap appIcon = AppIconCache.getInstance().getIcon(computer, app); + + if (appIcon != null) { + appPosterBackground.setVisibility(View.VISIBLE); + appPosterBackground.setImageBitmap(appIcon); + } + } + } + + +} diff --git a/app/src/main/java/com/limelight/utils/Iperf3Tester.java b/app/src/main/java/com/limelight/utils/Iperf3Tester.java new file mode 100644 index 0000000000..c81c0c9f4b --- /dev/null +++ b/app/src/main/java/com/limelight/utils/Iperf3Tester.java @@ -0,0 +1,411 @@ +package com.limelight.utils; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.R; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A utility class to manage and run an iperf3 network test with a custom UI. + * This class loads the iperf3 binary from the app's native library directory + * to comply with modern Android security policies (SELinux). + * It includes features like command injection prevention, output translation, + * auto-scrolling, and a final summary of results. + */ +public class Iperf3Tester { + + private final Context context; + private final String defaultServerAddress; + private volatile Process iperfProcess; + + // A whitelist of allowed iperf3 arguments for security reasons + private static final Set ALLOWED_IPERF_ARGS = new HashSet<>(Arrays.asList( + "-f", "--format", + "-i", "--interval", + "-J", "--json", + "-P", "--parallel", + "-w", "--window", + "-M", "--set-mss", + "-N", "--no-delay", + "-V", "--version", + "-l", "--len", + "-Z", "--zerocopy", + "-O", "--omit", + "-T", "--title", + "-C", "--congestion", + "-k", "--blockcount" + )); + + // UI elements + private EditText serverInput, portEditText, udpBandwidthEditText, rawArgsInput; + private RadioGroup directionRadioGroup; + private RadioButton downloadRadioButton; + private SeekBar durationSeekBar; + private TextView durationValueTextView, outputView; + private CheckBox udpCheckBox; + private LinearLayout udpBandwidthLayout; + private ScrollView outputScrollView; + + private final List allOutputLines = new ArrayList<>(); + + public Iperf3Tester(Context context, String defaultServerAddress) { + this.context = context; + this.defaultServerAddress = defaultServerAddress; + } + + public void show() { + AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.Iperf3DialogTheme); + builder.setTitle("iPerf3 Network Test"); + + LayoutInflater inflater = LayoutInflater.from(context); + View dialogView = inflater.inflate(R.layout.dialog_iperf3_test, null); + builder.setView(dialogView); + + initializeUiElements(dialogView); + setupUiListeners(); + + if (!TextUtils.isEmpty(defaultServerAddress)) { + serverInput.setText(defaultServerAddress); + } + + builder.setPositiveButton("Start", null); + builder.setNegativeButton("Stop", null); + builder.setNeutralButton("Close", (dialog, which) -> dialog.dismiss()); + + AlertDialog dialog = builder.create(); + dialog.setOnDismissListener(dialogInterface -> killProcess()); + dialog.setOnShowListener(dialogInterface -> { + Button startButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + Button stopButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); + stopButton.setEnabled(false); + + startButton.setOnClickListener(v -> executeTest(startButton, stopButton)); + stopButton.setOnClickListener(v -> killProcess()); + }); + + dialog.show(); + } + + private void initializeUiElements(View view) { + serverInput = view.findViewById(R.id.edit_text_server_ip); + directionRadioGroup = view.findViewById(R.id.radio_group_direction); + downloadRadioButton = view.findViewById(R.id.radio_download); + durationSeekBar = view.findViewById(R.id.seek_bar_duration); + durationValueTextView = view.findViewById(R.id.text_view_duration_value); + portEditText = view.findViewById(R.id.edit_text_port); + udpCheckBox = view.findViewById(R.id.checkbox_udp); + udpBandwidthLayout = view.findViewById(R.id.layout_udp_bandwidth); + udpBandwidthEditText = view.findViewById(R.id.edit_text_udp_bandwidth); + rawArgsInput = view.findViewById(R.id.edit_text_raw_args); + outputScrollView = view.findViewById(R.id.output_scroll_view); + outputView = view.findViewById(R.id.text_view_iperf3_output); + } + + private void setupUiListeners() { + durationSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + durationValueTextView.setText(String.format("%ds", Math.max(1, progress))); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + durationValueTextView.setText(String.format("%ds", durationSeekBar.getProgress())); + + udpCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> + udpBandwidthLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE)); + } + + private void executeTest(Button startButton, Button stopButton) { + if (!validateInputs()) return; + + killProcess(); + allOutputLines.clear(); + outputView.setText(""); + + setUiState(false, startButton, stopButton); + + new Thread(() -> { + try { + String iperfPath = getIperfPath(); + ArrayList command = buildCommand(iperfPath); + + appendOutput("正在执行: " + TextUtils.join(" ", command) + "\n\n"); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + iperfProcess = pb.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(iperfProcess.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + allOutputLines.add(line); + appendOutput(translateIperfOutput(line) + "\n"); + } + } + + int exitCode = iperfProcess.waitFor(); + appendOutput("\n--- 测试完成 (退出码: " + exitCode + ") ---\n"); + parseAndDisplaySummary(); + + } catch (IOException e) { + e.printStackTrace(); + appendOutput("\n错误: " + e.getMessage() + "\n请确保 iPerf3 服务器已在PC上运行,并且防火墙已正确配置。\n"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + appendOutput("\n--- 测试已中断 ---\n"); + } finally { + iperfProcess = null; + setUiState(true, startButton, stopButton); + } + }).start(); + } + + private boolean validateInputs() { + String serverIp = serverInput.getText().toString().trim(); + if (serverIp.isEmpty() || serverIp.matches(".*[;&|`<>\\$\\(\\)].*")) { + Toast.makeText(context, "无效的服务器地址", Toast.LENGTH_SHORT).show(); + return false; + } + String portStr = portEditText.getText().toString().trim(); + if (!portStr.matches("\\d+") || Integer.parseInt(portStr) > 65535) { + Toast.makeText(context, "无效的端口号 (1-65535)", Toast.LENGTH_SHORT).show(); + return false; + } + return true; + } + + private String getIperfPath() throws IOException { + String nativeLibraryDir = context.getApplicationInfo().nativeLibraryDir; + String iperfPath = nativeLibraryDir + "/libiperf3.so"; + File iperfFile = new File(iperfPath); + if (!iperfFile.exists()) { + throw new IOException("iPerf3 二进制文件未在原生库目录中找到。请确保 'libiperf3.so' 位于 'jniLibs/arm64-v8a' 文件夹中。"); + } + return iperfPath; + } + + private ArrayList buildCommand(String iperfPath) { + ArrayList command = new ArrayList<>(); + command.add(iperfPath); + command.add("-c"); + command.add(serverInput.getText().toString().trim()); + command.add("-p"); + command.add(portEditText.getText().toString().trim()); + + if (downloadRadioButton.isChecked()) command.add("-R"); + + command.add("-t"); + command.add(String.valueOf(durationSeekBar.getProgress())); + + if (udpCheckBox.isChecked()) { + command.add("-u"); + String bandwidth = udpBandwidthEditText.getText().toString().trim(); + if (!bandwidth.isEmpty()) { + command.add("-b"); + command.add(bandwidth); + } else { + appendOutput("警告: UDP 测试需要指定带宽, 使用默认值 1M。\n"); + command.add("-b"); + command.add("1M"); + } + } + + // Sanitize and add raw arguments + String rawArgs = rawArgsInput.getText().toString().trim(); + if (!rawArgs.isEmpty()) { + Set managedArgs = new HashSet<>(Arrays.asList("-p", "--port", "-R", "--reverse", "-t", "--time", "-u", "--udp", "-b", "--bandwidth")); + List rawArgsList = Arrays.asList(rawArgs.split("\\s+")); + List filteredArgs = new ArrayList<>(); + List ignoredArgs = new ArrayList<>(); + + for (int i = 0; i < rawArgsList.size(); i++) { + String currentArg = rawArgsList.get(i); + if (managedArgs.contains(currentArg.toLowerCase())) { + ignoredArgs.add(currentArg); + if (i + 1 < rawArgsList.size() && !rawArgsList.get(i + 1).startsWith("-")) { + ignoredArgs.add(rawArgsList.get(i + 1)); + i++; + } + } else if (ALLOWED_IPERF_ARGS.contains(currentArg.toLowerCase())) { + filteredArgs.add(currentArg); + } else { + ignoredArgs.add(currentArg); // Not in whitelist + } + } + + if (!filteredArgs.isEmpty()) command.addAll(filteredArgs); + if (!ignoredArgs.isEmpty()) { + appendOutput("提示: 为避免冲突或安全风险,已自动忽略以下参数: " + TextUtils.join(" ", ignoredArgs) + "\n"); + } + } + return command; + } + + private void parseAndDisplaySummary() { + // 根据用户在UI上的选择,直接确定测试方向的中文名称。 + final String direction = downloadRadioButton.isChecked() ? "下载" : "上传"; + + // TCP 总结行正则表达式 + Pattern tcpPattern = Pattern.compile( + "\\[\\s*\\d+\\s*\\]\\s+[\\d.-]+\\s+sec\\s+.*\\s+([\\d.]+)\\s+(Mbits/sec|Gbits/sec|Kbits/sec|bits/sec)" + ); + + // UDP 总结行正则表达式 + Pattern udpPattern = Pattern.compile( + "\\[\\s*\\d+\\s*\\]\\s+[\\d.-]+\\s+sec\\s+.*\\s+" + // Interval and Transfer + "([\\d.]+)\\s+(Mbits/sec|Gbits/sec|Kbits/sec|bits/sec)\\s+" + // Bitrate + "([\\d.]+)\\s+(ms)\\s+" + // Jitter + "(\\d+)\\s*/\\s*(\\d+)\\s+\\(([\\d.]+)%\\)" // Lost/Total Datagrams + ); + + String summaryResult = ""; + boolean foundSummary = false; + + // 从后往前遍历所有原始输出行 + for (int i = allOutputLines.size() - 1; i >= 0; i--) { + if (foundSummary) break; + + String line = allOutputLines.get(i); + // 我们只需要找到包含 "sec" 和 "Mbits/sec" (或类似单位) 的总结行即可 + if (!line.contains(" sec ") || !line.contains("bits/sec")) { + continue; + } + + if (udpCheckBox.isChecked()) { + // --- UDP 测试结果解析 --- + Matcher udpMatcher = udpPattern.matcher(line); + if (udpMatcher.find()) { + String bitrateValue = udpMatcher.group(1); + String bitrateUnit = udpMatcher.group(2); + String jitterValue = udpMatcher.group(3); + String lostPackets = udpMatcher.group(5); + String totalPackets = udpMatcher.group(6); + String lossPercent = udpMatcher.group(7); + + summaryResult = String.format( + "\n========================================\n" + + " 测试结果总结\n" + + "----------------------------------------\n" + + " 平均%s带宽: %s %s\n" + + " 抖动: %s ms\n" + + " 丢包: %s / %s (%s%%)\n" + + "========================================\n", + direction, bitrateValue, bitrateUnit, jitterValue, lostPackets, totalPackets, lossPercent + ); + foundSummary = true; + } + + } else { + // --- TCP 测试结果解析 --- + Matcher tcpMatcher = tcpPattern.matcher(line); + if (tcpMatcher.find()) { + String bitrateValue = tcpMatcher.group(1); + String bitrateUnit = tcpMatcher.group(2); + + summaryResult = String.format( + "\n========================================\n" + + " 测试结果总结\n" + + "----------------------------------------\n" + + " 平均%s带宽: %s %s\n" + + "========================================\n", + direction, bitrateValue, bitrateUnit + ); + foundSummary = true; + } + } + } + + if (!summaryResult.isEmpty()) { + appendOutput(summaryResult); + } else { + appendOutput("\n未能自动解析最终结果,请查看以上详细日志。\n"); + } + } + + private String translateIperfOutput(String originalLine) { + if (originalLine.contains("Connecting to host")) + return originalLine.replace("Connecting to host", "正在连接到主机"); + if (originalLine.contains("local") && originalLine.contains("port")) + return originalLine.replace("local", "本地").replace("port", "端口"); + if (originalLine.contains("Interval")) + return originalLine.replace("Interval", "时间段").replace("Transfer", "传输量").replace("Bitrate", "比特率").replace("Bandwidth", "带宽").replace("Retr", "重传").replace("Jitter", "抖动").replace("Lost/Total", "丢包/总计").replace("Datagrams", "数据报"); + if (originalLine.contains("sender")) return originalLine.replace("sender", "发送方"); + if (originalLine.contains("receiver")) return originalLine.replace("receiver", "接收方"); + if (originalLine.contains("iperf Done.")) return "iPerf3 测试完毕。"; + if (originalLine.contains("unable to connect to server")) + return "错误:无法连接到服务器。请检查IP地址和端口是否正确,以及服务器是否正在运行。"; + if (originalLine.contains("interrupt - the client has terminated")) + return "中断 - 客户端已终止。"; + return originalLine; + } + + private void killProcess() { + if (iperfProcess != null) { + iperfProcess.destroy(); + iperfProcess = null; + } + } + + private void setUiState(boolean isEnabled, Button startButton, Button stopButton) { + runOnUiThread(() -> { + startButton.setEnabled(isEnabled); + stopButton.setEnabled(!isEnabled); + serverInput.setEnabled(isEnabled); + directionRadioGroup.setEnabled(isEnabled); + for (int i = 0; i < directionRadioGroup.getChildCount(); i++) { + directionRadioGroup.getChildAt(i).setEnabled(isEnabled); + } + durationSeekBar.setEnabled(isEnabled); + portEditText.setEnabled(isEnabled); + udpCheckBox.setEnabled(isEnabled); + udpBandwidthEditText.setEnabled(isEnabled); + rawArgsInput.setEnabled(isEnabled); + }); + } + + private void appendOutput(final String text) { + runOnUiThread(() -> { + outputView.append(text); + outputScrollView.post(() -> outputScrollView.fullScroll(View.FOCUS_DOWN)); + }); + } + + private void runOnUiThread(Runnable action) { + if (context instanceof Activity) { + ((Activity) context).runOnUiThread(action); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/KeyCodeMapper.java b/app/src/main/java/com/limelight/utils/KeyCodeMapper.java new file mode 100644 index 0000000000..dcf49d8993 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/KeyCodeMapper.java @@ -0,0 +1,111 @@ +package com.limelight.utils; + +import android.view.KeyEvent; +import java.util.HashMap; +import java.util.Map; + +/** + * 翻译核心:将 Android KeyCode 映射到 Windows Virtual-Key Code 和显示名称。 + */ +public class KeyCodeMapper { + + // Map: Android KeyCode (Integer) -> Windows Virtual-Key Code (String) + private static final Map windowsKeyMap = new HashMap<>(); + + // Map: Android KeyCode (Integer) -> Display Name (String) + private static final Map displayNameMap = new HashMap<>(); + + static { + // --- 字母 (A-Z) --- + for (int i = 0; i < 26; i++) { + int androidKeyCode = KeyEvent.KEYCODE_A + i; + String letter = String.valueOf((char) ('A' + i)); + String windowsKeyCode = String.format("0x%02X", 0x41 + i); + windowsKeyMap.put(androidKeyCode, windowsKeyCode); + displayNameMap.put(androidKeyCode, letter); + } + + // --- 数字 (0-9) --- + for (int i = 0; i < 10; i++) { + int androidKeyCode = KeyEvent.KEYCODE_0 + i; + String number = String.valueOf((char) ('0' + i)); + String windowsKeyCode = String.format("0x%02X", 0x30 + i); + windowsKeyMap.put(androidKeyCode, windowsKeyCode); + displayNameMap.put(androidKeyCode, number); + } + + // --- 功能键 (F1-F12) --- + for (int i = 0; i < 12; i++) { + int androidKeyCode = KeyEvent.KEYCODE_F1 + i; + String fKey = "F" + (i + 1); + String windowsKeyCode = String.format("0x%02X", 0x70 + i); + windowsKeyMap.put(androidKeyCode, windowsKeyCode); + displayNameMap.put(androidKeyCode, fKey); + } + + // --- 修饰键 --- + addMapping(KeyEvent.KEYCODE_SHIFT_LEFT, "L-Shift", "0xA0"); // VK_LSHIFT + addMapping(KeyEvent.KEYCODE_SHIFT_RIGHT, "R-Shift", "0xA1"); // VK_RSHIFT + addMapping(KeyEvent.KEYCODE_CTRL_LEFT, "L-Ctrl", "0xA2"); // VK_LCONTROL + addMapping(KeyEvent.KEYCODE_CTRL_RIGHT, "R-Ctrl", "0xA3"); // VK_RCONTROL + addMapping(KeyEvent.KEYCODE_ALT_LEFT, "L-Alt", "0xA4"); // VK_LMENU + addMapping(KeyEvent.KEYCODE_ALT_RIGHT, "R-Alt", "0xA5"); // VK_RMENU + addMapping(KeyEvent.KEYCODE_META_LEFT, "L-Win", "0x5B"); // VK_LWIN + addMapping(KeyEvent.KEYCODE_META_RIGHT, "R-Win", "0x5C"); // VK_RWIN + addMapping(KeyEvent.KEYCODE_CAPS_LOCK, "Cap", "0x14"); // VK_CAPITAL + + // --- 控制与导航键 --- + addMapping(KeyEvent.KEYCODE_ESCAPE, "ESC", "0x1B"); + addMapping(KeyEvent.KEYCODE_ENTER, "Enter", "0x0D"); + addMapping(KeyEvent.KEYCODE_TAB, "Tab", "0x09"); + addMapping(KeyEvent.KEYCODE_DEL, "Back", "0x08"); // Backspace + addMapping(KeyEvent.KEYCODE_SPACE, "Space", "0x20"); + addMapping(KeyEvent.KEYCODE_PAGE_UP, "PgUp", "0x21"); + addMapping(KeyEvent.KEYCODE_PAGE_DOWN, "PgDn", "0x22"); + addMapping(KeyEvent.KEYCODE_MOVE_END, "End", "0x23"); + addMapping(KeyEvent.KEYCODE_MOVE_HOME, "Home", "0x24"); + addMapping(KeyEvent.KEYCODE_DPAD_LEFT, "←", "0x25"); + addMapping(KeyEvent.KEYCODE_DPAD_UP, "↑", "0x26"); + addMapping(KeyEvent.KEYCODE_DPAD_RIGHT, "→", "0x27"); + addMapping(KeyEvent.KEYCODE_DPAD_DOWN, "↓", "0x28"); + addMapping(KeyEvent.KEYCODE_INSERT, "Ins", "0x2D"); + addMapping(KeyEvent.KEYCODE_FORWARD_DEL, "Del", "0x2E"); // Delete key + + // --- 标点符号 --- + addMapping(KeyEvent.KEYCODE_GRAVE, "`", "0xC0"); + addMapping(KeyEvent.KEYCODE_MINUS, "-", "0xBD"); + addMapping(KeyEvent.KEYCODE_EQUALS, "=", "0xBB"); + addMapping(KeyEvent.KEYCODE_LEFT_BRACKET, "[", "0xDB"); + addMapping(KeyEvent.KEYCODE_RIGHT_BRACKET, "]", "0xDD"); + addMapping(KeyEvent.KEYCODE_BACKSLASH, "\\", "0xDC"); + addMapping(KeyEvent.KEYCODE_SEMICOLON, ";", "0xBA"); + addMapping(KeyEvent.KEYCODE_APOSTROPHE, "'", "0xDE"); + addMapping(KeyEvent.KEYCODE_COMMA, ",", "0xBC"); + addMapping(KeyEvent.KEYCODE_PERIOD, ".", "0xBE"); + addMapping(KeyEvent.KEYCODE_SLASH, "/", "0xBF"); + } + + // 辅助方法,简化添加映射的过程 + private static void addMapping(int androidKeyCode, String name, String windowsKeyCode) { + windowsKeyMap.put(androidKeyCode, windowsKeyCode); + displayNameMap.put(androidKeyCode, name); + } + + /** + * 根据 Android KeyCode 获取对应的 Windows Virtual-Key Code。 + * @param androidKeyCode Android 的 KeyEvent 常量。 + * @return 对应的 Windows 十六进制码字符串,如果找不到则返回 null。 + */ + public static String getWindowsKeyCode(int androidKeyCode) { + return windowsKeyMap.get(androidKeyCode); + } + + /** + * 根据 Android KeyCode 获取用于在UI上显示的友好名称。 + * @param androidKeyCode Android 的 KeyEvent 常量。 + * @return 对应的显示名称,如果找不到则返回 "Unknown"。 + */ + public static String getDisplayName(int androidKeyCode) { + return displayNameMap.get(androidKeyCode); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/MathUtils.java b/app/src/main/java/com/limelight/utils/MathUtils.java new file mode 100644 index 0000000000..1e81cad516 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/MathUtils.java @@ -0,0 +1,28 @@ +package com.limelight.utils; + +import java.security.MessageDigest; + +public class MathUtils { + + public static String computeMD5(String text) { + try { + // 创建一个 MessageDigest 实例 + MessageDigest digest = MessageDigest.getInstance("MD5"); + + // 计算输入的字节的 MD5 值 + byte[] hash = digest.digest(text.getBytes("UTF-8")); + + // 将 MD5 值转换为十六进制的字符串 + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + + return hexString.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/src/main/java/com/limelight/utils/MoonPhaseUtils.java b/app/src/main/java/com/limelight/utils/MoonPhaseUtils.java new file mode 100644 index 0000000000..e652729917 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/MoonPhaseUtils.java @@ -0,0 +1,230 @@ +package com.limelight.utils; + +import java.util.Calendar; +import java.util.TimeZone; + +/** + * 月相工具类 + * 提供月相计算、图标获取、信息查询等功能 + */ +public class MoonPhaseUtils { + + /** + * 月相信息类 + */ + public static class MoonPhaseInfo { + public final String poeticTitle; + public final String name; + public final String description; + public final String icon; + + public MoonPhaseInfo(String poeticTitle, String name, String description, String icon) { + this.poeticTitle = poeticTitle; + this.name = name; + this.description = description; + this.icon = icon; + } + } + + /** + * 月相类型枚举 + */ + public enum MoonPhaseType { + NEW_MOON("新月", 0.0, 0.0625), + WAXING_CRESCENT("娥眉月", 0.0625, 0.1875), + FIRST_QUARTER("上弦月", 0.1875, 0.3125), + WAXING_GIBBOUS("盈凸月", 0.3125, 0.4375), + FULL_MOON("满月", 0.4375, 0.5625), + WANING_GIBBOUS("亏凸月", 0.5625, 0.6875), + LAST_QUARTER("下弦月", 0.6875, 0.8125), + WANING_CRESCENT("残月", 0.8125, 0.9375); + + private final String name; + private final double minPhase; + private final double maxPhase; + + MoonPhaseType(String name, double minPhase, double maxPhase) { + this.name = name; + this.minPhase = minPhase; + this.maxPhase = maxPhase; + } + + public String getName() { + return name; + } + + public boolean isInRange(double phase) { + return phase >= minPhase && phase < maxPhase; + } + } + + /** + * 计算月相(0-1,0为新月,0.5为满月) + * 使用简化的天文算法 + */ + public static double calculateMoonPhase(Calendar date) { + // 基准日期:2000年1月6日为新月 + Calendar baseDate = Calendar.getInstance(); + baseDate.set(2000, Calendar.JANUARY, 6, 18, 14, 0); + + // 计算距离基准日期的天数 + long timeDiff = date.getTimeInMillis() - baseDate.getTimeInMillis(); + double daysDiff = timeDiff / (24.0 * 60.0 * 60.0 * 1000.0); + + // 月相周期约为29.53天 + double moonCycle = 29.530588853; + + // 计算当前月相位置(0-1) + double phase = (daysDiff % moonCycle) / moonCycle; + if (phase < 0) phase += 1.0; + + return phase; + } + + /** + * 获取当前月相 + */ + public static double getCurrentMoonPhase() { + return calculateMoonPhase(Calendar.getInstance(TimeZone.getDefault())); + } + + /** + * 根据月相值获取月相类型 + */ + public static MoonPhaseType getMoonPhaseType(double phase) { + for (MoonPhaseType type : MoonPhaseType.values()) { + if (type.isInRange(phase)) { + return type; + } + } + return MoonPhaseType.NEW_MOON; // 默认返回新月 + } + + /** + * 根据月相值获取对应的Unicode图标 + */ + public static String getMoonPhaseIcon(double phase) { + MoonPhaseType type = getMoonPhaseType(phase); + switch (type) { + case NEW_MOON: + return "🌑"; + case WAXING_CRESCENT: + return "🌒"; + case FIRST_QUARTER: + return "🌓"; + case WAXING_GIBBOUS: + return "🌔"; + case FULL_MOON: + return "🌕"; + case WANING_GIBBOUS: + return "🌖"; + case LAST_QUARTER: + return "🌗"; + case WANING_CRESCENT: + return "🌘"; + default: + return "🌙"; + } + } + + /** + * 获取诗意化的月相标题 + */ + public static String getMoonPhasePoeticTitle(double phase) { + MoonPhaseType type = getMoonPhaseType(phase); + switch (type) { + case NEW_MOON: + return "🌑 新月如钩 · 万象更新"; + case WAXING_CRESCENT: + return "🌒 娥眉初现 · 希望萌芽"; + case FIRST_QUARTER: + return "🌓 上弦月明 · 平衡之道"; + case WAXING_GIBBOUS: + return "🌔 盈凸月满 · 收获在望"; + case FULL_MOON: + return "🌕 满月当空 · 圆满时刻"; + case WANING_GIBBOUS: + return "🌖 亏凸月暗 · 感恩释放"; + case LAST_QUARTER: + return "🌗 下弦月残 · 反思内省"; + case WANING_CRESCENT: + return "🌘 残月如钩 · 循环往复"; + default: + return "🌑 新月如钩 · 万象更新"; + } + } + + /** + * 获取月相描述 + */ + public static String getMoonPhaseDescription(double phase) { + MoonPhaseType type = getMoonPhaseType(phase); + switch (type) { + case NEW_MOON: + return "月亮与太阳同方向,不可见。\n象征新的开始和重生。"; + case WAXING_CRESCENT: + return "月亮的右侧开始发光。\n象征成长和希望的萌芽。"; + case FIRST_QUARTER: + return "月亮的一半被照亮。\n象征平衡和决策的时刻。"; + case WAXING_GIBBOUS: + return "月亮大部分被照亮。\n象征接近圆满和收获。"; + case FULL_MOON: + return "月亮完全被照亮。\n象征圆满、成就和庆祝。"; + case WANING_GIBBOUS: + return "月亮开始变暗。\n象征释放和感恩。"; + case LAST_QUARTER: + return "月亮的一半变暗。\n象征反思和内省。"; + case WANING_CRESCENT: + return "月亮几乎不可见。\n象征结束和准备新的循环。"; + default: + return "月亮与太阳同方向,不可见。\n象征新的开始和重生。"; + } + } + + /** + * 获取完整的月相信息 + */ + public static MoonPhaseInfo getMoonPhaseInfo(double phase) { + return new MoonPhaseInfo( + getMoonPhasePoeticTitle(phase), + getMoonPhaseType(phase).getName(), + getMoonPhaseDescription(phase), + getMoonPhaseIcon(phase) + ); + } + + /** + * 获取当前月相的完整信息 + */ + public static MoonPhaseInfo getCurrentMoonPhaseInfo() { + return getMoonPhaseInfo(getCurrentMoonPhase()); + } + + /** + * 计算月相百分比 + */ + public static double getMoonPhasePercentage(double phase) { + return phase * 100; + } + + /** + * 计算月相周期中的天数 + */ + public static int getDaysInMoonCycle(double phase) { + return (int) (phase * 29.530588853); + } + + /** + * 判断是否为满月(允许一定误差) + */ + public static boolean isFullMoon(double phase, double tolerance) { + return Math.abs(phase - 0.5) < tolerance; + } + + /** + * 判断是否为新月(允许一定误差) + */ + public static boolean isNewMoon(double phase, double tolerance) { + return phase < tolerance || phase > (1.0 - tolerance); + } +} diff --git a/app/src/main/java/com/limelight/utils/NetHelper.java b/app/src/main/java/com/limelight/utils/NetHelper.java index eb5512f8b6..c4640cc1f9 100644 --- a/app/src/main/java/com/limelight/utils/NetHelper.java +++ b/app/src/main/java/com/limelight/utils/NetHelper.java @@ -6,6 +6,7 @@ import android.net.NetworkCapabilities; import android.net.NetworkInfo; import android.os.Build; +import android.annotation.SuppressLint; public class NetHelper { public static boolean isActiveNetworkVpn(Context context) { @@ -29,4 +30,40 @@ public static boolean isActiveNetworkVpn(Context context) { return false; } + + /** + * 计算并格式化网络带宽 + * @param currentRxBytes 当前接收字节数 + * @param previousRxBytes 上次接收字节数 + * @param timeInterval 时间间隔(毫秒) + * @return 格式化后的带宽字符串 + */ + @SuppressLint("DefaultLocale") + public static String calculateBandwidth(long currentRxBytes, long previousRxBytes, long timeInterval) { + // 检查时间间隔是否有效 + if (timeInterval <= 0 || timeInterval > 5000) { // 超过5秒的间隔认为无效 + return "N/A"; + } + + // 检查字节数是否有效(防止TrafficStats返回异常值) + if (currentRxBytes < 0 || previousRxBytes < 0) { + return "N/A"; + } + + // 防止字节数回绕(32位系统可能发生) + long rxBytesDifference = currentRxBytes - previousRxBytes; + if (rxBytesDifference < 0) { + // 如果出现负数,可能是计数器重置,返回0 + return "N/A"; + } + + // 转换为KB + long rxBytesPerDifference = rxBytesDifference / 1024; + double speedKBps = rxBytesPerDifference / ((double) timeInterval / 1000); + + if (speedKBps < 1024) { + return String.format("%.0f K/s", speedKBps); + } + return String.format("%.2f M/s", speedKBps / 1024); + } } diff --git a/app/src/main/java/com/limelight/utils/NonScrollableListView.java b/app/src/main/java/com/limelight/utils/NonScrollableListView.java new file mode 100644 index 0000000000..ed2f7107fd --- /dev/null +++ b/app/src/main/java/com/limelight/utils/NonScrollableListView.java @@ -0,0 +1,30 @@ +package com.limelight.utils; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.ListView; + +public class NonScrollableListView extends ListView { + + public NonScrollableListView(Context context) { + super(context); + } + + public NonScrollableListView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public NonScrollableListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int heightMeasureSpec_custom = MeasureSpec.makeMeasureSpec( + Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); + super.onMeasure(widthMeasureSpec, heightMeasureSpec_custom); + ViewGroup.LayoutParams params = getLayoutParams(); + params.height = getMeasuredHeight(); + } +} diff --git a/app/src/main/java/com/limelight/utils/PanZoomHandler.java b/app/src/main/java/com/limelight/utils/PanZoomHandler.java new file mode 100644 index 0000000000..e731c60e9b --- /dev/null +++ b/app/src/main/java/com/limelight/utils/PanZoomHandler.java @@ -0,0 +1,150 @@ +package com.limelight.utils; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +import com.limelight.Game; +import com.limelight.preferences.PreferenceConfiguration; + +public class PanZoomHandler { + static private final float MAX_SCALE = 10.0f; + + private final Game game; + private final View streamView; + private final PreferenceConfiguration prefConfig; + private final ScaleGestureDetector scaleGestureDetector; + private final GestureDetector gestureDetector; + private View parent; + private float scaleFactor = 1.0f; + private float childX, childY = 0; + private float parentWidth, parentHeight = 0; + private float childWidth, childHeight = 0; + + public PanZoomHandler(Context context, Game game, View streamView, PreferenceConfiguration prefConfig) { + this.game = game; + this.streamView = streamView; + this.prefConfig = prefConfig; + scaleGestureDetector = new ScaleGestureDetector(context, new ScaleListener()); + gestureDetector = new GestureDetector(context, new GestureListener()); + + // Everything gets easier with 0,0 as the pivot point + streamView.setPivotX(0); + streamView.setPivotY(0); + } + + public void handleTouchEvent(MotionEvent motionEvent) { + scaleGestureDetector.onTouchEvent(motionEvent); + gestureDetector.onTouchEvent(motionEvent); + } + + private void updateDimensions() { + childHeight = streamView.getHeight() * scaleFactor; + childWidth = streamView.getWidth() * scaleFactor; + parentWidth = parent.getWidth(); + parentHeight = parent.getHeight(); + } + + private void constrainToBounds() { + updateDimensions(); + + if (parentWidth >= childWidth) { + childX = (parentWidth - childWidth) / 2; + } else { + float boundaryX = parentWidth - childWidth; + childX = Math.max(boundaryX, Math.min(childX, 0)); + } + + if (parentHeight >= childHeight) { + childY = (parentHeight - childHeight) / 2; + } else { + float boundaryY = parentHeight - childHeight; + childY = Math.max(boundaryY, Math.min(childY, 0)); + } + + streamView.setX(childX); + streamView.setY(childY); + } + + public void handleSurfaceChange() { + if (childWidth == 0 || parent == null) { + // Retrieve parent, should handle both built-in display and external display + parent = (View)streamView.getParent(); + return; + } + + float prevChildWidth = childWidth; + float prevChildHeight = childHeight; + float prevParentWidth = parentWidth; + float prevParentHeight = parentHeight; + + updateDimensions(); + + float viewScaleX = childWidth / prevChildWidth; + float viewScaleY = childHeight / prevChildHeight; + + float dPivotX1 = childX - prevParentWidth / 2; + float dPivotY1 = childY - prevParentHeight / 2; + + float dPivotX2 = dPivotX1 * viewScaleX; + float dPivotY2 = dPivotY1 * viewScaleY; + + childX = dPivotX2 + parentWidth / 2; + childY = dPivotY2 + parentHeight / 2; + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + } + + private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { + @Override + public boolean onScale(ScaleGestureDetector detector) { + float newScaleFactor = scaleFactor * detector.getScaleFactor(); + newScaleFactor = Math.max(1, Math.min(newScaleFactor, MAX_SCALE)); // Apply minimum scale + + // Calculate pivot point + float focusX = detector.getFocusX(); + float focusY = detector.getFocusY(); + + float dPivotX = (childX - focusX) / scaleFactor * newScaleFactor; + float dPivotY = (childY - focusY) / scaleFactor * newScaleFactor; + + childX = focusX + dPivotX; + childY = focusY + dPivotY; + + scaleFactor = newScaleFactor; + + streamView.setScaleX(scaleFactor); + streamView.setScaleY(scaleFactor); + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + game.updatePipAutoEnter(); + } + } + + private class GestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + childX = streamView.getX() - distanceX; + childY = streamView.getY() - distanceY; + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + return true; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java index ef5a790297..df4cd5d122 100644 --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -15,6 +15,7 @@ import com.limelight.nvstream.http.NvApp; import com.limelight.nvstream.http.NvHTTP; import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.PreferenceConfiguration; import org.xmlpull.v1.XmlPullParserException; @@ -54,6 +55,12 @@ public static Intent createAppShortcutIntent(Activity parent, ComputerDetails co public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, ComputerManagerService.ComputerManagerBinder managerBinder) { + return createStartIntent(parent, app, computer, managerBinder, null); + } + + public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder, + PreferenceConfiguration lastSettings) { Intent intent = new Intent(parent, Game.class); intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); @@ -64,6 +71,11 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); intent.putExtra(Game.EXTRA_PC_NAME, computer.name); + intent.putExtra(Game.EXTRA_PAIR_NAME, computer.getPairName(parent)); + intent.putExtra(Game.EXTRA_PC_USEVDD, computer.useVdd); + if (app.getCmdList() != null) { + intent.putExtra(Game.EXTRA_APP_CMD, app.getCmdList().toString()); + } try { if (computer.serverCert != null) { intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); @@ -71,6 +83,12 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai } catch (CertificateEncodingException e) { e.printStackTrace(); } + + // 如果有上一次设置,通过Intent传递 + if (lastSettings != null) { + AppSettingsManager.addLastSettingsToIntent(intent, lastSettings); + } + return intent; } @@ -84,34 +102,68 @@ public static void doStart(Activity parent, NvApp app, ComputerDetails computer, } public static void doNetworkTest(final Activity parent) { - new Thread(new Runnable() { - @Override - public void run() { - SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_waiting), - parent.getResources().getString(R.string.nettest_text_waiting), - false); - - int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); - spinnerDialog.dismiss(); - - String dialogSummary; - if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); - } - else if (ret == 0) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_success); + new Thread(() -> { + SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_waiting), + parent.getResources().getString(R.string.nettest_text_waiting), + false); + + int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); + spinnerDialog.dismiss(); + + String dialogSummary; + if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); + } + else if (ret == 0) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_success); + } + else { + dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); + dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); + } + + Dialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_done), + dialogSummary, + false); + }).start(); + } + + public static void pcSleep(final Activity parent, final ComputerDetails computer, + final ComputerManagerService.ComputerManagerBinder managerBinder, + final Runnable onComplete) { + new Thread(() -> { + NvHTTP httpConn; + String message; + try { + httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, + managerBinder.getUniqueId(), "", computer.serverCert, PlatformBinding.getCryptoProvider(parent)); + if (httpConn.pcSleep()) { + message = parent.getResources().getString(R.string.pcview_menu_sleep_success); + } else { + message = parent.getResources().getString(R.string.pcview_menu_sleep_fail); } - else { - dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); - dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); + } catch (HostHttpResponseException e) { + message = e.getMessage(); + } catch (UnknownHostException e) { + message = parent.getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = parent.getResources().getString(R.string.error_404); + } catch (IOException | XmlPullParserException e) { + message = e.getMessage(); + e.printStackTrace(); + } catch (InterruptedException e) { + // Thread was interrupted, exit gracefully + message = parent.getResources().getString(R.string.error_interrupted); + } finally { + if (onComplete != null) { + onComplete.run(); } - - Dialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_done), - dialogSummary, - false); } + + final String toastMessage = message; + parent.runOnUiThread(() -> Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show()); }).start(); } @@ -121,49 +173,44 @@ public static void doQuit(final Activity parent, final ComputerManagerService.ComputerManagerBinder managerBinder, final Runnable onComplete) { Toast.makeText(parent, parent.getResources().getString(R.string.applist_quit_app) + " " + app.getAppName() + "...", Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - try { - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, - managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent)); - if (httpConn.quitApp()) { - message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName(); - } else { - message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName(); - } - } catch (HostHttpResponseException e) { - if (e.getErrorCode() == 599) { - message = "This session wasn't started by this device," + - " so it cannot be quit. End streaming on the original " + - "device or the PC itself. (Error code: "+e.getErrorCode()+")"; - } - else { - message = e.getMessage(); - } - } catch (UnknownHostException e) { - message = parent.getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = parent.getResources().getString(R.string.error_404); - } catch (IOException | XmlPullParserException e) { + new Thread(() -> { + NvHTTP httpConn; + String message; + try { + httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, + managerBinder.getUniqueId(), "", computer.serverCert, PlatformBinding.getCryptoProvider(parent)); + if (httpConn.quitApp()) { + message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName(); + } else { + message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName(); + } + } catch (HostHttpResponseException e) { + if (e.getErrorCode() == 599) { + message = "This session wasn't started by this device," + + " so it cannot be quit. End streaming on the original " + + "device or the PC itself. (Error code: "+e.getErrorCode()+")"; + } + else { message = e.getMessage(); - e.printStackTrace(); - } finally { - if (onComplete != null) { - onComplete.run(); - } } - - final String toastMessage = message; - parent.runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show(); - } - }); + } catch (UnknownHostException e) { + message = parent.getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = parent.getResources().getString(R.string.error_404); + } catch (IOException | XmlPullParserException e) { + message = e.getMessage(); + e.printStackTrace(); + } catch (InterruptedException e) { + // Thread was interrupted, exit gracefully + message = parent.getResources().getString(R.string.error_interrupted); + } finally { + if (onComplete != null) { + onComplete.run(); + } } + + final String toastMessage = message; + parent.runOnUiThread(() -> Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show()); }).start(); } } diff --git a/app/src/main/java/com/limelight/utils/ShortcutHelper.java b/app/src/main/java/com/limelight/utils/ShortcutHelper.java index 759ac9303b..c5abbda359 100644 --- a/app/src/main/java/com/limelight/utils/ShortcutHelper.java +++ b/app/src/main/java/com/limelight/utils/ShortcutHelper.java @@ -5,6 +5,15 @@ import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.AdaptiveIconDrawable; +import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Icon; import android.os.Build; @@ -151,7 +160,8 @@ public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bit Icon appIcon; if (iconBits != null) { - appIcon = Icon.createWithAdaptiveBitmap(iconBits); + Bitmap adaptiveSquare = prepareAdaptiveSquareBitmap(iconBits); + appIcon = Icon.createWithAdaptiveBitmap(adaptiveSquare); } else { appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut); } @@ -168,6 +178,65 @@ public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bit } } + private static Bitmap prepareAdaptiveSquareBitmap(Bitmap source) { + if (source == null) { + return null; + } + + int srcWidth = source.getWidth(); + int srcHeight = source.getHeight(); + if (srcWidth <= 0 || srcHeight <= 0) { + return source; + } + + // 确保来源位图为软件位图(避免在软件画布上绘制硬件位图导致异常) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && source.getConfig() == Bitmap.Config.HARDWARE) { + Bitmap softwareCopy = source.copy(Bitmap.Config.ARGB_8888, false); + if (softwareCopy != null) { + source = softwareCopy; + srcWidth = source.getWidth(); + srcHeight = source.getHeight(); + } else { + // 无法转换则直接返回原图,跳过绘制流程以避免崩溃 + return source; + } + } + + // 创建方形透明画布,边长取原图较大边 + int side = Math.max(srcWidth, srcHeight); + Bitmap output = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + + // 使用 centerCrop 模式:计算缩放比例,取较大值确保填满整个画布 + float scale = Math.max((float) side / srcWidth, (float) side / srcHeight); + + // 计算缩放后的尺寸 + int scaledWidth = Math.round(srcWidth * scale); + int scaledHeight = Math.round(srcHeight * scale); + + // 计算居中裁剪的源图区域 + int srcLeft = (scaledWidth - side) / 2; + int srcTop = (scaledHeight - side) / 2; + + // 将缩放后的坐标转换回原始图片坐标 + int actualSrcLeft = Math.round(srcLeft / scale); + int actualSrcTop = Math.round(srcTop / scale); + int actualSrcRight = Math.round((srcLeft + side) / scale); + int actualSrcBottom = Math.round((srcTop + side) / scale); + + // 确保不超出源图边界 + actualSrcLeft = Math.max(0, actualSrcLeft); + actualSrcTop = Math.max(0, actualSrcTop); + actualSrcRight = Math.min(srcWidth, actualSrcRight); + actualSrcBottom = Math.min(srcHeight, actualSrcBottom); + + Rect srcRect = new Rect(actualSrcLeft, actualSrcTop, actualSrcRight, actualSrcBottom); + Rect dstRect = new Rect(0, 0, side, side); + canvas.drawBitmap(source, srcRect, dstRect, null); + + return output; + } + public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) { tvChannelHelper.deleteChannel(computer); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { diff --git a/app/src/main/java/com/limelight/utils/SpinnerDialog.java b/app/src/main/java/com/limelight/utils/SpinnerDialog.java index 01fe269956..3c3213db35 100644 --- a/app/src/main/java/com/limelight/utils/SpinnerDialog.java +++ b/app/src/main/java/com/limelight/utils/SpinnerDialog.java @@ -8,6 +8,8 @@ import android.content.DialogInterface; import android.content.DialogInterface.OnCancelListener; +import com.limelight.R; + public class SpinnerDialog implements Runnable,OnCancelListener { private final String title; private final String message; @@ -75,7 +77,7 @@ public void run() { if (progress == null) { - progress = new ProgressDialog(activity); + progress = new ProgressDialog(activity, R.style.AppProgressDialogStyle); progress.setTitle(title); progress.setMessage(message); @@ -97,6 +99,14 @@ public void run() { rundownDialogs.add(this); progress.show(); } + + // 设置对话框透明度 + if (progress.getWindow() != null) { + android.view.WindowManager.LayoutParams layoutParams = progress.getWindow().getAttributes(); + layoutParams.alpha = 0.8f; + // layoutParams.dimAmount = 0.3f; + progress.getWindow().setAttributes(layoutParams); + } } else { diff --git a/app/src/main/java/com/limelight/utils/UiHelper.java b/app/src/main/java/com/limelight/utils/UiHelper.java index f6da70aa25..8922db7657 100644 --- a/app/src/main/java/com/limelight/utils/UiHelper.java +++ b/app/src/main/java/com/limelight/utils/UiHelper.java @@ -8,16 +8,21 @@ import android.app.UiModeManager; import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.res.Configuration; import android.graphics.Insets; +import android.os.BatteryManager; import android.os.Build; import android.os.LocaleList; +import android.view.Display; import android.view.View; import android.view.WindowInsets; import android.view.WindowManager; import com.limelight.Game; +import com.limelight.LimeLog; import com.limelight.R; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.preferences.PreferenceConfiguration; @@ -29,15 +34,38 @@ public class UiHelper { private static final int TV_VERTICAL_PADDING_DP = 15; private static final int TV_HORIZONTAL_PADDING_DP = 15; - private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + private static Boolean sGameManagerAvailable = null; + + private static boolean isGameManagerAvailable(Context context) { + if (sGameManagerAvailable != null) { + return sGameManagerAvailable; + } + try { GameManager gameManager = context.getSystemService(GameManager.class); + sGameManagerAvailable = (gameManager != null); + } catch (Exception e) { + sGameManagerAvailable = false; + } + return sGameManagerAvailable; + } - if (streaming) { - gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); + private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!isGameManagerAvailable(context)) { + return; } - else { - gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); + + try { + GameManager gameManager = context.getSystemService(GameManager.class); + + if (streaming) { + gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); + } + else { + gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); + } + } catch (Exception e) { + sGameManagerAvailable = false; } } } @@ -128,13 +156,15 @@ public static void notifyNewRootView(final Activity activity) } if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { - // Increase view padding on TVs - float scale = activity.getResources().getDisplayMetrics().density; - int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); - int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); - - rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, - horizontalPaddingPixels, verticalPaddingPixels); + // Only apply legacy overscan padding on pre-Android 10 TVs + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + float scale = activity.getResources().getDisplayMetrics().density; + int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); + int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); + + rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, + horizontalPaddingPixels, verticalPaddingPixels); + } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Draw under the status bar on Android Q devices @@ -145,10 +175,10 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { // Use the tappable insets so we can draw under the status bar in gesture mode Insets tappableInsets = windowInsets.getTappableElementInsets(); - view.setPadding(tappableInsets.left, - tappableInsets.top, - tappableInsets.right, - 0); + // view.setPadding(tappableInsets.left, + // tappableInsets.top, + // tappableInsets.right, + // 0); // Show a translucent navigation bar if we can't tap there if (tappableInsets.bottom != 0) { @@ -224,7 +254,7 @@ public void onClick(DialogInterface dialog, int which) { } }; - AlertDialog.Builder builder = new AlertDialog.Builder(parent); + AlertDialog.Builder builder = new AlertDialog.Builder(parent, R.style.AppDialogStyle); builder.setMessage(parent.getResources().getString(R.string.applist_quit_confirmation)) .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) @@ -251,11 +281,76 @@ public void onClick(DialogInterface dialog, int which) { } }; - AlertDialog.Builder builder = new AlertDialog.Builder(parent); + AlertDialog.Builder builder = new AlertDialog.Builder(parent, R.style.AppDialogStyle); builder.setMessage(parent.getResources().getString(R.string.delete_pc_msg)) .setTitle(computer.name) .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) .show(); } + + public static int getBatteryLevel(Context context) { + int level = 0; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // 使用BatteryManager获取更精确的电量信息 + BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); + } else { + // 兼容旧版本设备的实现 + Intent batteryIntent = context.registerReceiver(null, + new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + if (batteryIntent != null) { + level = (batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100) / + batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + } + } + + // 处理异常值 + if (level < 0 || level > 100) { + LimeLog.warning("Invalid battery level: " + level); + return 0; + } + } catch (Exception e) { + LimeLog.warning("Error getting battery level: " + e.getMessage()); + return 0; + } + + return level; + } + + public static boolean isColorOS() { + String manufacturer = android.os.Build.MANUFACTURER.toLowerCase(); + String model = android.os.Build.MODEL.toLowerCase(); + String brand = android.os.Build.BRAND.toLowerCase(); + + // 检查是否为OPPO、OnePlus或Realme设备(均使用ColorOS) + String[] colorOSBrands = {"oppo", "oneplus", "realme"}; + + for (String colorOSBrand : colorOSBrands) { + if (manufacturer.contains(colorOSBrand) || + model.contains(colorOSBrand) || + brand.contains(colorOSBrand)) { + return true; + } + } + + return false; + } + + /** + * 获取设备支持的刷新率 + * @param activity 活动上下文 + * @return 设备刷新率(Hz),如果获取失败则返回默认值60Hz + */ + public static float getDeviceRefreshRate(Activity activity) { + try { + Display display = activity.getWindowManager().getDefaultDisplay(); + return display.getRefreshRate(); + } catch (Exception e) { + LimeLog.warning("Failed to get device refresh rate: " + e.getMessage()); + // 如果获取失败,返回默认值60Hz + return 60.0f; + } + } } diff --git a/app/src/main/java/com/limelight/utils/UpdateManager.java b/app/src/main/java/com/limelight/utils/UpdateManager.java new file mode 100644 index 0000000000..4c9dfa70b4 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/UpdateManager.java @@ -0,0 +1,585 @@ +package com.limelight.utils; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.Settings; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; + +import com.limelight.BuildConfig; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class UpdateManager { + private static final String TAG = "UpdateManager"; + private static final String GITHUB_API_URL = "https://api.github.com/repos/qiin2333/moonlight-android/releases/latest"; + private static final String GITHUB_RELEASE_PAGE = "https://github.com/qiin2333/moonlight-android/releases/latest"; + private static final long UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1000; + + // 代理发现地址 + private static final String PROXY_DISCOVERY_URL = "https://ghproxy.link/js/src_views_home_HomeView_vue.js"; + + // API与下载的代理前缀(按优先级尝试)- 将在运行时动态更新 + private static volatile String[] PROXY_PREFIXES = new String[] {}; + + private static final AtomicBoolean isChecking = new AtomicBoolean(false); + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + // 代理缓存相关 + private static final long PROXY_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24小时 + private static final String PREF_LAST_PROXY_UPDATE_TIME = "last_proxy_update_time"; + + public static void checkForUpdates(Context context, boolean showToast) { + if (isChecking.getAndSet(true)) { + return; + } + + executor.execute(new UpdateCheckTask(context, showToast)); + } + + public static void checkForUpdatesOnStartup(Context context) { + long lastCheckTime = context.getSharedPreferences("update_prefs", Context.MODE_PRIVATE) + .getLong("last_check_time", 0); + long currentTime = System.currentTimeMillis(); + + if (currentTime - lastCheckTime > UPDATE_CHECK_INTERVAL) { + checkForUpdates(context, false); + } + } + + private static class UpdateCheckTask implements Runnable { + private final Context context; + private final boolean showToast; + + public UpdateCheckTask(Context context, boolean showToast) { + this.context = context; + this.showToast = showToast; + } + + @Override + public void run() { + if (shouldUpdateProxyList(context)) { + updateProxyList(context); + } + + UpdateInfo updateInfo = null; + try { + String json = httpGetWithProxies(GITHUB_API_URL); + if (json != null) { + JSONObject jsonResponse = new JSONObject(json); + String latestVersion = jsonResponse.optString("tag_name", ""); + String releaseNotes = jsonResponse.optString("body", ""); + + // 解析资产,优先选择APK + String apkUrl = null; + String apkName = null; + JSONArray assets = jsonResponse.optJSONArray("assets"); + if (assets != null) { + // 根据是否root版本尽量挑选合适APK + List apkAssets = new ArrayList<>(); + for (int i = 0; i < assets.length(); i++) { + JSONObject a = assets.optJSONObject(i); + if (a != null) { + String name = a.optString("name", ""); + String url = a.optString("browser_download_url", ""); + if (name.endsWith(".apk") && url.startsWith("http")) { + apkAssets.add(a); + } + } + } + // 优先匹配root/nonRoot + for (JSONObject a : apkAssets) { + String name = a.optString("name", ""); + boolean isRootApk = name.toLowerCase().contains("root"); + if (isRootApk == BuildConfig.ROOT_BUILD) { + apkName = name; + apkUrl = a.optString("browser_download_url", null); + break; + } + } + // 若没匹配到,退而求其次取第一个APK + if (apkUrl == null && !apkAssets.isEmpty()) { + JSONObject a = apkAssets.get(0); + apkName = a.optString("name", null); + apkUrl = a.optString("browser_download_url", null); + } + } + + updateInfo = new UpdateInfo(latestVersion, releaseNotes, apkName, apkUrl); + } + } catch (Exception e) { + Log.e(TAG, "检查更新失败", e); + } + + final UpdateInfo finalUpdateInfo = updateInfo; + + if (context instanceof Activity) { + ((Activity) context).runOnUiThread(() -> handleUpdateResult(finalUpdateInfo)); + } + } + + private void handleUpdateResult(UpdateInfo updateInfo) { + isChecking.set(false); + + context.getSharedPreferences("update_prefs", Context.MODE_PRIVATE) + .edit() + .putLong("last_check_time", System.currentTimeMillis()) + .apply(); + + if (updateInfo == null) { + if (showToast) { + Toast.makeText(context, "检查更新失败,请稍后重试", Toast.LENGTH_SHORT).show(); + } + return; + } + + String currentVersion = getCurrentVersion(context); + if (isNewVersionAvailable(currentVersion, updateInfo.version)) { + showUpdateDialog(context, updateInfo); + } else if (showToast) { + Toast.makeText(context, "已是最新版本", Toast.LENGTH_SHORT).show(); + } + } + } + + private static String getCurrentVersion(Context context) { + try { + PackageInfo packageInfo = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0); + return packageInfo.versionName; + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "获取当前版本失败", e); + return "0.0.0"; + } + } + + private static boolean isNewVersionAvailable(String currentVersion, String latestVersion) { + try { + currentVersion = currentVersion.replaceAll("^[Vv]", ""); + latestVersion = latestVersion.replaceAll("^[Vv]", ""); + + String[] currentParts = currentVersion.split("\\."); + String[] latestParts = latestVersion.split("\\."); + + int maxLength = Math.max(currentParts.length, latestParts.length); + + for (int i = 0; i < maxLength; i++) { + int currentPart = i < currentParts.length ? Integer.parseInt(currentParts[i]) : 0; + int latestPart = i < latestParts.length ? Integer.parseInt(latestParts[i]) : 0; + + if (latestPart > currentPart) { + return true; + } else if (latestPart < currentPart) { + return false; + } + } + return false; + } catch (NumberFormatException e) { + Log.e(TAG, "版本号格式错误: current=" + currentVersion + ", latest=" + latestVersion, e); + return false; + } + } + + private static void showUpdateDialog(Context context, UpdateInfo updateInfo) { + if (!(context instanceof Activity)) { + return; + } + + Activity activity = (Activity) context; + activity.runOnUiThread(() -> { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle("发现新版本: " + updateInfo.version); + + String message = "New version available!\n\n"; + if (updateInfo.releaseNotes != null && !updateInfo.releaseNotes.isEmpty()) { + String notes = updateInfo.releaseNotes; + if (notes.length() > 300) { + notes = notes.substring(0, 300) + "..."; + } + message += "What's changed:\n" + notes + "\n\n"; + } + if (updateInfo.apkName != null) { + message += "File: " + updateInfo.apkName + "\n\n"; + } + message += "Please choose the download method"; + + builder.setMessage(message); + + builder.setPositiveButton("打开浏览器更新", (dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_RELEASE_PAGE)); + context.startActivity(intent); + }); + if (updateInfo.apkDownloadUrl != null) { + builder.setNeutralButton("直接下载", (dialog, which) -> startDirectDownload(context, updateInfo)); + } + builder.setNegativeButton("稍后", null); + builder.setCancelable(true); + + AlertDialog dialog = builder.create(); + dialog.show(); + }); + } + + private static void startDirectDownload(Context context, UpdateInfo info) { + try { + // 检查安装权限 + if (!canInstallApk(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + showInstallPermissionDialog(context); + } + return; + } + + String src = info.apkDownloadUrl; + String fileName = info.apkName != null ? info.apkName : ("moonlight-" + info.version + ".apk"); + + // 构造候选列表(代理优先,最后直连) + List candidates = new ArrayList<>(); + for (String p : PROXY_PREFIXES) { + candidates.add(p + src); + } + candidates.add(src); + + // 优先使用代理链接,提供备选方案 + String primaryUrl = candidates.get(0); + Toast.makeText(context, "开始下载: " + fileName, Toast.LENGTH_SHORT).show(); + + DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Request req = new DownloadManager.Request(Uri.parse(primaryUrl)); + req.setTitle("Moonlight V+ 更新下载"); + req.setDescription(fileName + " (下载完成后点击通知即可安装)"); + req.setMimeType("application/vnd.android.package-archive"); + req.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + req.setVisibleInDownloadsUi(true); + req.setAllowedOverMetered(true); + req.setAllowedOverRoaming(true); + req.addRequestHeader("User-Agent", "Mozilla/5.0 (Android; Mobile; rv:40.0)"); + req.addRequestHeader("Accept", "*/*"); + req.addRequestHeader("Referer", "https://github.com/"); + + // 设置下载路径 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + req.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + } else { + req.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + } + + long downloadId = dm.enqueue(req); + Log.d(TAG, "已启动下载,ID: " + downloadId + ", URL: " + primaryUrl); + Toast.makeText(context, "已开始下载,下载完成后点击通知栏即可安装", Toast.LENGTH_LONG).show(); + } catch (Exception e) { + Toast.makeText(context, "下载失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); + } + } + + // 检查是否可以安装APK + private static boolean canInstallApk(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return context.getPackageManager().canRequestPackageInstalls(); + } + return true; // Android 8.0以下不需要此权限 + } + + // 显示安装权限请求对话框 + @RequiresApi(api = Build.VERSION_CODES.O) + private static void showInstallPermissionDialog(Context context) { + if (!(context instanceof Activity)) { + Toast.makeText(context, "需要安装权限才能自动安装更新", Toast.LENGTH_LONG).show(); + return; + } + + Activity activity = (Activity) context; + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle("需要安装权限"); + builder.setMessage("为了自动安装更新,需要授予应用安装权限。\n\n点击确定前往设置页面开启权限。"); + builder.setPositiveButton(activity.getResources().getText(android.R.string.ok), (dialog, which) -> { + try { + Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES); + intent.setData(Uri.parse("package:" + context.getPackageName())); + activity.startActivity(intent); + } catch (Exception e) { + // 如果无法打开特定包名的设置,则打开通用设置 + Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES); + activity.startActivity(intent); + } + }); + builder.setNegativeButton(activity.getResources().getText(android.R.string.cancel), null); + builder.setCancelable(true); + builder.show(); + } + + // 检查是否需要更新代理列表 + private static boolean shouldUpdateProxyList(Context context) { + long currentTime = System.currentTimeMillis(); + long lastUpdateTime = context.getSharedPreferences("update_prefs", Context.MODE_PRIVATE) + .getLong(PREF_LAST_PROXY_UPDATE_TIME, 0); + return (currentTime - lastUpdateTime) > PROXY_CACHE_DURATION || PROXY_PREFIXES.length == 0; + } + + // 从 ghproxy.link 脚本中自动发现并更新代理地址 + private static void updateProxyList(Context context) { + try { + Log.d(TAG, "开始更新代理列表..."); + String scriptContent = fetchScriptContent(); + if (scriptContent != null) { + String[] newProxies = extractProxiesFromScript(scriptContent); + if (newProxies.length > 0) { + // 合并新代理和现有代理,去重 + Set allProxies = new HashSet<>(Arrays.asList(PROXY_PREFIXES)); + allProxies.addAll(Arrays.asList(newProxies)); + + PROXY_PREFIXES = allProxies.toArray(new String[0]); + + // 保存代理更新时间到SharedPreferences + context.getSharedPreferences("update_prefs", Context.MODE_PRIVATE) + .edit() + .putLong(PREF_LAST_PROXY_UPDATE_TIME, System.currentTimeMillis()) + .apply(); + + Log.d(TAG, "代理列表已更新,共 " + PROXY_PREFIXES.length + " 个代理:" + Arrays.toString(PROXY_PREFIXES)); + } + } + } catch (Exception e) { + Log.w(TAG, "更新代理列表失败: " + e.getMessage()); + } + } + + // 获取代理发现脚本内容 + private static String fetchScriptContent() { + try { + // 直接访问脚本,不使用代理避免循环依赖 + URL url = new URL(PROXY_DISCOVERY_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile; rv:40.0)"); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + + int responseCode = conn.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); + StringBuilder content = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + content.append(line).append("\n"); + } + reader.close(); + return content.toString(); + } + } catch (Exception e) { + Log.w(TAG, "获取代理发现脚本失败: " + e.getMessage()); + } + return null; + } + + // 从脚本内容中提取代理地址 + private static String[] extractProxiesFromScript(String scriptContent) { + List proxies = new ArrayList<>(); + + try { + // 匹配 JavaScript 脚本中的 URL 模式 - 扩展域名后缀 + String[] patterns = { + // 匹配引号中的完整URL: "https://xxx.com/" + "[\"']https://[\\w.-]+\\.(?:com|net|org|cn|top|cc|io|me|cf|tk|ml|ga|gg|xyz|site|online|tech|info|biz|work|space|shop|club|pro|dev|app|link|run|art|fun|live|store|world|today|design|cloud)/[\"']", + // 匹配变量赋值: baseUrl = "https://xxx.com/" + "baseUrl\\s*=\\s*[\"']https://[\\w.-]+\\.(?:com|net|org|cn|top|cc|io|me|cf|tk|ml|ga|gg|xyz|site|online|tech|info|biz|work|space|shop|club|pro|dev|app|link|run|art|fun|live|store|world|today|design|cloud)/[\"']", + // 匹配配置对象中的URL + "url:\\s*[\"']https://[\\w.-]+\\.(?:com|net|org|cn|top|cc|io|me|cf|tk|ml|ga|gg|xyz|site|online|tech|info|biz|work|space|shop|club|pro|dev|app|link|run|art|fun|live|store|world|today|design|cloud)/[\"']", + // 匹配GitHub代理特征的域名 + "[\"']https://(?:gh|mirror|proxy|cdn)[\\w.-]*\\.(?:com|net|org|cn|top|cc|io|me|cf|tk|ml|ga|gg|xyz|site|online|tech|info|biz|work|space|shop|club|pro|dev|app|link|run|art|fun|live|store|world|today|design|cloud)/[\"']" + }; + + for (String patternStr : patterns) { + Pattern pattern = Pattern.compile(patternStr, Pattern.CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(scriptContent); + + while (matcher.find()) { + String match = matcher.group(); + // 移除引号获取纯URL + String url = match.replaceAll("[\"']", ""); + + // 确保URL格式正确 + if (url.startsWith("https://") && url.endsWith("/")) { + // 过滤掉明显不是代理的地址 + if (isValidProxyUrl(url)) { + proxies.add(url); + Log.d(TAG, "发现代理地址: " + url); + } + } + } + } + + // 额外查找脚本中的域名配置 - 扩展域名后缀 + Pattern domainPattern = Pattern.compile("(?:proxy|mirror|gh|cdn)[\\w.-]*\\.(?:com|net|org|cn|top|cc|io|me|cf|tk|ml|ga|gg|xyz|site|online|tech|info|biz|work|space|shop|club|pro|dev|app|link|run|art|fun|live|store|world|today|design|cloud)", Pattern.CASE_INSENSITIVE); + Matcher domainMatcher = domainPattern.matcher(scriptContent); + + while (domainMatcher.find()) { + String domain = domainMatcher.group(); + String proxyUrl = "https://" + domain + "/"; + if (isValidProxyUrl(proxyUrl)) { + proxies.add(proxyUrl); + Log.d(TAG, "发现域名代理: " + proxyUrl); + } + } + + } catch (Exception e) { + Log.w(TAG, "解析代理地址失败: " + e.getMessage()); + } + + // 去重并返回 + Set uniqueProxies = new HashSet<>(proxies); + return uniqueProxies.toArray(new String[0]); + } + + // 验证代理URL是否有效 + private static boolean isValidProxyUrl(String url) { + if (url == null || url.length() < 15 || url.length() > 100) { + return false; + } + + // 排除明显不是代理的地址 + String[] blacklist = { + "github.com", "googleapis.com", "gstatic.com", + "jquery.com", "bootstrap.com", "cdnjs.com", + "unpkg.com", "jsdelivr.net", "ghproxy.link" + }; + + for (String blocked : blacklist) { + if (url.contains(blocked)) { + return false; + } + } + + // 检测是否会重定向回 ghproxy.link(失效代理的常见行为) + if (detectRedirectToGhproxyLink(url)) { + return false; + } + + return true; + } + + // 检测代理是否会重定向回 ghproxy.link + private static boolean detectRedirectToGhproxyLink(String proxyUrl) { + HttpURLConnection conn = null; + try { + // 测试访问一个简单的GitHub API + String testUrl = proxyUrl + "https://api.github.com/zen"; + URL url = new URL(testUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("HEAD"); + conn.setInstanceFollowRedirects(false); // 不自动跟随重定向 + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile; rv:40.0)"); + conn.setConnectTimeout(1000); // 进一步缩短超时时间 + conn.setReadTimeout(1000); + + int responseCode = conn.getResponseCode(); + + // 检查重定向 + if (responseCode >= 300 && responseCode < 400) { + String location = conn.getHeaderField("Location"); + if (location != null && location.contains("ghproxy.link")) { + return true; // 重定向回 ghproxy.link,说明代理失效 + } + } + + // 检查最终URL(即使没有重定向状态码,有些服务器可能直接返回ghproxy.link内容) + String finalUrl = conn.getURL().toString(); + if (finalUrl.contains("ghproxy.link")) { + return true; + } + + // 检查响应头中的服务器信息 + String server = conn.getHeaderField("Server"); + if (server != null && server.toLowerCase().contains("ghproxy")) { + return true; + } + + // 代理检测成功,没有重定向问题 + return false; + + } catch (Exception e) { + // 网络错误或异常情况,认为代理不可靠,排除 + Log.w(TAG, "代理检测异常,排除不可靠代理: " + proxyUrl + " - " + e.getMessage()); + return true; // 返回true表示有问题,应该排除 + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private static String httpGetWithProxies(String url) { + List tries = new ArrayList<>(); + tries.add(url); // 先尝试直连 + for (String p : PROXY_PREFIXES) { + tries.add(p + url); + } + + // 限制最大尝试次数,避免等待过久 + int maxTries = Math.min(tries.size(), 3); + for (int i = 0; i < maxTries; i++) { + String u = tries.get(i); + try { + HttpURLConnection connection = (HttpURLConnection) new URL(u).openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", "Moonlight-Android"); + connection.setConnectTimeout(3000); + connection.setReadTimeout(3000); + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + return response.toString(); + } + } catch (Exception e) { + Log.w(TAG, "Request failed, trying next: " + u + " - " + e.getMessage()); + } + } + return null; + } + + private static class UpdateInfo { + final String version; + final String releaseNotes; + final String apkName; + final String apkDownloadUrl; + + UpdateInfo(String version, String releaseNotes, String apkName, String apkDownloadUrl) { + this.version = version; + this.releaseNotes = releaseNotes; + this.apkName = apkName; + this.apkDownloadUrl = apkDownloadUrl; + } + } +} diff --git a/app/src/main/jni/moonlight-core/Android.mk b/app/src/main/jni/moonlight-core/Android.mk index f74231a333..55b0646619 100644 --- a/app/src/main/jni/moonlight-core/Android.mk +++ b/app/src/main/jni/moonlight-core/Android.mk @@ -28,6 +28,7 @@ LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \ moonlight-common-c/src/SimpleStun.c \ moonlight-common-c/src/VideoDepacketizer.c \ moonlight-common-c/src/VideoStream.c \ + moonlight-common-c/src/MicrophoneStream.c \ moonlight-common-c/reedsolomon/rs.c \ moonlight-common-c/enet/callbacks.c \ moonlight-common-c/enet/compress.c \ @@ -41,7 +42,7 @@ LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \ simplejni.c \ callbacks.c \ minisdl.c \ - + OpusEncoder.c \ LOCAL_C_INCLUDES := $(LOCAL_PATH)/moonlight-common-c/enet/include \ $(LOCAL_PATH)/moonlight-common-c/reedsolomon \ diff --git a/app/src/main/jni/moonlight-core/OpusEncoder.c b/app/src/main/jni/moonlight-core/OpusEncoder.c new file mode 100644 index 0000000000..914e2624d2 --- /dev/null +++ b/app/src/main/jni/moonlight-core/OpusEncoder.c @@ -0,0 +1,106 @@ +#include +#include +#include +#include "opus.h" + +typedef struct { + OpusEncoder* encoder; + int frameSize; +} OpusContext; + +JNIEXPORT jlong JNICALL +Java_com_limelight_binding_audio_OpusEncoder_nativeInit(JNIEnv* env, jclass clazz, + jint sampleRate, jint channels, jint bitrate) { + int error; + OpusContext* ctx = (OpusContext*)malloc(sizeof(OpusContext)); + if (!ctx) { + return 0; + } + + // 计算每帧采样数 (20ms @ 48kHz = 960 samples) + ctx->frameSize = sampleRate / 50; // 20ms帧 + + // 创建Opus编码器 + ctx->encoder = opus_encoder_create(sampleRate, channels, OPUS_APPLICATION_VOIP, &error); + if (error != OPUS_OK) { + free(ctx); + return 0; + } + + // 设置比特率 + opus_encoder_ctl(ctx->encoder, OPUS_SET_BITRATE(bitrate)); + + // 为VoIP优化设置 + opus_encoder_ctl(ctx->encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE)); + opus_encoder_ctl(ctx->encoder, OPUS_SET_COMPLEXITY(6)); // 降低复杂度以减少延迟 (从8降到6) + + // 启用DTX(不发送静音帧) + opus_encoder_ctl(ctx->encoder, OPUS_SET_DTX(1)); + + // 设置帧大小 + opus_encoder_ctl(ctx->encoder, OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_20_MS)); + + // 启用前向纠错 + opus_encoder_ctl(ctx->encoder, OPUS_SET_INBAND_FEC(1)); + + // 设置包丢失率预测 + opus_encoder_ctl(ctx->encoder, OPUS_SET_PACKET_LOSS_PERC(1)); + + return (jlong)ctx; +} + +JNIEXPORT jbyteArray +Java_com_limelight_binding_audio_OpusEncoder_nativeEncode(JNIEnv* env, jclass clazz, + jlong handle, jbyteArray pcmData, + jint offset, jint length) { + OpusContext* ctx = (OpusContext*)handle; + jbyte* pcm; + jbyteArray result = NULL; + + // 最大Opus帧大小 + unsigned char encoded[4000]; + int encodedLength; + + // 获取PCM数据 + pcm = (*env)->GetByteArrayElements(env, pcmData, NULL); + if (!pcm) { + return NULL; + } + + // 确保数据长度是正确的(必须是一个完整的帧) + int samples = length / 2; // 16位样本 (2字节) + + // 检查帧大小是否匹配 + if (samples != ctx->frameSize) { + // 如果帧大小不匹配,记录警告但继续处理 + // 这可能是由于音频捕获的微小变化 + } + + // 编码音频数据 + encodedLength = opus_encode(ctx->encoder, (const opus_int16*)(pcm + offset), + ctx->frameSize, encoded, sizeof(encoded)); + + // 释放PCM数据 + (*env)->ReleaseByteArrayElements(env, pcmData, pcm, JNI_ABORT); + + if (encodedLength > 0) { + // 创建结果数组 + result = (*env)->NewByteArray(env, encodedLength); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, encodedLength, (jbyte*)encoded); + } + } + + return result; +} + +JNIEXPORT void JNICALL +Java_com_limelight_binding_audio_OpusEncoder_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { + OpusContext* ctx = (OpusContext*)handle; + if (ctx) { + if (ctx->encoder) { + opus_encoder_destroy(ctx->encoder); + } + free(ctx); + } +} diff --git a/app/src/main/jni/moonlight-core/build-openssl.sh b/app/src/main/jni/moonlight-core/build-openssl.sh old mode 100755 new mode 100644 diff --git a/app/src/main/jni/moonlight-core/callbacks.c b/app/src/main/jni/moonlight-core/callbacks.c index 6b6e13ac5a..6d5a50e39f 100644 --- a/app/src/main/jni/moonlight-core/callbacks.c +++ b/app/src/main/jni/moonlight-core/callbacks.c @@ -168,7 +168,7 @@ int BridgeDrSubmitDecodeUnit(PDECODE_UNIT decodeUnit) { ret = (*env)->CallStaticIntMethod(env, GlobalBridgeClass, BridgeDrSubmitDecodeUnitMethod, DecodedFrameBuffer, currentEntry->length, currentEntry->bufferType, decodeUnit->frameNumber, decodeUnit->frameType, (jchar)decodeUnit->frameHostProcessingLatency, - (jlong)decodeUnit->receiveTimeMs, (jlong)decodeUnit->enqueueTimeMs); + (jlong)decodeUnit->receiveTimeUs, (jlong)decodeUnit->enqueueTimeUs); if ((*env)->ExceptionCheck(env)) { // We will crash here (*JVM)->DetachCurrentThread(JVM); @@ -189,7 +189,7 @@ int BridgeDrSubmitDecodeUnit(PDECODE_UNIT decodeUnit) { ret = (*env)->CallStaticIntMethod(env, GlobalBridgeClass, BridgeDrSubmitDecodeUnitMethod, DecodedFrameBuffer, offset, BUFFER_TYPE_PICDATA, decodeUnit->frameNumber, decodeUnit->frameType, (jchar)decodeUnit->frameHostProcessingLatency, - (jlong)decodeUnit->receiveTimeMs, (jlong)decodeUnit->enqueueTimeMs); + (jlong)decodeUnit->receiveTimeUs, (jlong)decodeUnit->enqueueTimeUs); if ((*env)->ExceptionCheck(env)) { // We will crash here (*JVM)->DetachCurrentThread(JVM); @@ -461,7 +461,8 @@ Java_com_limelight_nvstream_jni_MoonBridge_startConnection(JNIEnv *env, jclass c jint clientRefreshRateX100, jbyteArray riAesKey, jbyteArray riAesIv, jint videoCapabilities, - jint colorSpace, jint colorRange) { + jint colorSpace, jint colorRange, jboolean enableMic, + jboolean controlOnly) { SERVER_INFORMATION serverInfo = { .address = (*env)->GetStringUTFChars(env, address, 0), .serverInfoAppVersion = (*env)->GetStringUTFChars(env, appVersion, 0), @@ -481,7 +482,9 @@ Java_com_limelight_nvstream_jni_MoonBridge_startConnection(JNIEnv *env, jclass c .clientRefreshRateX100 = clientRefreshRateX100, .encryptionFlags = ENCFLG_AUDIO, .colorSpace = colorSpace, - .colorRange = colorRange + .colorRange = colorRange, + .enableMic = enableMic, + .controlOnly = controlOnly }; jbyte* riAesKeyBuf = (*env)->GetByteArrayElements(env, riAesKey, NULL); diff --git a/app/src/main/jni/moonlight-core/moonlight-common-c b/app/src/main/jni/moonlight-core/moonlight-common-c index 8af4562af6..3fcb64f790 160000 --- a/app/src/main/jni/moonlight-core/moonlight-common-c +++ b/app/src/main/jni/moonlight-core/moonlight-common-c @@ -1 +1 @@ -Subproject commit 8af4562af672dd6b9ed28553ead172984fd9a683 +Subproject commit 3fcb64f790626ee71a7f5212f19707a46f3f1a81 diff --git a/app/src/main/jni/moonlight-core/simplejni.c b/app/src/main/jni/moonlight-core/simplejni.c index 39f4843d76..6576158c07 100644 --- a/app/src/main/jni/moonlight-core/simplejni.c +++ b/app/src/main/jni/moonlight-core/simplejni.c @@ -10,6 +10,10 @@ #include "controller_type.h" #include "controller_list.h" +// 外部变量声明 +extern uint16_t MicPortNumber; +extern STREAM_CONFIGURATION StreamConfig; + JNIEXPORT void JNICALL Java_com_limelight_nvstream_jni_MoonBridge_sendMouseMove(JNIEnv *env, jclass clazz, jshort deltaX, jshort deltaY) { LiSendMouseMoveEvent(deltaX, deltaY); @@ -258,4 +262,21 @@ JNIEXPORT jboolean JNICALL Java_com_limelight_nvstream_jni_MoonBridge_guessControllerHasShareButton(JNIEnv *env, jclass clazz, jint vendorId, jint productId) { // Xbox Elite and DualSense Edge controllers have paddles return SDL_IsJoystickXboxSeriesX(vendorId, productId); -} \ No newline at end of file +} + +JNIEXPORT jint JNICALL +Java_com_limelight_nvstream_jni_MoonBridge_getHostFeatureFlags(JNIEnv *env, jclass clazz) { + return LiGetHostFeatureFlags(); +} + +JNIEXPORT jint JNICALL +Java_com_limelight_nvstream_jni_MoonBridge_getMicPortNumber(JNIEnv *env, jclass clazz) { + return MicPortNumber; +} + +JNIEXPORT jboolean JNICALL +Java_com_limelight_nvstream_jni_MoonBridge_isMicrophoneRequested(JNIEnv *env, jclass clazz) { + // 如果麦克风端口号已经协商好并且StreamConfig.enableMic为true,说明主机需要麦克风 + // 这表示主机已经通过RTSP协商请求了麦克风流 + return (MicPortNumber != 0 && StreamConfig.enableMic) ? JNI_TRUE : JNI_FALSE; +} \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libeasytier_android_jni.so b/app/src/main/jniLibs/arm64-v8a/libeasytier_android_jni.so new file mode 100644 index 0000000000..ac194013ee Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libeasytier_android_jni.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libeasytier_ffi.so b/app/src/main/jniLibs/arm64-v8a/libeasytier_ffi.so new file mode 100644 index 0000000000..0693294222 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libeasytier_ffi.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libiperf3.so b/app/src/main/jniLibs/arm64-v8a/libiperf3.so new file mode 100644 index 0000000000..156df021e4 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libiperf3.so differ diff --git a/app/src/main/res/anim/background_crossfade.xml b/app/src/main/res/anim/background_crossfade.xml new file mode 100644 index 0000000000..5511c40808 --- /dev/null +++ b/app/src/main/res/anim/background_crossfade.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/background_fadein.xml b/app/src/main/res/anim/background_fadein.xml new file mode 100644 index 0000000000..b7c01e89dc --- /dev/null +++ b/app/src/main/res/anim/background_fadein.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/background_fadeout.xml b/app/src/main/res/anim/background_fadeout.xml new file mode 100644 index 0000000000..57e1c9d340 --- /dev/null +++ b/app/src/main/res/anim/background_fadeout.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/boxart_fadein.xml b/app/src/main/res/anim/boxart_fadein.xml index 334481bba2..9bb9c4a079 100644 --- a/app/src/main/res/anim/boxart_fadein.xml +++ b/app/src/main/res/anim/boxart_fadein.xml @@ -3,6 +3,6 @@ + android:interpolator="@android:anim/decelerate_interpolator" + android:duration="300"/> \ No newline at end of file diff --git a/app/src/main/res/anim/boxart_fadeout.xml b/app/src/main/res/anim/boxart_fadeout.xml index 579c5a3f35..bd64117f8e 100644 --- a/app/src/main/res/anim/boxart_fadeout.xml +++ b/app/src/main/res/anim/boxart_fadeout.xml @@ -4,5 +4,5 @@ android:fromAlpha="1.0" android:toAlpha="0.0" android:interpolator="@android:anim/accelerate_interpolator" - android:duration="100"/> + android:duration="300"/> \ No newline at end of file diff --git a/app/src/main/res/anim/button_scale_animation.xml b/app/src/main/res/anim/button_scale_animation.xml new file mode 100644 index 0000000000..e5b09e525c --- /dev/null +++ b/app/src/main/res/anim/button_scale_animation.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/anim/button_scale_restore.xml b/app/src/main/res/anim/button_scale_restore.xml new file mode 100644 index 0000000000..22ea684164 --- /dev/null +++ b/app/src/main/res/anim/button_scale_restore.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/anim/game_menu_dialog_enter.xml b/app/src/main/res/anim/game_menu_dialog_enter.xml new file mode 100644 index 0000000000..3647353c93 --- /dev/null +++ b/app/src/main/res/anim/game_menu_dialog_enter.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/game_menu_dialog_exit.xml b/app/src/main/res/anim/game_menu_dialog_exit.xml new file mode 100644 index 0000000000..3eff82f2cb --- /dev/null +++ b/app/src/main/res/anim/game_menu_dialog_exit.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/perf_overlay_fadein.xml b/app/src/main/res/anim/perf_overlay_fadein.xml new file mode 100644 index 0000000000..431405363f --- /dev/null +++ b/app/src/main/res/anim/perf_overlay_fadein.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/perf_overlay_fadeout.xml b/app/src/main/res/anim/perf_overlay_fadeout.xml new file mode 100644 index 0000000000..97efdd86d0 --- /dev/null +++ b/app/src/main/res/anim/perf_overlay_fadeout.xml @@ -0,0 +1,17 @@ + + + + + \ 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 0000000000..d7cf9b7230 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,11 @@ + + + + + \ 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 0000000000..ae74f21a0e --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000000..4c139c59fb --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/address_list_item_selector.xml b/app/src/main/res/drawable/address_list_item_selector.xml new file mode 100644 index 0000000000..2d1dda6337 --- /dev/null +++ b/app/src/main/res/drawable/address_list_item_selector.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/app_dialog_bg_cute.xml b/app/src/main/res/drawable/app_dialog_bg_cute.xml new file mode 100644 index 0000000000..fb451542f4 --- /dev/null +++ b/app/src/main/res/drawable/app_dialog_bg_cute.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/app_progress_indeterminate.xml b/app/src/main/res/drawable/app_progress_indeterminate.xml new file mode 100644 index 0000000000..e30086604f --- /dev/null +++ b/app/src/main/res/drawable/app_progress_indeterminate.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_progress_spinner.xml b/app/src/main/res/drawable/app_progress_spinner.xml new file mode 100644 index 0000000000..91570dfa42 --- /dev/null +++ b/app/src/main/res/drawable/app_progress_spinner.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow.xml b/app/src/main/res/drawable/arrow.xml new file mode 100644 index 0000000000..9c94accc5d --- /dev/null +++ b/app/src/main/res/drawable/arrow.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_pressed_bg.xml b/app/src/main/res/drawable/btn_pressed_bg.xml new file mode 100644 index 0000000000..3ec60bf6a2 --- /dev/null +++ b/app/src/main/res/drawable/btn_pressed_bg.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_selector_background.xml b/app/src/main/res/drawable/button_selector_background.xml new file mode 100644 index 0000000000..29db6e50e8 --- /dev/null +++ b/app/src/main/res/drawable/button_selector_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_border.xml b/app/src/main/res/drawable/circle_border.xml new file mode 100644 index 0000000000..323a929145 --- /dev/null +++ b/app/src/main/res/drawable/circle_border.xml @@ -0,0 +1,4 @@ +> + + + diff --git a/app/src/main/res/drawable/confirm_square_border.xml b/app/src/main/res/drawable/confirm_square_border.xml new file mode 100644 index 0000000000..c43a6dcaed --- /dev/null +++ b/app/src/main/res/drawable/confirm_square_border.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/custom_progress_bar_drawable.xml b/app/src/main/res/drawable/custom_progress_bar_drawable.xml new file mode 100644 index 0000000000..d27b888e1f --- /dev/null +++ b/app/src/main/res/drawable/custom_progress_bar_drawable.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/details_content_bg.xml b/app/src/main/res/drawable/details_content_bg.xml new file mode 100644 index 0000000000..7b40e3a79f --- /dev/null +++ b/app/src/main/res/drawable/details_content_bg.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/disabled_square.xml b/app/src/main/res/drawable/disabled_square.xml new file mode 100644 index 0000000000..5115b71772 --- /dev/null +++ b/app/src/main/res/drawable/disabled_square.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_background.xml b/app/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 0000000000..ee05650a8b --- /dev/null +++ b/app/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_background_small.xml b/app/src/main/res/drawable/edit_text_background_small.xml new file mode 100644 index 0000000000..0f6301f114 --- /dev/null +++ b/app/src/main/res/drawable/edit_text_background_small.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/enabled_square.xml b/app/src/main/res/drawable/enabled_square.xml new file mode 100644 index 0000000000..8ee996bc47 --- /dev/null +++ b/app/src/main/res/drawable/enabled_square.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/game_menu_bitrate_bg.xml b/app/src/main/res/drawable/game_menu_bitrate_bg.xml new file mode 100644 index 0000000000..1700513078 --- /dev/null +++ b/app/src/main/res/drawable/game_menu_bitrate_bg.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/game_menu_button_bg.xml b/app/src/main/res/drawable/game_menu_button_bg.xml new file mode 100644 index 0000000000..0fff0680d8 --- /dev/null +++ b/app/src/main/res/drawable/game_menu_button_bg.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/game_menu_dialog_bg.xml b/app/src/main/res/drawable/game_menu_dialog_bg.xml new file mode 100644 index 0000000000..b45b02dade --- /dev/null +++ b/app/src/main/res/drawable/game_menu_dialog_bg.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/drawable/game_menu_list_item_bg.xml b/app/src/main/res/drawable/game_menu_list_item_bg.xml new file mode 100644 index 0000000000..41f827c330 --- /dev/null +++ b/app/src/main/res/drawable/game_menu_list_item_bg.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/game_menu_super_list_item_bg.xml b/app/src/main/res/drawable/game_menu_super_list_item_bg.xml new file mode 100644 index 0000000000..c77e4e8c10 --- /dev/null +++ b/app/src/main/res/drawable/game_menu_super_list_item_bg.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_advanced_settings.xml b/app/src/main/res/drawable/ic_advanced_settings.xml new file mode 100644 index 0000000000..24836cadb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_advanced_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000000..07bc825be4 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_audio_settings.xml b/app/src/main/res/drawable/ic_audio_settings.xml new file mode 100644 index 0000000000..45c5dbc8e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_basic_settings.xml b/app/src/main/res/drawable/ic_basic_settings.xml new file mode 100644 index 0000000000..c67360774a --- /dev/null +++ b/app/src/main/res/drawable/ic_basic_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_bitrate.xml b/app/src/main/res/drawable/ic_btn_bitrate.xml new file mode 100644 index 0000000000..4623e98f40 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_bitrate.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_esc.xml b/app/src/main/res/drawable/ic_btn_esc.xml new file mode 100644 index 0000000000..8e034b1004 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_esc.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_gyro.xml b/app/src/main/res/drawable/ic_btn_gyro.xml new file mode 100644 index 0000000000..3a93cd4d4c --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_gyro.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_hdr.xml b/app/src/main/res/drawable/ic_btn_hdr.xml new file mode 100644 index 0000000000..6a513f5467 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_hdr.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_mic.xml b/app/src/main/res/drawable/ic_btn_mic.xml new file mode 100644 index 0000000000..b4b8d0b9e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_mic_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_disabled.xml new file mode 100644 index 0000000000..b6b28e7b94 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_disabled.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_blue.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_blue.xml new file mode 100644 index 0000000000..fcc8ee00ce --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_blue.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_blue_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_blue_disabled.xml new file mode 100644 index 0000000000..f6575eff07 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_blue_disabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_green.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_green.xml new file mode 100644 index 0000000000..4db1c4a5c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_green.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_green_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_green_disabled.xml new file mode 100644 index 0000000000..c2c1d0a952 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_green_disabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_orange.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_orange.xml new file mode 100644 index 0000000000..f17b987994 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_orange.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_orange_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_orange_disabled.xml new file mode 100644 index 0000000000..ac9eac1fa5 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_orange_disabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_purple.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_purple.xml new file mode 100644 index 0000000000..692b4e8536 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_purple.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_purple_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_purple_disabled.xml new file mode 100644 index 0000000000..4fb231cf9c --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_purple_disabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_red.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_red.xml new file mode 100644 index 0000000000..51946a6538 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_red.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_mic_gradient_red_disabled.xml b/app/src/main/res/drawable/ic_btn_mic_gradient_red_disabled.xml new file mode 100644 index 0000000000..e3859ff2cc --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_mic_gradient_red_disabled.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btn_quit.xml b/app/src/main/res/drawable/ic_btn_quit.xml new file mode 100644 index 0000000000..e38b3b329d --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_quit.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_sleep.xml b/app/src/main/res/drawable/ic_btn_sleep.xml new file mode 100644 index 0000000000..c51f10e0d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_sleep.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_btn_win.xml b/app/src/main/res/drawable/ic_btn_win.xml new file mode 100644 index 0000000000..f540747ef7 --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_win.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cancel_cute.xml b/app/src/main/res/drawable/ic_cancel_cute.xml new file mode 100644 index 0000000000..2acf804d43 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel_cute.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_change.xml b/app/src/main/res/drawable/ic_change.xml new file mode 100644 index 0000000000..499c577117 --- /dev/null +++ b/app/src/main/res/drawable/ic_change.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000000..8643b4fe67 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cmd_cute.xml b/app/src/main/res/drawable/ic_cmd_cute.xml new file mode 100644 index 0000000000..8c3221af9e --- /dev/null +++ b/app/src/main/res/drawable/ic_cmd_cute.xml @@ -0,0 +1,34 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_computer.xml b/app/src/main/res/drawable/ic_computer.xml index 2617515737..dc4123a0eb 100644 --- a/app/src/main/res/drawable/ic_computer.xml +++ b/app/src/main/res/drawable/ic_computer.xml @@ -1,9 +1,27557 @@ + + android:viewportWidth="512" + android:viewportHeight="512"> + - + android:fillColor="#40c7e8" + android:fillAlpha="0.996078431372549" + android:strokeColor="#40c7e8" + android:strokeAlpha="0.996078431372549" + android:strokeWidth="1" + android:pathData="M 272 8 Q 278 6 276 12 L 272 12 L 272 16 Q 266.8 13.9 268 18.5 L 264 20 L 265.5 16 Q 270.1 17.2 268 12 L 272 12 L 272 8 Z" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_controller_cute.xml b/app/src/main/res/drawable/ic_controller_cute.xml new file mode 100644 index 0000000000..f27e25a458 --- /dev/null +++ b/app/src/main/res/drawable/ic_controller_cute.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000000..3f0659a830 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_decode.xml b/app/src/main/res/drawable/ic_decode.xml new file mode 100644 index 0000000000..fee4dbc76c --- /dev/null +++ b/app/src/main/res/drawable/ic_decode.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..eb27865056 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_disconnect_cute.xml b/app/src/main/res/drawable/ic_disconnect_cute.xml new file mode 100644 index 0000000000..12494a3deb --- /dev/null +++ b/app/src/main/res/drawable/ic_disconnect_cute.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml new file mode 100644 index 0000000000..f36df87501 --- /dev/null +++ b/app/src/main/res/drawable/ic_done.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_enhance.xml b/app/src/main/res/drawable/ic_enhance.xml new file mode 100644 index 0000000000..ab6a9e2a9d --- /dev/null +++ b/app/src/main/res/drawable/ic_enhance.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000000..833d7e3435 --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_gamepad_settings.xml b/app/src/main/res/drawable/ic_gamepad_settings.xml new file mode 100644 index 0000000000..45c5dbc8e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_gamepad_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_grid_view.xml b/app/src/main/res/drawable/ic_grid_view.xml new file mode 100644 index 0000000000..aa3bc8150f --- /dev/null +++ b/app/src/main/res/drawable/ic_grid_view.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_host_keyboard.xml b/app/src/main/res/drawable/ic_host_keyboard.xml new file mode 100644 index 0000000000..bef0e8ea86 --- /dev/null +++ b/app/src/main/res/drawable/ic_host_keyboard.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_host_settings.xml b/app/src/main/res/drawable/ic_host_settings.xml new file mode 100644 index 0000000000..7b44bcf343 --- /dev/null +++ b/app/src/main/res/drawable/ic_host_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000000..a6baa82bed --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_input_settings.xml b/app/src/main/res/drawable/ic_input_settings.xml new file mode 100644 index 0000000000..9d2fe3fb28 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_cute.xml b/app/src/main/res/drawable/ic_keyboard_cute.xml new file mode 100644 index 0000000000..daa5637e59 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_cute.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000000..576ada2552 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000000..d83ca06dd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_list_view.xml b/app/src/main/res/drawable/ic_list_view.xml new file mode 100644 index 0000000000..d5662d447c --- /dev/null +++ b/app/src/main/res/drawable/ic_list_view.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_menu_item_default.xml b/app/src/main/res/drawable/ic_menu_item_default.xml new file mode 100644 index 0000000000..4b324227c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_item_default.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mic_gm.xml b/app/src/main/res/drawable/ic_mic_gm.xml new file mode 100644 index 0000000000..7d75ac878b --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_gm.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mic_gm_disabled.xml b/app/src/main/res/drawable/ic_mic_gm_disabled.xml new file mode 100644 index 0000000000..175f94ae78 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_gm_disabled.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mouse_cute.xml b/app/src/main/res/drawable/ic_mouse_cute.xml new file mode 100644 index 0000000000..e4d30637ff --- /dev/null +++ b/app/src/main/res/drawable/ic_mouse_cute.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mouse_emulation_cute.xml b/app/src/main/res/drawable/ic_mouse_emulation_cute.xml new file mode 100644 index 0000000000..7f1e675355 --- /dev/null +++ b/app/src/main/res/drawable/ic_mouse_emulation_cute.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_network_control.xml b/app/src/main/res/drawable/ic_network_control.xml new file mode 100644 index 0000000000..f84b0b5d0e --- /dev/null +++ b/app/src/main/res/drawable/ic_network_control.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pc_offline.xml b/app/src/main/res/drawable/ic_pc_offline.xml index 983ddf4d7e..7c14b003b0 100644 --- a/app/src/main/res/drawable/ic_pc_offline.xml +++ b/app/src/main/res/drawable/ic_pc_offline.xml @@ -1,9 +1,17 @@ + + android:width="1200dp" + android:height="1200dp" + android:viewportWidth="1200" + android:viewportHeight="1200"> + + + - + android:pathData="M331.12,579.984c7.148-9.71,14.296-19.42,21.444-29.129c7.169-9.744,14.34-19.489,21.508-29.219 c2.741-3.733,5.482-7.453,8.223-11.173c2.101-2.864,4.566-5.666,6.646-8.585c0.478-0.669,0.935-1.346,1.364-2.029 c1.181-1.898,1.615-4.154,1.726-6.383c0.165-3.319-0.37-6.735-1.976-9.655c-1.849-3.361-5.041-5.859-8.601-7.281 c-5.742-2.291-11.921-1.477-17.944-1.498c-10.538-0.055-21.076-0.104-31.615-0.152c-10.902-0.055-21.804-0.104-32.706-0.145 c-5.026-0.028-10.052-0.055-15.078-0.076c-8.863-0.034-16.807,7.515-16.85,16.694c-0.043,9.496,7.834,16.804,16.696,16.852 l57.907,0.269c-2.58,3.526-5.158,7.053-7.735,10.586c-5.972,8.171-11.945,16.349-17.917,24.519 c-7.072,9.682-14.144,19.357-21.215,29.047c-5.876,8.04-11.752,16.086-17.629,24.126c-4.821,6.597-12.264,13.788-10.275,22.56 c1.058,4.665,4.198,8.73,8.21,11.331c0.827,0.531,1.697,1.014,2.617,1.373c4.104,1.58,9.056,0.821,13.366,0.89 c2.123,0.048,4.248,0.028,6.371,0.034c5.432,0.028,10.863,0.055,16.295,0.076c13.469,0.062,26.939,0.124,40.407,0.179 c6.827,0.034,13.655,0.069,20.481,0.097c8.85,0.041,16.795-7.515,16.838-16.687c0.043-9.503-7.834-16.818-16.684-16.859 l-58.229-0.262c0.386-0.524,0.772-1.049,1.158-1.573C322.322,591.936,326.72,585.96,331.12,579.984z M905.999,502.195 c7.562-9.827,15.376-19.992,22.447-31.179c2.165-3.526,4.431-7.688,4.753-11.939c0.287-3.865-1.133-8.15-3.89-11.787 c-4.923-6.515-11.841-9.172-23.868-9.172l-8.052,0.076l-124.565-0.642c-10.86,0-20.434,9.517-20.485,20.379 c-0.034,5.493,2.132,10.683,6.107,14.582c3.84,3.768,9.033,5.949,14.277,5.983l88.603,0.442l9.674,0.083l-10.876,15.562 l-93.39,127.704c-1.421,1.939-2.96,3.865-4.499,5.811c-3.18,3.996-6.478,8.122-8.542,12.263c-1.591,3.216-2.301,6.818-2.047,10.421 c0.812,11.69,11.875,17.46,21.957,18.964c3.315,0.497,6.681,0.649,10.031,0.649c3.112,0,6.207-0.124,10.86-0.317 c11.841-0.476,23.698-0.635,35.556-0.635c13.194,0,26.389,0.2,39.599,0.407c10.555,0.159,21.11,0.317,31.666,0.38l1.928,0.007 c6.005,0,12.331-0.2,17.592-2.698c6.953-3.292,11.757-11.276,11.419-18.985c-0.339-7.702-5.853-15.21-13.093-17.874 c-4.686-1.725-10.233-1.746-15.579-1.753l-91.039-0.269l82.598-112.121C898.675,511.725,902.329,506.964,905.999,502.195z M649.468,480.546c4.449-7.736,5.565-14.037,3.417-19.254c-1.843-4.472-6.496-8.233-12.433-10.048 c-5.785-1.78-12.196-1.794-18.403-1.808l-104.098-0.324c-6.056,0-12.399,0.221-17.524,2.85c-5.853,2.988-9.946,9.413-10.15,15.997 c-0.236,6.563,3.417,13.243,9.067,16.625c6.174,3.699,14.259,3.706,21.381,3.706l74.901,0.007l-98.888,135.668 c-3.739,5.121-6.614,9.413-6.986,14.195c-0.119,1.553,0.034,3.119,0.456,4.651c1.37,4.914,5.345,11.076,10.319,13.374 c2.52,1.159,5.498,1.836,9.642,2.174c3.349,0.283,6.716,0.373,10.082,0.373c2.892,0,14.581-0.255,17.524-0.255l4.431,0.048 c4.923,0.138,9.862,0.173,14.801,0.173l69.421,0.145c5.904,0,11.588-0.193,16.408-2.443c6.056-2.823,10.386-9.199,10.775-15.852 c0.355-6.687-3.214-13.512-8.881-17.004c-5.852-3.582-13.059-3.871-21.871-3.871h-75.054l96.012-130.623 C645.797,486.392,647.776,483.535,649.468,480.546z" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_performance_cute.xml b/app/src/main/res/drawable/ic_performance_cute.xml new file mode 100644 index 0000000000..9b27c0261f --- /dev/null +++ b/app/src/main/res/drawable/ic_performance_cute.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_play_cute.xml b/app/src/main/res/drawable/ic_play_cute.xml new file mode 100644 index 0000000000..738e3dc524 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_cute.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_bottom_center.xml b/app/src/main/res/drawable/ic_position_bottom_center.xml new file mode 100644 index 0000000000..b49acd9da6 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_bottom_center.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_bottom_left.xml b/app/src/main/res/drawable/ic_position_bottom_left.xml new file mode 100644 index 0000000000..464e4ffd88 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_bottom_left.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_bottom_right.xml b/app/src/main/res/drawable/ic_position_bottom_right.xml new file mode 100644 index 0000000000..ad174494a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_bottom_right.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_center.xml b/app/src/main/res/drawable/ic_position_center.xml new file mode 100644 index 0000000000..49ce8c45a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_center.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_center_left.xml b/app/src/main/res/drawable/ic_position_center_left.xml new file mode 100644 index 0000000000..b3787c19aa --- /dev/null +++ b/app/src/main/res/drawable/ic_position_center_left.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_center_right.xml b/app/src/main/res/drawable/ic_position_center_right.xml new file mode 100644 index 0000000000..adcb86e60b --- /dev/null +++ b/app/src/main/res/drawable/ic_position_center_right.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_top_center.xml b/app/src/main/res/drawable/ic_position_top_center.xml new file mode 100644 index 0000000000..6849f8cc12 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_top_center.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_top_left.xml b/app/src/main/res/drawable/ic_position_top_left.xml new file mode 100644 index 0000000000..81b25eac3e --- /dev/null +++ b/app/src/main/res/drawable/ic_position_top_left.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_position_top_right.xml b/app/src/main/res/drawable/ic_position_top_right.xml new file mode 100644 index 0000000000..9b14ad8415 --- /dev/null +++ b/app/src/main/res/drawable/ic_position_top_right.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_qq.xml b/app/src/main/res/drawable/ic_qq.xml new file mode 100644 index 0000000000..d349f39786 --- /dev/null +++ b/app/src/main/res/drawable/ic_qq.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml new file mode 100644 index 0000000000..00267bd024 --- /dev/null +++ b/app/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rumble_cute.xml b/app/src/main/res/drawable/ic_rumble_cute.xml new file mode 100644 index 0000000000..9232df4b7b --- /dev/null +++ b/app/src/main/res/drawable/ic_rumble_cute.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_scene_circle.xml b/app/src/main/res/drawable/ic_scene_circle.xml new file mode 100644 index 0000000000..f7fc399e22 --- /dev/null +++ b/app/src/main/res/drawable/ic_scene_circle.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_controls.xml b/app/src/main/res/drawable/ic_screen_controls.xml new file mode 100644 index 0000000000..c8f2046731 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_controls.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_position.xml b/app/src/main/res/drawable/ic_screen_position.xml new file mode 100644 index 0000000000..84a9c37d63 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_position.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_send_keys_cute.xml b/app/src/main/res/drawable/ic_send_keys_cute.xml new file mode 100644 index 0000000000..47dd2bf284 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_keys_cute.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_super_crown.xml b/app/src/main/res/drawable/ic_super_crown.xml new file mode 100644 index 0000000000..67c70c7749 --- /dev/null +++ b/app/src/main/res/drawable/ic_super_crown.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_touch_settings.xml b/app/src/main/res/drawable/ic_touch_settings.xml new file mode 100644 index 0000000000..b287385b1e --- /dev/null +++ b/app/src/main/res/drawable/ic_touch_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_triangle_left.xml b/app/src/main/res/drawable/ic_triangle_left.xml new file mode 100644 index 0000000000..1e733c5688 --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_left.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ui_settings.xml b/app/src/main/res/drawable/ic_ui_settings.xml new file mode 100644 index 0000000000..e0f625b1a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_settings.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 0000000000..e58feb5453 --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000000..0818a67bd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/icon_background.xml b/app/src/main/res/drawable/icon_background.xml new file mode 100644 index 0000000000..7bae18055f --- /dev/null +++ b/app/src/main/res/drawable/icon_background.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_pulse_background.xml b/app/src/main/res/drawable/icon_pulse_background.xml new file mode 100644 index 0000000000..26c8360c02 --- /dev/null +++ b/app/src/main/res/drawable/icon_pulse_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/keyboard_background.xml b/app/src/main/res/drawable/keyboard_background.xml new file mode 100644 index 0000000000..5d019a491f --- /dev/null +++ b/app/src/main/res/drawable/keyboard_background.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/keyboard_function_key_normal.xml b/app/src/main/res/drawable/keyboard_function_key_normal.xml new file mode 100644 index 0000000000..9cfb112eb8 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_function_key_normal.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/keyboard_function_key_pressed.xml b/app/src/main/res/drawable/keyboard_function_key_pressed.xml new file mode 100644 index 0000000000..869937e4ad --- /dev/null +++ b/app/src/main/res/drawable/keyboard_function_key_pressed.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/keyboard_function_key_selector.xml b/app/src/main/res/drawable/keyboard_function_key_selector.xml new file mode 100644 index 0000000000..93ba7c008f --- /dev/null +++ b/app/src/main/res/drawable/keyboard_function_key_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/keyboard_key_normal.xml b/app/src/main/res/drawable/keyboard_key_normal.xml new file mode 100644 index 0000000000..abc917f60c --- /dev/null +++ b/app/src/main/res/drawable/keyboard_key_normal.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/keyboard_key_pressed.xml b/app/src/main/res/drawable/keyboard_key_pressed.xml new file mode 100644 index 0000000000..c85713f754 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_key_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/keyboard_key_selector.xml b/app/src/main/res/drawable/keyboard_key_selector.xml new file mode 100644 index 0000000000..b7e5c3e858 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_key_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/minus.xml b/app/src/main/res/drawable/minus.xml new file mode 100644 index 0000000000..2f74b60813 --- /dev/null +++ b/app/src/main/res/drawable/minus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/mouse_bottom.xml b/app/src/main/res/drawable/mouse_bottom.xml new file mode 100644 index 0000000000..a51c2f73b8 --- /dev/null +++ b/app/src/main/res/drawable/mouse_bottom.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/mouse_left.xml b/app/src/main/res/drawable/mouse_left.xml new file mode 100644 index 0000000000..b33f8b2cd2 --- /dev/null +++ b/app/src/main/res/drawable/mouse_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/mouse_right.xml b/app/src/main/res/drawable/mouse_right.xml new file mode 100644 index 0000000000..a8e0244bf2 --- /dev/null +++ b/app/src/main/res/drawable/mouse_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/notification_background.xml b/app/src/main/res/drawable/notification_background.xml new file mode 100644 index 0000000000..104df4386d --- /dev/null +++ b/app/src/main/res/drawable/notification_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/pc_item_multiple_addresses_bg.xml b/app/src/main/res/drawable/pc_item_multiple_addresses_bg.xml new file mode 100644 index 0000000000..6c8bae752f --- /dev/null +++ b/app/src/main/res/drawable/pc_item_multiple_addresses_bg.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/pc_item_multiple_addresses_selector.xml b/app/src/main/res/drawable/pc_item_multiple_addresses_selector.xml new file mode 100644 index 0000000000..7ca0d85ffd --- /dev/null +++ b/app/src/main/res/drawable/pc_item_multiple_addresses_selector.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/pc_item_selector.xml b/app/src/main/res/drawable/pc_item_selector.xml new file mode 100644 index 0000000000..0b170ea941 --- /dev/null +++ b/app/src/main/res/drawable/pc_item_selector.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_divider.xml b/app/src/main/res/drawable/preference_divider.xml new file mode 100644 index 0000000000..7cd76d863f --- /dev/null +++ b/app/src/main/res/drawable/preference_divider.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/right_arrow.xml b/app/src/main/res/drawable/right_arrow.xml new file mode 100644 index 0000000000..48d20299f7 --- /dev/null +++ b/app/src/main/res/drawable/right_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_corner.xml b/app/src/main/res/drawable/rounded_corner.xml new file mode 100644 index 0000000000..e08296ca08 --- /dev/null +++ b/app/src/main/res/drawable/rounded_corner.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/saba.webp b/app/src/main/res/drawable/saba.webp new file mode 100644 index 0000000000..5a6d248879 Binary files /dev/null and b/app/src/main/res/drawable/saba.webp differ diff --git a/app/src/main/res/drawable/scene_btn_background.xml b/app/src/main/res/drawable/scene_btn_background.xml new file mode 100644 index 0000000000..49f927f2fd --- /dev/null +++ b/app/src/main/res/drawable/scene_btn_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/scene_btn_bg_selector.xml b/app/src/main/res/drawable/scene_btn_bg_selector.xml new file mode 100644 index 0000000000..cdeccabe36 --- /dev/null +++ b/app/src/main/res/drawable/scene_btn_bg_selector.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selection_indicator.xml b/app/src/main/res/drawable/selection_indicator.xml new file mode 100644 index 0000000000..9ed59d3975 --- /dev/null +++ b/app/src/main/res/drawable/selection_indicator.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/settings_info_background.xml b/app/src/main/res/drawable/settings_info_background.xml new file mode 100644 index 0000000000..157fe9939c --- /dev/null +++ b/app/src/main/res/drawable/settings_info_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/simplify_performance_background.xml b/app/src/main/res/drawable/simplify_performance_background.xml new file mode 100644 index 0000000000..44ed9a7d85 --- /dev/null +++ b/app/src/main/res/drawable/simplify_performance_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/square_border.xml b/app/src/main/res/drawable/square_border.xml new file mode 100644 index 0000000000..4dc42ccb27 --- /dev/null +++ b/app/src/main/res/drawable/square_border.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/super_content_box_background.xml b/app/src/main/res/drawable/super_content_box_background.xml new file mode 100644 index 0000000000..cfd1fcec87 --- /dev/null +++ b/app/src/main/res/drawable/super_content_box_background.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toggle_button_background.xml b/app/src/main/res/drawable/toggle_button_background.xml new file mode 100644 index 0000000000..c5c9ffcb0f --- /dev/null +++ b/app/src/main/res/drawable/toggle_button_background.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/vplus.webp b/app/src/main/res/drawable/vplus.webp new file mode 100644 index 0000000000..5aebda1104 Binary files /dev/null and b/app/src/main/res/drawable/vplus.webp differ diff --git a/app/src/main/res/drawable/white_dots_animation.xml b/app/src/main/res/drawable/white_dots_animation.xml new file mode 100644 index 0000000000..1aac91c70a --- /dev/null +++ b/app/src/main/res/drawable/white_dots_animation.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/activity_pc_view.xml b/app/src/main/res/layout-land/activity_pc_view.xml index f745fe0a04..6ce6e28724 100644 --- a/app/src/main/res/layout-land/activity_pc_view.xml +++ b/app/src/main/res/layout-land/activity_pc_view.xml @@ -2,16 +2,20 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingLeft="@dimen/activity_horizontal_margin" - android:paddingRight="@dimen/activity_horizontal_margin" - android:paddingTop="@dimen/activity_vertical_margin" tools:context=".PcView" > + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-w576dp/custom_dialog.xml b/app/src/main/res/layout-w576dp/custom_dialog.xml new file mode 100644 index 0000000000..16035d3e87 --- /dev/null +++ b/app/src/main/res/layout-w576dp/custom_dialog.xml @@ -0,0 +1,549 @@ + + + + + + + + + + + + + + + + + + +