Author SHA1 Message Date
Zhou Weidong 45e867561d chore(ios): 补充 CocoaPods 配置(Podfile 与 Pods xcconfig 引用) 2026-10-19 13:23:03 +08:00
Zhou Weidong 64fa3b1efe build: release 开启 R8 混淆与资源压缩(反编译保护) 2026-10-17 03:03:52 +08:00
Zhou Weidong faaf2f7283 feat: C3 客户端埋点 SDK(EventTracker 批量上报 + 核心事件) 2026-10-17 02:55:47 +08:00
Zhou Weidong 070ec78668 feat: A3 数据层 AppCatalogController(feed/详情/分类加载,供页面切换) 2026-10-17 02:43:41 +08:00
Zhou Weidong 88a401512d feat: A3-A6 数据层(内容/播放/记录收藏/互动的 HTTP 对接)
- AppCatalog 模型(feed/section/card/detail/episode/banner/category)
- HttpCatalogRepository 对接首页/发现/分类/详情
- PlaybackRepository 播放票据、WatchRepository 记录收藏
- InteractionRepository 评论点赞举报、WalletRepository 钱包解锁
2026-10-17 02:31:47 +08:00
Zhou Weidong a7ac3b7fc7 feat: A1 网络层与鉴权 + A2 账号登录对接
- 引入 dio,封装统一 ApiClient(BaseUrl/Token 注入/401 自动刷新/{code,message,data} 解包)
- TokenStorage 令牌持久化,AuthController 登录状态管理
- AuthRepository 封装 otp/login/logout 接口
- 登录页对接手机号/邮箱验证码登录(Google 登录待 OAuth client 配置)
2026-10-17 02:30:16 +08:00
Zhou Weidong 015677d1a8 feat: 新增登录页 + 主题改橙红对齐设计稿
- 新增登录页(Google / 手机号 / 邮箱 + 游客浏览 + 首登送金币)
- seedColor 改橙红 #FF5B3D,themeMode 改 dark,背景对齐设计稿 #0B0C10
- app 启动入口改为 LoginPage
2026-10-17 02:24:30 +08:00
624 changed files with 6363 additions and 94517 deletions

No files matched your search

-25
View File
@@ -1,25 +0,0 @@
# Author: fruit.com.cn
# ReelShort 客户端 CI:静态分析 + 测试门禁
name: flutter-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: 依赖
run: flutter pub get
- name: 静态分析(零告警门禁)
run: dart analyze --fatal-infos
- name: 单元测试(全绿门禁)
run: flutter test -r expanded
+1 -10
View File
@@ -45,13 +45,7 @@ app.*.map.json
/android/app/release
# Local demo media and internal planning are absent from the public repo.
# Launcher/splash source icons are required at build time, so keep them tracked.
/assets/*
!/assets/icon/
!/assets/icon/*.png
!/assets/fonts/
!/assets/fonts/*.ttf
!/assets/fonts/OFL-*.txt
/assets/
/server/
/docs/
/AGENTS.md
@@ -65,6 +59,3 @@ app.*.map.json
.env
.env.*
!.env.example
# IDE 的 Flutter Widget Preview 生成目录。
.widget_preview/
+11 -206
View File
@@ -1,212 +1,17 @@
# FruitShorts(ReelShort)客户端
一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验
FruitShorts 是面向印尼市场的短剧 Flutter 客户端,已连接 `FruitServer` 的
`/app/**` 接口。功能覆盖:四 tab 主页(推荐视频流 / 找剧 / 追剧 / 我的)、
沉浸式播放与全屏剧集播放器、清晰度切换、剧单与排行榜、新剧页、搜索、观看记录、
收藏短剧、评论与点赞、站内通知、钱包与商品、下载落盘与离线播放、中英印尼三语,
以及 Android / iOS 双端画中画(退到后台自动进小窗)。
## 主要功能
## 技术栈
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
- Flutter 3.47.7 / Dart 3.13.5(`environment.sdk: ^3.12.2`)
- Provider 状态管理与依赖注入
- dio 网络请求
- video_player 播放
- shared_preferences 与 `SharedPreferencesAsync` 本地配置
- path_provider + dart:io 下载落盘(先写 `.part`,完成后原子改名)
- flutter_localizations,支持 `en`、`zh`、`id`
- 画中画:Android 用 `floating` + `permission_handler`(需悬浮窗权限);
iOS 用原生 `AVPictureInPictureController` + 影子播放器(`ios/Runner/AppDelegate.swift`)
## 技术实现
## 目录结构
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
```text
lib/
core/
config/ 地址、语言和运行时配置
data/ models、remote API、repository、controller
formatting/ 文案与日期格式化
platform/ 平台能力:画中画通道、设备号、触觉
theme/ 颜色与主题
widgets/ 跨页面复用组件
l10n/ 三语 ARB 与生成代码
pages/ 业务页面
test/ 单元、组件、页面和联调测试
integration_test/ 设备侧集成测试
android/ Android 工程
ios/ iOS 工程与 SPM 配置
assets/ 图标、图片和本地媒体文件
```
## 当前说明
## 已接接口
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 47 个不同路径。
| 分组 | 接口 |
| --- | --- |
| 账号 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout`、`/otp`;`GET /app/me`、`/app/me/devices`、`/app/me/export`;`POST /app/me/deactivate`、`/app/me/deactivate/cancel` |
| 内容 | `GET /app/home/feed`、`/app/feed`、`/app/discover/feed`、`/app/categories`、`/app/categories/{id}/dramas`、`/app/drama-categories`、`/app/dramas/page`、`/app/dramas/{id}`、`/app/tags`、`/app/search`、`/app/playlists`、`/app/playlists/{id}` |
| 播放 | `GET /app/episodes/{id}/play`、`/app/episodes/{id}/stats`;`POST /app/episodes/{id}/unlock` |
| 观看记录 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
| 收藏 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、`POST /app/favorites/remove` |
| 下载 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{id}/url`、`POST /app/downloads/{id}/progress`、`DELETE /app/downloads/{id}`、`DELETE /app/downloads` |
| 互动 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`GET /app/comments/count`、`POST /app/likes/toggle`、`POST /app/reports` |
| 商业化 | `GET /app/wallet`、`/app/products`、`/app/orders` |
| 公告 | `GET /app/notices` |
客户端没有调用 `/app/config` 和创作者入驻接口。
## 运行
### 安装依赖
```bash
flutter pub get
```
### Android USB
手机开启 USB 调试并连接电脑后:
```bash
adb devices
adb reverse tcp:8081 tcp:8081
flutter run -d <device>
```
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
重新执行。此时用调试条把地址改成 `http://127.0.0.1:8081`(见下节)。
Android 模拟器访问宿主机时,同样在调试条里填 `http://10.0.2.2:8081`。
### iOS
iOS 模拟器直接访问 Mac 本机服务,默认地址为 `http://127.0.0.1:8081`:
```bash
flutter run -d <simulator-udid>
```
iPhone 真机不能用 `127.0.0.1` 访问 Mac;真机联调时需让手机与 Mac 位于同一网络,
并通过调试入口临时填写 Mac 的局域网地址。
### 服务地址配置
地址是代码里的一个开关,**不做任何网络探测**,也不用传构建参数。两套环境各占
一个文件:
| 文件 | 用途 | 默认值 |
| --- | --- | --- |
| `lib/core/config/dev_config.dart` | 开发 / 联调 | `http://127.0.0.1:8081` |
| `lib/core/config/prod_config.dart` | 生产 | `http://39.162.50.99:8081` |
切换环境只改 `lib/core/config/app_config.dart` 里的一个布尔值:
```dart
static const bool useDevConfig = true; // false 即用生产地址
```
真机或 Android 模拟器联调时,通过调试入口覆盖地址,不修改默认开发配置。打生产包前把
`useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
避免线上包连到开发机。
debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
生效,release 不读取)。
## 画中画
| 平台 | 行为 |
| --- | --- |
| Android | 首次点播放页小窗按钮时申请悬浮窗权限;授权后退出 App 自动进入系统小窗。沉浸流与独立播放页共用一份引用计数,任一处活跃都算「在播」,避免互相顶掉 |
| iOS | 原生影子播放器承载 `AVPictureInPictureController`,只有播放页处于活跃状态时退到后台才自动进小窗;停在剧单等页面退后台不会触发 |
- 小窗内切集直接换源,不走页面翻页,可连续自动播放
- iOS 从系统小窗还原时用原生过渡层盖住纹理黑帧,等首帧就绪后再淡出
- 播放页「更多」面板里有「退出自动小窗」开关(仅支持自动小窗的平台显示),关掉后
退到后台不再进入小窗
## 环境注意
### iOS Swift 版本
`pubspec.yaml` 将 `shared_preferences_foundation` 固定为 `2.5.6`。该版本兼容
Xcode 16.2 / Swift 6.0.3;`2.6.0` 的生成代码使用 Swift 6.1 才支持的 trailing
comma。不要手工升级该依赖,`flutter pub get` 后应显示
`shared_preferences_foundation 2.5.6 (overridden)`。
### iOS SPM 包缺失
如果 Xcode 报 `Missing package product FlutterGeneratedPluginSwiftPackage`:
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-*
flutter clean
flutter pub get
xcodebuild -resolvePackageDependencies \
-project ios/Runner.xcodeproj \
-scheme Runner
```
随后重新打开工程构建。`Runner.xcscheme` 的 PreAction 会先执行
`flutter pub get`,再执行 Flutter prepare。
### Android libdartjni
若启动时报 `libdartjni.so` 加载失败,通常是旧安装中的原生库残留:
```bash
adb uninstall com.stormg.lumadrama
flutter run -d <device>
```
## 生产打包(Android)
两个渠道出两种产物,源码同一套:
| 渠道 | 产物 | 命令 | 体积 |
| --- | --- | --- | --- |
| Google Play | AAB(按设备下发) | `flutter build appbundle --release` | 上传 28MB,用户下载约 8MB |
| 国内各大市场 | 通用 APK(arm64 + v7) | `flutter build apk --release` | 约 13MB |
说明:
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
- `android/gradle.properties` 里固定了 `split-debug-info=build/symbols` 与 `dart-obfuscation=true`,**IDE 里点 Build APK / Build App Bundle 也会生效**:`libapp.so` 从 ~8MB/ABI 降到 ~1.1MB/ABI。
- `useLegacyPackaging = true`:原生库以压缩形式存储,这是国内 APK 从 40MB 降到 13MB 的主要原因;代价是安装时解压。
- **每次发版都要归档 `build/symbols`**,线上崩溃栈需要它还原。
- release 仍在用 debug 签名(`build.gradle.kts` 的 TODO),上架前必须换成正式 keystore。
- 不要用 `--split-per-abi`:与 `abiFilters` 冲突;Play 的 AAB 已经按设备拆好。
## 生产打包(iOS)
```bash
# 正式签名(需要 iOS Distribution 证书 + App Store 描述文件)
flutter build ipa --release
# 只有 Apple Development 证书时:出开发签名 IPA,只能装已注册设备
flutter build ipa --release --export-method development
```
产物在 `build/ios/ipa/*.ipa`。画中画依赖 `UIBackgroundModes` 里的 `audio` 与
`AVPictureInPictureController`,改动 `ios/Runner/Info.plist` 或 `AppDelegate.swift`
后要重新构建,热重载不会生效。
## 门禁
```bash
dart analyze
flutter test
flutter build apk --debug
```
当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明);
`flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
## 当前边界
- 下载会落盘并支持离线播放,但不支持断点续传:中途失败会丢弃 `.part` 重下。
- 支付与金币:客户端已有钱包、商品、下单接口与页面,商店票据校验依赖服务端配置凭据。
- 站内通知没有已读/未读和红点状态。
- 播放清晰度切换会保留旧画面 500ms 掩盖闪屏,切换瞬间的进度以相对位置续播。
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
-1
View File
@@ -1,5 +1,4 @@
/.gradle
/build/
/captures/
/local.properties
GeneratedPluginRegistrant.java
+4 -24
View File
@@ -8,9 +8,7 @@ plugins {
android {
namespace = "com.stormg.lumadrama"
// permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37,
// 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。
compileSdk = 37
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -31,20 +29,12 @@ android {
buildTypes {
release {
// 生产包只保留 arm64-v8a 与 armeabi-v7a,避免插件带入 x86_64 原生库
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
// R8 裁剪未使用的 Java/Kotlin 代码与资源,减小 dex 与 res。
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// 反编译保护(法律红线配套):开启 R8 混淆 + 资源压缩,发布前替换正式签名。
isMinifyEnabled = true
isShrinkResources = true
}
}
}
@@ -55,16 +45,6 @@ kotlin {
}
}
// 生产包不允许出现 x86_64:部分插件 AAR 会带入少量 x86_64 原生库,这里显式剔除。
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
// 原生库随 APK 压缩存储:下载体积明显小于解压后的体积,代价是安装时解压。
// 本包 90% 体积是 so,这一项对国内商店的 APK 分发影响最大。
variant.packaging.jniLibs.useLegacyPackaging = true
}
}
flutter {
source = "../.."
}
-11
View File
@@ -1,11 +0,0 @@
# Author: fruit.com.cn
#
# R8 保号规则:Flutter 引擎与插件自带的 consumer rules 已覆盖大部分场景,
# 这里只补本项目原生层通过反射/通道访问、裁剪后容易失效的入口。
# 画中画与权限通道由 MainActivity 直接注册,保留应用包名下的入口类。
-keep class com.stormg.lumadrama.** { *; }
# Flutter 引擎在 release 下通过 JNI 回调这些类,禁止改名。
-keep class io.flutter.** { *; }
-dontwarn io.flutter.embedding.**
@@ -6,6 +6,4 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+10 -16
View File
@@ -1,15 +1,10 @@
<!-- Author: fruit.com.cn -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission
android:name="android.permission.INTERNET"/>
<!-- 画中画自动触发依赖的「显示在其他应用上层」权限(部分 ROM 用它管控 PiP) -->
<uses-permission
android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application
android:label="ymb"
android:label="Nontonki"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -20,14 +15,15 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustNothing">
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -37,7 +33,7 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -46,10 +42,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action
android:name="android.intent.action.PROCESS_TEXT"/>
<data
android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -4,48 +4,18 @@ package com.stormg.lumadrama
import android.content.Intent
import android.app.PictureInPictureParams
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.telephony.TelephonyManager
import android.util.Rational
import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity() {
private val shareRequestCode = 9041
private val thumbnailExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val thumbnailHandler = Handler(Looper.getMainLooper())
private var pendingShare: MethodChannel.Result? = null
private var pipChannel: MethodChannel? = null
private var keyboardChannel: MethodChannel? = null
private var thumbnailChannel: MethodChannel? = null
private var imeInset = 0
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { screen -> screen.remove() }
}
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -69,23 +39,6 @@ class MainActivity : FlutterActivity() {
result.success(false)
}
}
// 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device")
.setMethodCallHandler { call, result ->
when (call.method) {
"ssaid" -> {
val value = try {
Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
} catch (_: Exception) {
null
}
result.success(value)
}
// 设备区域:注册时作为服务端 IP 解析失败后的兜底
"region" -> result.success(currentRegion())
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share")
.setMethodCallHandler { call, result ->
if (call.method != "shareText") {
@@ -109,155 +62,8 @@ class MainActivity : FlutterActivity() {
result.error("share_failed", error.message, null)
}
}
keyboardChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/keyboard"
)
keyboardChannel?.setMethodCallHandler { call, result ->
if (call.method != "get") {
result.notImplemented()
return@setMethodCallHandler
}
val inset = currentImeInset()
emitImeInset(inset)
result.success(inset)
}
thumbnailChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/thumbnail"
)
thumbnailChannel?.setMethodCallHandler { call, result ->
if (call.method != "frame") {
result.notImplemented()
return@setMethodCallHandler
}
val timeMs = (call.argument<Number>("timeMs"))?.toLong()
if (timeMs == null) {
result.success(null)
return@setMethodCallHandler
}
val localPath = call.argument<String>("localPath").orEmpty()
val remoteUrl = call.argument<String>("url").orEmpty()
val rawHeaders = call.argument<Map<*, *>>("headers")
val headers = rawHeaders?.entries
?.associate { it.key.toString() to it.value.toString() }
.orEmpty()
val responded = AtomicBoolean(false)
val task = thumbnailExecutor.submit {
val bytes = renderThumbnail(
timeMs = timeMs,
localPath = localPath,
remoteUrl = remoteUrl,
headers = headers
)
thumbnailHandler.post {
if (responded.compareAndSet(false, true)) {
result.success(bytes)
}
}
}
thumbnailHandler.postDelayed({
if (responded.compareAndSet(false, true)) {
task.cancel(true)
result.success(null)
}
}, thumbnailTimeoutMs)
}
val decorView = window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
ViewCompat.onApplyWindowInsets(view, insets)
}
ViewCompat.requestApplyInsets(decorView)
}
private fun currentImeInset(): Int =
ViewCompat.getRootWindowInsets(window.decorView)
?.getInsets(WindowInsetsCompat.Type.ime())
?.bottom
?: 0
/** 网络运营商国家优先,其次 SIM 卡,最后系统语言;都拿不到返回空串。 */
private fun currentRegion(): String {
return try {
val telephony = getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
val localeCountry = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resources.configuration.locales.get(0).country
} else {
@Suppress("DEPRECATION")
resources.configuration.locale.country
}
val candidates = listOfNotNull(
telephony?.networkCountryIso,
telephony?.simCountryIso,
localeCountry
)
candidates.firstOrNull { it.isNotBlank() }?.uppercase(Locale.ROOT) ?: ""
} catch (_: Exception) {
// 个别 ROM 读运营商信息会抛 SecurityException,别让注册流程卡住
""
}
}
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
keyboardChannel?.invokeMethod("insetsChanged", value)
}
private fun renderThumbnail(
timeMs: Long,
localPath: String,
remoteUrl: String,
headers: Map<String, String>
): ByteArray? {
val retriever = MediaMetadataRetriever()
var frame: Bitmap? = null
var scaled: Bitmap? = null
return try {
when {
localPath.isNotEmpty() -> retriever.setDataSource(normalizeLocalPath(localPath))
remoteUrl.isNotEmpty() -> retriever.setDataSource(remoteUrl, headers)
else -> return null
}
frame = retriever.getFrameAtTime(
timeMs * 1000,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
)
val source = frame ?: return null
scaled = if (source.width > thumbnailMaxWidth) {
val targetHeight = (source.height * thumbnailMaxWidth / source.width)
.coerceAtLeast(1)
Bitmap.createScaledBitmap(
source,
thumbnailMaxWidth,
targetHeight,
true
)
} else {
source
}
val outputBitmap = scaled ?: source
val output = ByteArrayOutputStream()
val compressed = outputBitmap.compress(
Bitmap.CompressFormat.JPEG,
thumbnailJpegQuality,
output
)
if (!compressed) return null
output.toByteArray()
} catch (_: Exception) {
null
} finally {
if (scaled != null && scaled !== frame) scaled.recycle()
frame?.recycle()
retriever.release()
}
}
private fun normalizeLocalPath(path: String): String =
if (path.startsWith("file:")) Uri.parse(path).path ?: path else path
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
@@ -271,22 +77,4 @@ class MainActivity : FlutterActivity() {
pendingShare = null
}
}
override fun onDestroy() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
keyboardChannel?.setMethodCallHandler(null)
keyboardChannel = null
thumbnailChannel?.setMethodCallHandler(null)
thumbnailChannel = null
thumbnailHandler.removeCallbacksAndMessages(null)
thumbnailExecutor.shutdownNow()
super.onDestroy()
}
companion object {
private const val TAG = "FruitPip"
private const val thumbnailMaxWidth = 240
private const val thumbnailJpegQuality = 70
private const val thumbnailTimeoutMs = 1500L
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

@@ -1,22 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
android:src="@drawable/splash_icon" />
</item>
</layer-list>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

@@ -1,22 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
android:src="@drawable/splash_icon" />
</item>
</layer-list>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,17 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
</style>
</resources>
@@ -1,15 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -18,6 +15,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,17 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
</style>
</resources>
+3 -3
View File
@@ -2,6 +2,6 @@
<!-- Author: fruit.com.cn -->
<resources>
<color name="splash_background">#000000</color>
<color name="ic_launcher_background">#E9142E</color>
</resources>
<color name="splash_background">#FF000000</color>
<color name="ic_launcher_background">#D74A7D</color>
</resources>
+2 -5
View File
@@ -1,15 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -18,6 +15,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -6,6 +6,4 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
-5
View File
@@ -6,8 +6,3 @@ android.useAndroidX=true
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# release 包默认剥离 Dart 调试符号并混淆:libapp.so 由 ~8MB 降到 ~1MB/ABI。
# 符号表输出到 build/symbols,崩溃分析时用它还原堆栈,发布前务必归档。
split-debug-info=build/symbols
dart-obfuscation=true
-93
View File
@@ -1,93 +0,0 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1004 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

-99
View File
@@ -1,99 +0,0 @@
# 客户端文件说明
## 目录树
```text
.
├── android/ Android 工程与 Gradle 配置
├── assets/
│ ├── icon/ 启动图标
│ ├── images/ 本地图片
│ └── media/ 本地视频文件(当前未在 pubspec 声明)
├── integration_test/ 设备侧集成测试
├── ios/
│ ├── Flutter/ Flutter 生成配置与 SPM 临时包
│ ├── Runner/ iOS Runner
│ ├── Runner.xcodeproj/ Xcode 工程与共享 Scheme
│ └── RunnerTests/ iOS 原生测试
├── lib/
│ ├── core/
│ │ ├── config/ 地址、语言、运行时服务器设置
│ │ ├── data/ models、remote、repositories、services
│ │ ├── formatting/ 多语言文案、日期和错误文本
│ │ ├── platform/ 平台能力封装
│ │ ├── theme/ 颜色与 ThemeData
│ │ └── widgets/ 通用组件
│ ├── l10n/ ARB 与本地化生成代码
│ └── pages/ 业务页面
├── test/ 单元和 Widget 测试
├── docs/ 客户端任务与文件说明
├── pubspec.yaml 依赖、资源和 Flutter 配置
└── README.md 运行、接口与环境说明
```
## 关键文件
| 路径 | 职责 |
| --- | --- |
| `lib/main.dart` | 初始化 Flutter、屏幕方向、debug 服务器覆盖并启动 App |
| `lib/app.dart` | Provider、repository、controller、ApiClient 和主题装配 |
| `lib/core/config/app_config.dart` | 地址优先级、超时、媒体 URL 和平台默认地址 |
| `lib/core/config/server_endpoint.dart` | debug 运行时地址规范化、读取、保存和清除 |
| `lib/core/config/locale_controller.dart` | 语言选择与系统语言回退 |
| `lib/core/data/models/` | 账号、剧目、评论、观看、下载、公告等数据结构 |
| `lib/core/data/remote/` | `/app/**` 接口调用;`api_client.dart` 统一鉴权和错误映射 |
| `lib/core/data/repositories/` | HTTP 与内存实现的 repository 边界 |
| `lib/core/data/services/` | 页面 controller、播放会话、分享、画中画等业务服务 |
| `lib/core/formatting/` | 本地化内容、日期与错误文本 |
| `lib/core/platform/haptics.dart` | 可注入的触觉反馈封装 |
| `lib/core/theme/app_colors.dart` | 克莱因蓝和界面颜色 token |
| `lib/core/theme/app_theme.dart` | light/dark ColorScheme 与组件主题 |
| `lib/core/widgets/empty_state.dart` | 统一空列表占位 |
| `lib/core/widgets/remote_image.dart` | 后端相对图片地址与失败占位 |
| `lib/core/widgets/auth_gate.dart` | 未登录操作拦截 |
| `lib/core/widgets/download_action.dart` | 下载授权操作 |
| `lib/pages/home/` | 根页面、四个底部 tab 和抽屉 |
| `lib/pages/player/` | 竖屏播放、选集、进度和播放器交互 |
| `lib/pages/discover/` | 首页/发现信息流、轮播、分类与三列剧目网格 |
| `lib/pages/drama_detail/` | 剧目详情与剧集入口 |
| `lib/pages/auth/` | 登录/注册页面与表单逻辑 |
| `lib/pages/mine/` | 我的页、片单、设置、语言与 debug 地址入口 |
| `lib/pages/favorites/` | 收藏列表 |
| `lib/pages/watch_records/` | 独立观看记录页 |
| `lib/pages/downloads/` | 下载授权与策略页面 |
| `lib/pages/notices/` | 站内通知分页和详情 |
| `lib/pages/interaction/` | 举报弹窗 |
| `lib/l10n/*.arb` | en/zh/id 源文案,修改后执行 `flutter gen-l10n` |
| `ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme` | iOS SPM 预构建脚本 |
## 测试结构
| 目录 | 覆盖范围 |
| --- | --- |
| `test/auth/` | 登录 controller、表单、视觉与交互 |
| `test/catalog/`、`test/category/` | 首页数据、分类和模型解析 |
| `test/comment/`、`test/interaction/` | 评论、点赞、举报和弹窗 |
| `test/config/` | 三语、系统语言和运行时服务器地址 |
| `test/core/` | ApiClient、ID 解析、触觉、分享和画中画 |
| `test/discover/`、`test/home/` | 首页、轮播、网格和导航 |
| `test/download/`、`test/library/`、`test/watch/` | 下载、收藏、观看记录 |
| `test/drama_detail/`、`test/player/`、`test/playback/` | 详情、播放器与播放队列 |
| `test/notice/` | 通知分页、空态、详情与入口 |
| `test/empty_state/`、`test/theme/` | 空态组件与主题 token |
| `test/integration/` | 依赖本机后端的联调测试,服务未启动时跳过 |
| `integration_test/app_backend_test.dart` | 设备侧后端连通性测试 |
| `test/support/` | fake repository、HTTP adapter、播放器和测试数据 |
## 生成物
以下内容由工具生成,不要手工修改:
- `lib/l10n/app_localizations*.dart`
- `*.g.dart`
- `.dart_tool/`
- `build/`
- `ios/Flutter/ephemeral/`
- `ios/Runner/GeneratedPluginRegistrant.h`
- `ios/Runner/GeneratedPluginRegistrant.m`
`pubspec.lock` 由 Dart 包管理工具维护,应提交但不手工编辑。
-38
View File
@@ -1,38 +0,0 @@
# 客户端任务清单
状态按当前 `develop` 中已合入的功能整理。契约章节以
`FruitServer/docs/api-phase1.md` 为准;任务过程记录见
`FruitServer/docs/plans/PROGRESS.md`。
## K1-K13
| 批次 | 范围 | 状态 | 关键提交或契约章节 | 备注 |
| --- | --- | --- | --- | --- |
| K1 | 详情、播放票据、观看进度、续看、收藏 | 已完成并合入 | §1.3-§1.5 | 已有三态和基础页面 |
| K2 | 分类、观看记录、收藏列表、下载授权管理 | 已完成并合入 | §1.2、§1.5、§1.6 | 下载只管理授权,不落盘 |
| K3 | 评论、回复、点赞、举报、分享 | 已完成并合入 | §1.7 | 分享使用系统分享面板 |
| K4 | 账号剩余能力 | 未开始 | §1.1 | Google 登录所需凭据和契约范围需核对 |
| K5 | 克莱因蓝主题、印尼语适配 | 已完成并合入 | 无接口变更 | 三语为 en/zh/id |
| K6 | 登录页、底部导航、触觉反馈 | 已完成并合入 | 无接口变更 | 触觉通过 `Haptics` 封装 |
| K7 | 首页轮播、站内通知 | 已完成并合入 | §1.3、§1.9.2 | 通知无已读态 |
| K8 | 登录页视觉重设计 | 已完成并合入 | 无接口变更 | 保留原有登录逻辑和校验 |
| K9 | 首页全部剧目双列网格 | 已完成并合入 | 无接口变更 | 数据源为 feed 分类结果 |
| K10 | 统一空列表占位 | 已完成并合入 | 无接口变更 | 评论弹窗使用 compact 变体 |
| K11 | 首页三列、分类首位“全部” | 已完成并合入 | 无接口变更 | 网格比例保持 2:3 |
| K12 | 首页去标题、空态视觉重做 | 已完成并合入 | 无接口变更 | 双层同心圆空态 |
| K13 | debug 运行时服务器地址 | 已完成并合入 | 无接口变更 | 保存后重启生效,release 不开放 |
## 待办
| 事项 | 状态 | 依赖或说明 |
| --- | --- | --- |
| K4 账号剩余能力 | 待办 | Google 登录需要客户端凭据、平台配置和后端契约,范围需核对 |
| B1 支付与金币 | 待办 | 客户端尚未接入 §1.8 的支付/权益闭环,商品、渠道和验证方式需核对 |
| 搜索 | 待办 | 当前没有独立搜索接口,接口契约和数据范围需核对 |
| 下载落盘 | 待办 | 需要新增文件下载、进度持久化、续传、离线播放和本地清理设计 |
## 维护规则
- 新批次完成后更新本表,不写未经确认的提交号。
- 功能状态以当前 `develop` 代码和自动化测试为准。
- “需核对”表示产品范围、接口或外部凭据尚未确认,不能按已完成处理。
@@ -1,28 +0,0 @@
# ReelShort 客户端深色视觉改造
## 目标
- 深色设为默认且唯一主题,页面、卡片、浮层、播放器和底部导航保持一致。
- 色板收敛到 `AppColors`,主题组件配置收敛到 `AppTheme`。
- 保持现有布局、信息结构、路由、业务逻辑和文案不变。
## 结果
- 背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、分割线白色 6%。
- 主文字白色 92%、次文字 64%、禁用 38%。
- 主色 `#FF6B2C`,辅助链接色 `#22D3EE`。
- 卡片 12、按钮/输入框 10、标签 6,阴影关闭,组件使用分割线分层。
- 页面级透明、遮罩、阴影、媒体底、玻璃面和渐变全部映射到 AppColors 语义 token。
- 系统栏统一浅色图标,AppBar 与底部导航使用深色表面。
## 验证
- 主题 token、对比度、系统栏、圆角与关键页 smoke 测试。
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 约束
- 不提交 `pubspec.lock` 漂移。
- 不启停服务、不操作真机。
@@ -1,24 +0,0 @@
# Android Studio 调试 HTTP 日志
## 目标
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
- 单条请求体或响应体最多 3000 字符。
## 实现步骤
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
## 风险与约束
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
避免拿到已解包后的 `data`。
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
@@ -1,20 +0,0 @@
# 收藏卡片简介
## 目标
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
## 实现
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
3. 无简介时继续使用原 Spacer,保持原布局。
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
## 约束
- 不改接口、后端、收藏分页。
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
@@ -1,31 +0,0 @@
# 屏幕常亮与前台自动续播
## 目标
- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。
- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。
- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。
## 实施步骤
1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。
2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。
3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。
4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。
5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。
- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。
## 执行结果
- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。
- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。
- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。
- 后台仍先保存进度并暂停。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。
- 未启停服务,未操作真机。
@@ -1,31 +0,0 @@
# 收藏与观看历史分页 UI
## 目标
- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。
- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。
- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。
## 实现步骤
1. 新增通用 `PageResult<T>`,更新 LibraryApi、WatchApi、Repository 与内存替身。
2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。
3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。
4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。
5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。
## 风险与约束
- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
- 空页立即停止加载;`loaded >= total` 后不再请求。
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
## 2026-10-22 范围修订
- 分页协议和 Controller 分页状态保持不改。
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
只在 PROGRESS 记录证据,不做前端全量兜底。
@@ -1,28 +0,0 @@
# 登录异常居中弹窗
## 目标
- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。
- 本地表单校验继续使用现有内联字段错误,不触发弹窗。
## 实施步骤
1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。
2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。
3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。
4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。
5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。
- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。
## 执行结果
- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。
- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。
- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。
- 未启停服务,未操作真机。
@@ -1,29 +0,0 @@
# 登录异常居中吐司
## 目标
- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。
- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。
- 同一时刻只存在一个吐司,新消息替换旧消息。
## 实施步骤
1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。
2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。
3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。
4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。
- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。
## 执行结果
- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。
- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。
- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。
- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。
- 未启停服务,未操作真机。
@@ -1,21 +0,0 @@
# 播放入口无缓冲提示
## 目标
- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。
- 仅真实 buffering 持续超过 350ms 时显示白色高光。
- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。
- 同集返回复用 controller;重建时 initialize→seek→play。
## 实施步骤
1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。
2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。
3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。
4. 更新进度条、全屏入口、详情返回和重建顺序测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 真实持续网络卡顿仍保留缓冲高光。
- 锁定集继续沿用静态空轨道和禁用 seek 行为。
@@ -1,32 +0,0 @@
# 播放位置连续性
## 目标
- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。
- 新增进程内最近播放位置缓存,优先于服务端旧记录。
- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。
## 实施步骤
1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。
2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。
3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。
4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。
5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。
6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。
7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。
- 片尾附近继续复用现有 `clampResumePosition` 完成保护。
## 执行结果
- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。
- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。
- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。
- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。
- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。
- 未启停服务,未操作真机。
@@ -1,21 +0,0 @@
# Seek 后播放位置保持
## 目标
- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。
- 拖动后立即进详情/离开也不会回退到旧进度。
- 越界位置 clamp,锁定/票据失败不写无效缓存。
## 实施步骤
1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。
2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。
3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。
4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。
5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 片尾与完成逻辑继续沿用现有 controller/播放器行为。
- 缓存仅在进程内,不改变服务端观看记录契约。
@@ -1,30 +0,0 @@
# 播放器 loading 与锁定进度条调整
## 目标
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
- 缓冲/加载进度高光改为白色。
- 未解锁剧集显示静态 inactive 轨道,不进入 loading,不允许拖动 seek。
## 实施步骤
1. 更新 `LoadingProgressTrack` 默认高光颜色,并补颜色断言。
2. 为 `SeekProgressBar`、`PlayerOverlayData`、`HomeFeedOverlay` 和 `_FeedProgressBar` 增加 locked 状态。
3. `PlayerPage` 使用 `logic.session.isLocked || episode.locked` 传入两个浮层。
4. 缩小 `player-ticket-loading` 与 `player-session-loading` 的尺寸和线宽。
5. 补首页/全屏锁定态、缓冲高光和两个 loading 指示器尺寸测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 锁定态只影响进度条 UI 与手势,不改播放、解锁或接口逻辑。
- 首页浮层锁定线沿用当前 `Colors.white24` inactive 色,避免改变非锁定轨道样式。
## 执行结果
- 两个播放器 loading 圈均为 24×24、线宽 2、主色不变。
- loading 高光默认改为白色,普通轨道与首页 white24 轨道颜色未变。
- 首页与全屏播放器均按剧集锁定态渲染静态空轨道并禁用 seek。
- 已覆盖锁定轨道高度、无 loading、无拖动 seek、loading 指示器尺寸和高光颜色。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 576 项全过。
- 未启停服务,未操作真机。
@@ -1,62 +0,0 @@
# 播放器进度拖动画面预览
## 目标
首页 feed 与全屏播放器的进度条在拖动时显示当前时间点的视频画面缩略图、
当前时间与总时长;松手立即隐藏并提交 seek。缩略图未返回或失败时显示短剧
封面,不出现空白闪烁或错误文案。
## 性能红线
1. 拖动期间主线程无卡顿;图片解码和原生取帧不得阻塞 UI 线程。
2. 原生取帧并发数不超过 1。
3. 预览图最大宽度 240px。
4. Dart 内存 LRU 缓存不超过 24 帧。
5. 不新增 pub 依赖。
6. 不改后端契约。
## 实现
### 缩略图服务
- 新增 `PlaybackThumbnailService`,公开入口按 `episodeId + position` 取
`ByteData`,底层通过
`MethodChannel('com.stormg.lumadrama/thumbnail')` 调用原生。
- 时间按 1 秒分桶;同一 bucket 的在途请求合流。
- 最多 24 帧 LRU;成功和失败结果都做过期控制,避免旧拖动位置覆盖当前预览。
- 全局单飞;拖动期间只保留最新 bucket,尚未开始的旧请求直接丢弃。
- 拖动停顿 120ms 后最多预取相邻一个 bucket,松手取消尚未开始的请求。
- 原生异常、缺失实现和超过 1.5 秒无结果统一返回 `null`。
### 原生取帧
- Android 使用 `MediaMetadataRetriever`,优先本地下载路径,其次签名 URL;
`OPTION_CLOSEST_SYNC` 取帧,最大宽 240px,JPEG 质量 70,单线程 Executor,
完成后立即 release。
- iOS 使用 `AVAssetImageGenerator`,应用画面方向,最大尺寸约 240x426,
合理时间容差,后台串行队列生成 JPEG。
- 两端均保证只回调一次;失败或 1.5 秒超时返回 `null`,不向 UI 抛异常。
### UI
- 新增 `ScrubPreviewCard`:宽 120dp、圆角 8、白色 1px 描边,显示
`当前时间 / 总时长`;沿拖动点水平跟随并做安全边距 clamp。
- 首页 `_FeedProgressBar` 与全屏 `SeekProgressBar` 共用该卡片。
- 封面始终铺在卡片底层;`Image.memory` 仅在帧到达后覆盖,并限制
`cacheWidth: 240`。
- 开始拖动请求当前 bucket,拖动更新只保留最新位置;松手立即隐藏卡片并取消
未开始请求。
- `PlayerPage` 持有页面级服务,dispose 时清空缓存,避免跨页面串集。
## 测试
- 单测:bucket、LRU 淘汰、同 bucket 合流、过期请求丢弃、松手取消、
相邻 bucket 防抖、异常/超时回落。
- Widget:拖动显示预览卡片和时间、松手消失、无缩略图显示封面。
- 门禁:`dart analyze lib test` 与 `flutter test --no-pub`。
## 非目标
- 不改服务端接口或持久化策略。
- 不新增第三方依赖。
- 不启停服务、不操作真机;原生取帧实际效果由 PM 真机验收。
@@ -1,46 +0,0 @@
# 雪碧图 VTT 拖动预览
## 目标
将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级:
1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。
2. VTT 只拉取/解析一次,内存缓存 10 分钟。
3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。
4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。
5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。
## 性能红线
- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。
- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。
- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。
- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。
- 签名 `thumbnailsUrl` 不写入任何持久化缓存。
## 实施
- 新增 `SpriteThumbnailAxisService`:
- Dio 拉取 VTT,403 清缓存后重试一次;
- 解析和选 cue 为纯函数,解析失败返回空轴;
- 播放器就绪时预取当前 cue 及相邻 sprite;
- LRU2 sprite 缓存和 10 分钟 VTT 缓存。
- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。
- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪;
VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。
- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。
- 不新增 pub 依赖、不改后端契约。
## 测试
- VTT 解析:时间、xywh、多 sprite 文件。
- cue 选择:命中、越界最近邻。
- sprite LRU 最多 2 张。
- 回退状态判定。
- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。
- 门禁:`dart analyze lib test`、`flutter test --no-pub`。
## 风险
- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。
- 本次不启动服务、不操作真机、不修改 Web/FruitServer。
File diff suppressed because it is too large. Load diff
-74
View File
@@ -1,74 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:integration_test/integration_test.dart';
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
///
/// 跑法(红线第 10 条要求的"设备侧取证"):
/// ```
/// IP=$(ipconfig getifaddr en0)
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
/// ```
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final baseUrl = AppConfig.apiBaseUrl;
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
tester,
) async {
debugPrint('[device] API_BASE_URL=$baseUrl');
final client = ApiClient(baseUrl: baseUrl);
addTearDown(client.close);
final api = CatalogApi(client);
final feed = await api.homeFeed();
expect(
feed.dramas,
isNotEmpty,
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
final card = feed.dramas.first;
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
final detail = await api.dramaDetail(card.id);
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
expect(
detail.episodes,
isNotEmpty,
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
);
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
});
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
tester,
) async {
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
await tester.pumpWidget(const LumaDramaApp());
var loaded = false;
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
await tester.pump(const Duration(milliseconds: 500));
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
}
expect(
loaded,
isTrue,
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
});
}
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+3 -7
View File
@@ -11,7 +11,6 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -50,7 +49,6 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WirelessDataPrompt.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -124,7 +122,6 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -285,7 +282,6 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
@@ -364,7 +360,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -491,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -542,7 +538,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -6,27 +6,11 @@
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Bootstrap Flutter SPM Package"
scriptText = "#!/bin/sh&#10;set -e&#10;FLUTTER=&quot;&quot;&#10;if [ -n &quot;${FLUTTER_ROOT:-}&quot; ] &amp;&amp; [ -x &quot;$FLUTTER_ROOT/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$FLUTTER_ROOT/bin/flutter&quot;&#10;elif FLUTTER=&quot;$(command -v flutter)&quot;; then&#10; :&#10;elif [ -x &quot;$HOME/flutter/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$HOME/flutter/bin/flutter&quot;&#10;elif [ -x &quot;/opt/homebrew/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;/opt/homebrew/bin/flutter&quot;&#10;fi&#10;if [ -z &quot;$FLUTTER&quot; ]; then&#10; echo &quot;error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;if [ -z &quot;${SRCROOT:-}&quot; ]; then&#10; echo &quot;error: SRCROOT is not set; cannot locate the Flutter project.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;cd &quot;$SRCROOT/..&quot;&#10;&quot;$FLUTTER&quot; pub get&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "#!/bin/sh&#10;set -e&#10;FLUTTER=&quot;&quot;&#10;if [ -n &quot;${FLUTTER_ROOT:-}&quot; ] &amp;&amp; [ -x &quot;$FLUTTER_ROOT/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$FLUTTER_ROOT/bin/flutter&quot;&#10;elif FLUTTER=&quot;$(command -v flutter)&quot;; then&#10; :&#10;elif [ -x &quot;$HOME/flutter/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$HOME/flutter/bin/flutter&quot;&#10;elif [ -x &quot;/opt/homebrew/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;/opt/homebrew/bin/flutter&quot;&#10;fi&#10;if [ -z &quot;$FLUTTER&quot; ]; then&#10; echo &quot;error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;FLUTTER_ROOT=&quot;$(cd &quot;$(dirname &quot;$FLUTTER&quot;)/..&quot; &amp;&amp; pwd -P)&quot;&#10;if [ -z &quot;${SRCROOT:-}&quot; ]; then&#10; echo &quot;error: SRCROOT is not set; cannot locate the Flutter project.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;cd &quot;$SRCROOT&quot;&#10;&quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
+6 -588
View File
@@ -1,27 +1,5 @@
import Flutter
import UIKit
import AVFoundation
import AVKit
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
private final class ThumbnailResponseGate {
private let lock = NSLock()
private var completed = false
func send(_ result: @escaping FlutterResult, data: Data?) {
lock.lock()
if completed {
lock.unlock()
return
}
completed = true
lock.unlock()
DispatchQueue.main.async {
result(data)
}
}
}
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
/// instantiate that storyboard again and the icon appears a second time.
@@ -36,348 +14,17 @@ final class RunnerFlutterViewController: FlutterViewController {
}
}
/// iOS 影子播放器:独立 AVPlayer + AVPlayerLayer 专供画中画窗口。
/// Flutter 的 video_player 走纹理渲染,没有 AVPlayerLayer,无法直接创建
/// AVPictureInPictureController;小窗的画面与声音由影子接管,退出时把
/// 影子进度回传给 Dart,由 Flutter 播放器 seek 对齐后继续。
final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate {
static let shared = PipShadowCoordinator()
private var player: AVPlayer?
private var playerLayer: AVPlayerLayer?
private var controller: AVPictureInPictureController?
private var endObserver: NSObjectProtocol?
private var possibleObservation: NSKeyValueObservation?
/// 画中画还原过渡层:原生窗口级红晕渐变,在系统还原动画开始时立即盖上,
/// 避免回前台瞬间 Flutter 表面未恢复显示黑帧;视频出帧后由 Dart 通知淡出。
private var restoreOverlay: UIView?
/// 系统要求 playerLayer 位于真实的 view 层级中才会判定画中画可用,
/// 因此用 1x1 透明宿主 view 承载,而不是直接挂在 window.layer 上。
private var hostView: UIView?
/// 影子图层就绪稍晚于创建,置位后由 KVO 自动拉起画中画。
private var pendingStart = false
private var startRetryWorkItems: [DispatchWorkItem] = []
weak var channel: FlutterMethodChannel?
/// active 变化回调;停止时把影子最终进度(毫秒)一并提供。
var onPipChanged: ((Bool, Int64) -> Void)?
/// 影子在画中画里播完当前集。
var onPlaybackEnded: (() -> Void)?
static var isSupported: Bool {
AVPictureInPictureController.isPictureInPictureSupported()
}
private var keyWindow: UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
/// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。
/// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。
func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) {
teardown()
log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)")
guard Self.isSupported else { return }
guard let url = URL(string: urlString) else {
log("prepare failed bad url \(urlString)")
return
}
guard let window = keyWindow else {
log("prepare failed no key window")
return
}
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
p.isMuted = true
p.actionAtItemEnd = .pause
let layer = AVPlayerLayer()
layer.player = p
let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
host.isUserInteractionEnabled = false
host.backgroundColor = .clear
window.addSubview(host)
layer.frame = host.bounds
host.layer.addSublayer(layer)
hostView = host
guard let c = AVPictureInPictureController(playerLayer: layer) else {
p.pause()
log("prepare failed no pip controller")
return
}
c.canStartPictureInPictureAutomaticallyFromInline = autoEnter
c.delegate = self
player = p
playerLayer = layer
controller = c
// 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。
p.play()
possibleObservation = c.observe(
\.isPictureInPicturePossible,
options: [.new, .initial]
) { [weak self] controller, change in
let value = change.newValue ?? controller.isPictureInPicturePossible
self?.log("possible changed to \(value)")
guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return }
self.pendingStart = false
// 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。
DispatchQueue.main.async {
guard self.controller === controller, controller.isPictureInPicturePossible else {
self.log("start via kvo aborted (shadow replaced)")
return
}
self.log("start via kvo")
controller.startPictureInPicture()
}
}
log("shadow ready, seeking to \(positionMs)ms")
if positionMs > 0 {
p.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
registerEndObserver(for: item)
activateAudioSession()
}
/// 退出 App 前(inactive)把影子对齐到当前进度并静音起播:
/// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。
func armAutoEnter(positionMs: Int64) {
guard let player, let controller else {
log("arm failed, shadow missing")
return
}
guard controller.canStartPictureInPictureAutomaticallyFromInline else {
log("arm failed, autoEnter disabled on shadow")
return
}
log("arm auto enter at \(positionMs)ms")
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: CMTime(value: 800, timescale: 1000),
toleranceAfter: CMTime(value: 800, timescale: 1000)
)
}
player.play()
}
/// 回前台后取消待命:影子暂停回静音,等待下一次退后台。
func disarmAutoEnter() {
guard let player else { return }
log("disarm auto enter")
player.pause()
player.isMuted = true
}
/// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。
/// 仅当影子从未创建成功(prepare 失败)时返回 false。
func start() -> Bool {
guard let controller else {
log("start failed, shadow not created")
return false
}
log("start requested possible=\(controller.isPictureInPicturePossible)")
pendingStart = true
if controller.isPictureInPicturePossible {
pendingStart = false
controller.startPictureInPicture()
}
scheduleStartRetries()
return true
}
/// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。
private func scheduleStartRetries() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
for delay in [1.0, 2.5] {
let item = DispatchWorkItem { [weak self] in
guard
let self,
self.pendingStart,
let controller = self.controller,
controller.isPictureInPicturePossible
else { return }
self.log("start via retry after \(delay)s")
self.pendingStart = false
controller.startPictureInPicture()
}
startRetryWorkItems.append(item)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
}
}
/// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。
func replaceItem(urlString: String, positionMs: Int64) {
guard let player, let url = URL(string: urlString) else { return }
let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)
registerEndObserver(for: item)
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
}
/// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。
func unmuteAndPlay() {
player?.isMuted = false
player?.play()
}
func positionMs() -> Int64 {
Int64(max(0, player?.currentTime().seconds ?? 0) * 1000)
}
func stopPip() {
controller?.stopPictureInPicture()
}
func teardown() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
possibleObservation?.invalidate()
possibleObservation = nil
pendingStart = false
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = nil
player?.pause()
player?.replaceCurrentItem(with: nil)
playerLayer?.removeFromSuperlayer()
playerLayer = nil
hostView?.removeFromSuperview()
hostView = nil
controller?.delegate = nil
controller = nil
player = nil
}
/// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。
private func log(_ message: String) {
NSLog("pip: %@", message)
channel?.invokeMethod("pipDebug", arguments: message)
}
private func registerEndObserver(for item: AVPlayerItem) {
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.log("item did play to end")
self?.onPlaybackEnded?()
}
}
/// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。
private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
// 会话激活失败只影响后台出声,不阻断画中画。
}
}
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will start")
pendingStart = false
onPipChanged?(true, 0)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will stop, showing restore overlay")
showRestoreOverlay()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("did stop")
showRestoreOverlay()
onPipChanged?(false, positionMs())
}
/// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。
func showRestoreOverlay() {
guard let window = keyWindow else { return }
restoreOverlay?.removeFromSuperview()
let overlay = UIView(frame: window.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let gradient = CAGradientLayer()
gradient.frame = overlay.bounds
gradient.colors = [
UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor,
UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor,
UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor,
]
gradient.locations = [0, 0.22, 0.62]
gradient.startPoint = CGPoint(x: 0.5, y: 0)
gradient.endPoint = CGPoint(x: 0.5, y: 1)
overlay.layer.addSublayer(gradient)
window.addSubview(overlay)
restoreOverlay = overlay
}
/// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。
func hideRestoreOverlay() {
guard let overlay = restoreOverlay else { return }
restoreOverlay = nil
UIView.animate(
withDuration: 0.25,
animations: { overlay.alpha = 0 }
) { _ in overlay.removeFromSuperview() }
}
}
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let wirelessDataPrompt = WirelessDataPrompt()
private let thumbnailQueue = DispatchQueue(
label: "com.stormg.lumadrama.thumbnail",
qos: .userInitiated
)
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UIApplication.shared.isIdleTimerDisabled = true
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func applicationDidBecomeActive(_ application: UIApplication) {
super.applicationDidBecomeActive(application)
requestWirelessDataPrompt()
}
func requestWirelessDataPrompt() {
wirelessDataPrompt.requestSystemPrompt()
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerWirelessDataChannel(engineBridge)
registerDeviceIdChannel(engineBridge)
registerPipChannel(engineBridge)
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/share",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
@@ -386,244 +33,15 @@ final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate
guard call.method == "shareText",
let arguments = call.arguments as? [String: Any],
let text = arguments["text"] as? String,
!text.isEmpty else {
!text.isEmpty,
let controller = self?.window?.rootViewController else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return
}
DispatchQueue.main.async {
guard let controller = self?.sharePresenter() else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return
}
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
if let popover = activity.popoverPresentationController {
popover.sourceView = controller.view
popover.sourceRect = CGRect(
x: controller.view.bounds.midX,
y: controller.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
}
}
let thumbnailChannel = FlutterMethodChannel(
name: "com.stormg.lumadrama/thumbnail",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
thumbnailChannel.setMethodCallHandler { [weak self] call, result in
guard call.method == "frame" else {
result(FlutterMethodNotImplemented)
return
}
guard let self,
let arguments = call.arguments as? [String: Any],
let timeNumber = arguments["timeMs"] as? NSNumber else {
result(nil)
return
}
let localPath = arguments["localPath"] as? String ?? ""
let remoteUrl = arguments["url"] as? String ?? ""
let headers = arguments["headers"] as? [String: String] ?? [:]
let assetUrl: URL?
if !localPath.isEmpty {
assetUrl = localPath.hasPrefix("file:")
? URL(string: localPath)
: URL(fileURLWithPath: localPath)
} else if !remoteUrl.isEmpty {
assetUrl = URL(string: remoteUrl)
} else {
assetUrl = nil
}
guard let assetUrl else {
result(nil)
return
}
let options: [String: Any]? = headers.isEmpty
? nil
: [thumbnailHTTPHeaderFieldsKey: headers]
let asset = AVURLAsset(url: assetUrl, options: options)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 240, height: 426)
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600)
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600)
let time = CMTime(value: timeNumber.int64Value, timescale: 1000)
let gate = ThumbnailResponseGate()
self.thumbnailQueue.async {
do {
let image = try generator.copyCGImage(at: time, actualTime: nil)
let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7)
gate.send(result, data: data)
} catch {
gate.send(result, data: nil)
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + 1.5
) {
gate.send(result, data: nil)
}
}
}
/// 当前前台 Scene 的根控制器之上,最适合展示系统分享面板的控制器。
private func sharePresenter() -> UIViewController? {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.windows.first { $0.isKeyWindow }
guard let rootController = keyWindow?.rootViewController else { return nil }
let presenter = topPresentedController(from: rootController)
guard presenter.viewIfLoaded?.window != nil else { return nil }
return presenter
}
/// 解析已展示页面、导航栈和标签页中的当前可见控制器。
private func topPresentedController(from controller: UIViewController) -> UIViewController {
if let presented = controller.presentedViewController {
return topPresentedController(from: presented)
}
if let navigation = controller as? UINavigationController,
let visible = navigation.visibleViewController {
return topPresentedController(from: visible)
}
if let tabs = controller as? UITabBarController,
let selected = tabs.selectedViewController {
return topPresentedController(from: selected)
}
return controller
}
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/pip",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
let pip = PipShadowCoordinator.shared
pip.channel = channel
channel.setMethodCallHandler { call, result in
switch call.method {
case "isAvailable":
result(PipShadowCoordinator.isSupported)
case "enter":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(false)
return
}
let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0
pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false)
// 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。
DispatchQueue.main.async {
result(pip.start())
}
case "prepareShadow":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.prepare(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0,
autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false
)
result(nil)
case "armAutoEnter":
pip.armAutoEnter(
positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0
)
result(nil)
case "showRestoreOverlay":
pip.showRestoreOverlay()
result(nil)
case "hideRestoreOverlay":
pip.hideRestoreOverlay()
result(nil)
case "disarmAutoEnter":
pip.disarmAutoEnter()
result(nil)
case "unmute":
pip.unmuteAndPlay()
result(nil)
case "replaceItem":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.replaceItem(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0
)
result(nil)
case "stop":
pip.stopPip()
result(nil)
case "teardown":
pip.teardown()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
pip.onPipChanged = { active, positionMs in
channel.invokeMethod(
"onPipChanged",
arguments: ["active": active, "positionMs": positionMs]
)
}
pip.onPlaybackEnded = {
channel.invokeMethod("onPipPlaybackEnded", arguments: nil)
}
}
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/device",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
channel.setMethodCallHandler { call, result in
switch call.method {
case "idfv":
result(UIDevice.current.identifierForVendor?.uuidString)
case "region":
if #available(iOS 16.0, *) {
result(Locale.current.region?.identifier)
} else {
result(Locale.current.regionCode)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: WirelessDataPrompt.channelName,
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == WirelessDataPrompt.awaitDecisionMethod else {
result(FlutterMethodNotImplemented)
return
}
guard let self else {
result(nil)
return
}
self.wirelessDataPrompt.awaitDecision(result)
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
activity.popoverPresentationController?.sourceView = controller.view
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

After

Width:  |  Height:  |  Size: 340 B

Loaded 100 of 624 files, more files were not shown because too many files have changed in this diff. Show more