Author SHA1 Message Date
Zhou Weidong 67e178c89d chore(merge): 将 develop 同步到 release/v1.0.0 2026-10-23 13:07:33 +08:00
Zhou Weidong 8481611372 chore(merge): 合并错误码三语文案 2026-10-23 13:05:29 +08:00
Zhou Weidong b1e95b370c feat(app): 按错误码展示中英印尼文案 2026-10-23 13:05:29 +08:00
Zhou Weidong 0ef0d46919 chore(merge): 合并未使用注销原因清理 2026-10-23 12:45:30 +08:00
Zhou Weidong b4d38f8e10 refactor(app): 删除未使用的注销原因组件 2026-10-23 12:45:30 +08:00
Zhou Weidong c034627fdb chore(merge): 合并播放协议搜索与空状态 2026-10-23 12:38:47 +08:00
Zhou Weidong ef29fc5f15 feat(app): 完善播放协议搜索与空状态 2026-10-23 12:38:47 +08:00
Zhou Weidong 671e0ce143 merge: 将远程 main 同步到 develop 2026-10-23 08:09:24 +08:00
Zhou Weidong 0f1f64a09f merge: 将 release/v1.0.0 同步到 main 2026-10-23 03:22:26 +08:00
Zhou Weidong d29ae20491 merge: 将客户端调整同步到 release/v1.0.0 2026-10-23 03:21:53 +08:00
Zhou Weidong 12e08a89a4 fix(player): 提高解锁金币标签对比度 2026-10-23 03:18:24 +08:00
Zhou Weidong e0ba54f23b fix(cover): 封面占位改为品牌标识 2026-10-23 03:18:24 +08:00
Zhou Weidong 5043d6c25b fix(l10n): 缩短印尼语全部短剧文案 2026-10-23 03:18:24 +08:00
Zhou Weidong ab6800aba2 fix(search): 避免键盘顶起搜索页 2026-10-23 03:18:24 +08:00
Zhou Weidong f8500bbaca fix(player): 完善进度预览清屏与下拉刷新 2026-10-23 03:18:24 +08:00
Zhou Weidong 9b1afbdb36 feat(recommend): 剧单更多进入两列详情页 2026-10-23 03:18:24 +08:00
Zhou Weidong f4cf7bc2dd fix(home): 调整榜单版式并去掉二级分类 2026-10-23 03:18:24 +08:00
Zhou Weidong 60489d6dca fix(ios): 修复逐帧请求头编译失败 2026-10-23 03:18:23 +08:00
Zhou Weidong 44d3bcba18 style(empty): 放大空状态图标并降低亮度 2026-10-23 01:10:59 +08:00
Zhou Weidong f206977ad8 fix(config): 开发服务地址改为当前局域网 2026-10-23 01:10:59 +08:00
Zhou Weidong f3d64f6fb5 fix(auth): 登录页靠上排列并支持外部失焦 2026-10-23 01:10:59 +08:00
Zhou Weidong 6318ca24ec feat(splash): 缩短开屏停留并统一开屏图 2026-10-23 01:06:07 +08:00
Zhou Weidong b513ac0a9c merge: 调整品牌开屏与纯黑原生启动 2026-10-22 23:15:36 +08:00
Zhou Weidong 25d90e1383 feat: 调整品牌开屏与纯黑原生启动 2026-10-22 23:15:27 +08:00
Zhou Weidong 7804809d83 merge: 开屏后直接展示首页骨架 2026-10-22 23:11:13 +08:00
Zhou Weidong 5f278a3510 feat: 开屏后直接展示首页骨架 2026-10-22 23:11:03 +08:00
Zhou Weidong 56d7abc6b1 merge: 保留系统开屏并更新品牌素材 2026-10-22 23:04:31 +08:00
Zhou Weidong 345cc2ad28 feat: 保留系统开屏并更新品牌素材 2026-10-22 23:04:23 +08:00
Zhou Weidong 41bd4683c3 merge: 更新 App 图标与开屏素材 2026-10-22 22:59:14 +08:00
Zhou Weidong d868ce9336 feat: 更新 App 图标与开屏素材 2026-10-22 22:59:07 +08:00
Zhou Weidong 928ac1de19 merge: 增加搜索结果下拉刷新 2026-10-22 22:53:01 +08:00
Zhou Weidong 6b3d870c93 feat: 增加搜索结果下拉刷新 2026-10-22 22:52:50 +08:00
Zhou Weidong 281f13a810 merge: 调整搜索结果两列布局 2026-10-22 22:50:55 +08:00
Zhou Weidong 8637f5da21 style: 调整搜索结果两列布局 2026-10-22 22:50:47 +08:00
Zhou Weidong 0319f8a6a8 merge: 统一首页与推荐搜索入口 2026-10-22 22:48:29 +08:00
Zhou Weidong 9aa797504b feat: 统一首页与推荐搜索入口 2026-10-22 22:48:21 +08:00
Zhou Weidong 933333b0fe merge: 使用点击序号防止双击误暂停 2026-10-22 22:39:30 +08:00
Zhou Weidong bbc759473c fix: 使用点击序号防止双击误暂停 2026-10-22 22:39:21 +08:00
Zhou Weidong 9ddd7fc833 merge: 修复播放器重复暂停手势 2026-10-22 22:34:26 +08:00
Zhou Weidong 5794dd4e4d fix: 修复播放器重复暂停手势 2026-10-22 22:34:18 +08:00
Zhou Weidong 76c4b528d3 merge: 修正双击点赞与心形联动 2026-10-22 22:30:11 +08:00
Zhou Weidong 68ccfa21c0 fix: 修正双击点赞与心形联动 2026-10-22 22:30:01 +08:00
Zhou Weidong 78d0166eb7 merge: 增加播放器双击点赞与飘心 2026-10-22 22:25:22 +08:00
Zhou Weidong 4140c4eb50 feat: 增加播放器双击点赞与飘心 2026-10-22 22:25:14 +08:00
Vicky a1c0af3f00 merge: 将 release/v1.0.0 同步到 main 2026-10-21 20:07:35 +08:00
Vicky b5bfc1d7b2 merge: 将 develop 同步到 release/v1.0.0 2026-10-21 20:07:30 +08:00
Vicky 1d2ba93e48 merge: 同步 release 剔除 x86_64 到 main 2026-10-21 19:13:33 +08:00
Vicky c08df9af40 merge: 同步 release 剔除 x86_64 到 release/v1.0.0 2026-10-21 19:13:28 +08:00
Vicky be166f5856 merge: 同步生产包 ABI 约束到 main 2026-10-21 19:10:11 +08:00
Vicky e531c78997 merge: 同步生产包 ABI 约束到 release/v1.0.0 2026-10-21 19:10:10 +08:00
Vicky 8ed6cd9fd6 merge: 同步生产打包单包说明到 main 2026-10-21 19:07:03 +08:00
Vicky c1d55b91a2 merge: 同步生产打包单包说明到 release/v1.0.0 2026-10-21 19:06:57 +08:00
Vicky 3dcfa02372 merge: 同步 README 生产打包说明到 main 2026-10-21 19:03:06 +08:00
Vicky b6826f7483 merge: 同步 README 生产打包说明到 release/v1.0.0 2026-10-21 19:03:01 +08:00
Vicky cd3d84cf9a merge: 将 release/v1.0.0 同步到 main 2026-10-21 18:58:26 +08:00
Vicky 010ce326ea merge: 将 develop 同步到 release/v1.0.0 2026-10-21 18:58:12 +08:00
Zhou Weidong 2577797772 merge: 将 C3 埋点同步到 main 2026-10-21 09:09:34 +08:00
Zhou Weidong 05e2952815 merge: 将 C3 埋点同步到 release/v1.0.0 2026-10-21 09:07:42 +08:00
Zhou Weidong 6ae9e8e043 merge: 将 release/v1.0.0 同步到 main 2026-10-20 22:26:48 +08:00
Zhou Weidong 182b7e3ac2 merge: 将 develop 同步到 release/v1.0.0 2026-10-20 22:23:32 +08:00
Zhou Weidong 8bb365db2a merge: 将 release/v1.0.0 合入 main 2026-10-20 20:38:58 +08:00
Zhou Weidong 0cb57d084d merge: 将 develop 合入 release/v1.0.0
# Conflicts:
#	lib/core/config/app_config.dart
#	lib/main.dart
#	test/config/server_endpoint_test.dart
2026-10-20 20:36:43 +08:00
jianghaifeng 5c174b007c Merge pull request 'Release/v1.0.0' (#1) from release/v1.0.0 into main
Reviewed-on: #1
2026-10-19 13:42:16 +00:00
Zhou Weidong 35364c949a chore(release): 合并 develop 到 release/v1.0.0 2026-10-19 21:39:21 +08:00
Zhou Weidong 814b2c0b7b chore(config): 默认服务地址改为公网 39.162.50.99:8081 2026-10-19 21:12:16 +08:00
Zhou Weidong 3d0e0804ca fix(app): 修复视频播放明文HTTP与双网络访问切换 2026-10-19 18:37:38 +08:00
193 changed files with 5976 additions and 1871 deletions

No files matched your search

+9 -8
View File
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission
android:name="android.permission.INTERNET"/>
<application <application
android:label="FruitShorts" android:label="FruitShorts"
android:name="${applicationName}" android:name="${applicationName}"
@@ -23,9 +23,8 @@
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"/>
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
@@ -35,7 +34,7 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -44,8 +43,10 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action
<data android:mimeType="text/plain"/> android:name="android.intent.action.PROCESS_TEXT"/>
<data
android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>
@@ -36,6 +36,9 @@ class MainActivity : FlutterActivity() {
private var imeInset = 0 private var imeInset = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { screen -> screen.remove() }
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list> </layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list> </layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap
</item> android:gravity="center"
<item> android:src="@drawable/splash_screen" />
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item> </item>
</layer-list> </layer-list>
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list> </layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap
</item> android:gravity="center"
<item> android:src="@drawable/splash_screen" />
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item> </item>
</layer-list> </layer-list>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<resources> <resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item> <item name="android:forceDarkAllowed">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</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:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item> <item name="android:windowBackground">@color/splash_background</item>
@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<resources> <resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item> <item name="android:forceDarkAllowed">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</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:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item> <item name="android:windowBackground">@color/splash_background</item>
+3 -2
View File
@@ -2,5 +2,6 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<resources> <resources>
<color name="splash_background">#1F1B16</color> <color name="splash_background">#000000</color>
</resources> <color name="ic_launcher_background">#FF6B2C</color>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+25
View File
@@ -1294,3 +1294,28 @@ $ flutter test
- 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。 - 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。
- 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。 - 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。
- 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。 - 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。
## 2026-10-22 播放器双击点赞与飘心
- 沉浸式播放器改用 onTapUp + 280ms Timer 判定双击,双击取消单击,长按左右倍速与中间更多保持不变。
- 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。
- 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。
- 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。
- `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。
## 2026-10-22 统一二级搜索页
- 首页与推荐/剧单/全部短剧搜索入口统一 push SearchPage,不再切换推荐 tab 聚焦。
- 搜索页提供返回、自动聚焦输入框与取消,复用 `/app/search` 逻辑和首页三列卡片。
- 清理旧的 searchRequest/focusSearch/syncSearch 参数;`flutter analyze`:No issues found。
- 搜索页结果改为独立两列卡片,左右 14、列距 12、行距 16,并移除 HomeFeedCard 复用。
- 搜索结果支持下拉刷新:保留旧结果按当前关键词重取第一页,空关键词直接回弹且不改变焦点。
## 2026-10-22 App 图标与开屏更新
- Launcher/Android 12 启动背景统一纯黑,自适应图标背景改为品牌橙。
- Android/iOS 图标与启动资源已重新生成;应用内开屏固定 splash_logo,宽 200 等比居中。
- `flutter analyze`:No issues found。
- 移除 Flutter 内 `_startupSplash` 与 `startup-splash`,只保留系统原生开屏;最新斜体字标素材已重新生成。
+4 -2
View File
@@ -2,6 +2,8 @@ import Flutter
import UIKit import UIKit
import AVFoundation import AVFoundation
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
private final class ThumbnailResponseGate { private final class ThumbnailResponseGate {
private let lock = NSLock() private let lock = NSLock()
private var completed = false private var completed = false
@@ -29,7 +31,7 @@ final class RunnerFlutterViewController: FlutterViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1) view.backgroundColor = .black
} }
} }
@@ -104,7 +106,7 @@ final class RunnerFlutterViewController: FlutterViewController {
let options: [String: Any]? = headers.isEmpty let options: [String: Any]? = headers.isEmpty
? nil ? nil
: [AVURLAssetHTTPHeaderFieldsKey: headers] : [thumbnailHTTPHeaderFieldsKey: headers]
let asset = AVURLAsset(url: assetUrl, options: options) let asset = AVURLAsset(url: assetUrl, options: options)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -16,18 +16,13 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/> <constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints> </constraints>
</view> </view>
@@ -38,7 +33,6 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="1152" height="1152"/> <image name="LaunchImage" width="390" height="844"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>
+98 -96
View File
@@ -1,109 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FruitShorts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>FruitShorts</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>NSAllowsLocalNetworking</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FruitShorts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>FruitShorts</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSExceptionDomains</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>127.0.0.1</key> <key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict> <dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>127.0.0.1</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>192.168.31.187</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>39.162.50.99</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict> <dict>
<key>UISceneClassName</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<string>UIWindowScene</string> <true/>
<key>UISceneConfigurationName</key> <key>NSIncludesSubdomains</key>
<string>flutter</string> <true/>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict> </dict>
</array> <key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>10.43.47.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>39.162.50.99</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist> </plist>
+1 -1
View File
@@ -14,7 +14,7 @@ class AppConfig {
/// 开发调试默认服务地址,当前指向本机局域网 IP。 /// 开发调试默认服务地址,当前指向本机局域网 IP。
/// ///
/// 换网络时只改这一行,地址不要带结尾斜杠。 /// 换网络时只改这一行,地址不要带结尾斜杠。
static const String devHostBaseUrl = 'http://192.168.31.187:8081'; static const String devHostBaseUrl = 'http://10.43.47.1:8081';
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
+67 -1
View File
@@ -14,6 +14,7 @@ class PlayTicket {
this.unlockCoin = 0, this.unlockCoin = 0,
this.previewSeconds = 0, this.previewSeconds = 0,
this.qualities = const [], this.qualities = const [],
this.tracks = const [],
this.thumbnailsUrl, this.thumbnailsUrl,
this.likeCount, this.likeCount,
this.commentCount, this.commentCount,
@@ -39,8 +40,11 @@ class PlayTicket {
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds; final int previewSeconds;
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 /// 后端下发的清晰度档位(如 AUTO / 720p / 1080p)。
final List<String> qualities; final List<String> qualities;
/// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。
final List<PlayQualityTrack> tracks;
final String? thumbnailsUrl; final String? thumbnailsUrl;
final int? likeCount; final int? likeCount;
final int? commentCount; final int? commentCount;
@@ -64,6 +68,7 @@ class PlayTicket {
.whereType<String>() .whereType<String>()
.where((quality) => quality.isNotEmpty) .where((quality) => quality.isNotEmpty)
.toList(), .toList(),
tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''),
thumbnailsUrl: _optionalString(json['thumbnailsUrl']), thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
likeCount: json['likeCount'] == null likeCount: json['likeCount'] == null
? null ? null
@@ -75,6 +80,67 @@ class PlayTicket {
} }
} }
List<PlayQualityTrack> _tracks(dynamic raw, String playUrl) {
final tracks = <PlayQualityTrack>[];
if (raw is List) {
for (final item in raw) {
if (item is! Map) continue;
final quality = item['quality'] as String? ?? '';
final url = item['playUrl'] as String? ?? '';
if (quality.isEmpty || url.isEmpty) continue;
tracks.add(PlayQualityTrack(quality: quality, playUrl: url));
}
}
if (tracks.isEmpty && playUrl.isNotEmpty) {
tracks.add(PlayQualityTrack(quality: 'AUTO', playUrl: playUrl));
}
return tracks;
}
/// 按票据里的分轨地址取播放地址;没有分轨时,从主列表路径推出同目录的单档地址。
String? qualityPlayUrl({
required String quality,
required List<PlayQualityTrack> tracks,
required String currentUrl,
}) {
final wanted = quality.toUpperCase();
for (final track in tracks) {
if (track.quality.toUpperCase() == wanted && track.playUrl.isNotEmpty) {
return track.playUrl;
}
}
return siblingRenditionUrl(currentUrl, wanted);
}
/// `.../master.m3u8` 与 `.../720p/index.m3u8` 互相换算,查询参数原样保留。
String? siblingRenditionUrl(String currentUrl, String quality) {
final uri = Uri.tryParse(currentUrl);
if (uri == null || uri.path.isEmpty) return null;
final path = uri.path;
const masterSuffix = '/master.m3u8';
String? root;
if (path.endsWith(masterSuffix)) {
root = path.substring(0, path.length - masterSuffix.length);
} else {
final match = RegExp(r'/(\d+p)/index\.m3u8$').firstMatch(path);
if (match == null) return null;
root = path.substring(0, match.start);
}
final next = quality.toUpperCase() == 'AUTO'
? '$root$masterSuffix'
: '$root/${quality.toLowerCase()}/index.m3u8';
if (next == path) return currentUrl;
return uri.replace(path: next).toString();
}
/// 一档清晰度的签名播放地址。
class PlayQualityTrack {
const PlayQualityTrack({required this.quality, required this.playUrl});
final String quality;
final String playUrl;
}
String? _optionalString(dynamic value) { String? _optionalString(dynamic value) {
final text = value is String ? value.trim() : ''; final text = value is String ? value.trim() : '';
return text.isEmpty ? null : text; return text.isEmpty ? null : text;
+8
View File
@@ -18,6 +18,7 @@ class WatchProgress {
this.totalEpisodes, this.totalEpisodes,
this.finished = false, this.finished = false,
this.playCount = 0, this.playCount = 0,
this.viewCount,
this.updatedAt, this.updatedAt,
}); });
@@ -31,6 +32,9 @@ class WatchProgress {
final Duration duration; final Duration duration;
final bool finished; final bool finished;
final int playCount; final int playCount;
/// 短剧热度。历史列表没下发时为空,不能拿用户自己的 playCount 冒充。
final int? viewCount;
final DateTime? updatedAt; final DateTime? updatedAt;
bool get hasPosition => position > Duration.zero; bool get hasPosition => position > Duration.zero;
@@ -57,6 +61,9 @@ class WatchProgress {
duration: Duration(milliseconds: jsonToInt(json['durationMs'])), duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true, finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']), playCount: jsonToInt(json['playCount']),
viewCount: json['viewCount'] == null
? null
: jsonToInt(json['viewCount']),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''), updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
); );
} }
@@ -72,6 +79,7 @@ class WatchProgress {
'durationMs': duration.inMilliseconds, 'durationMs': duration.inMilliseconds,
'finished': finished, 'finished': finished,
'playCount': playCount, 'playCount': playCount,
if (viewCount != null) 'viewCount': viewCount,
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
}; };
} }
+2 -1
View File
@@ -148,7 +148,8 @@ class ApiClient {
skipAuthRetry: skipAuthRetry, skipAuthRetry: skipAuthRetry,
); );
} }
onUnauthorized?.call(); final token = accessTokenReader?.call();
if (token == null || token.isEmpty) onUnauthorized?.call();
throw ApiException(401, cause.message ?? '登录已过期'); throw ApiException(401, cause.message ?? '登录已过期');
} }
if (cause is ApiException) { if (cause is ApiException) {
+18 -4
View File
@@ -33,6 +33,7 @@ class AuthApi {
required String code, required String code,
required String deviceId, required String deviceId,
String platform = '', String platform = '',
String osVersion = '',
String appVersion = '', String appVersion = '',
}) async { }) async {
final data = await _client.post( final data = await _client.post(
@@ -43,6 +44,7 @@ class AuthApi {
'code': code, 'code': code,
'deviceId': deviceId, 'deviceId': deviceId,
'platform': platform, 'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion, 'appVersion': appVersion,
}, },
); );
@@ -55,6 +57,7 @@ class AuthApi {
required String code, required String code,
required String deviceId, required String deviceId,
String platform = '', String platform = '',
String osVersion = '',
String appVersion = '', String appVersion = '',
}) async { }) async {
final data = await _client.post( final data = await _client.post(
@@ -65,6 +68,7 @@ class AuthApi {
'code': code, 'code': code,
'deviceId': deviceId, 'deviceId': deviceId,
'platform': platform, 'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion, 'appVersion': appVersion,
}, },
); );
@@ -76,6 +80,7 @@ class AuthApi {
required String password, required String password,
required String deviceId, required String deviceId,
String platform = '', String platform = '',
String osVersion = '',
String appVersion = '', String appVersion = '',
}) async { }) async {
final data = await _client.post( final data = await _client.post(
@@ -87,6 +92,7 @@ class AuthApi {
'countryCode': 'ID', 'countryCode': 'ID',
'deviceId': deviceId, 'deviceId': deviceId,
'platform': platform, 'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion, 'appVersion': appVersion,
}, },
); );
@@ -98,6 +104,7 @@ class AuthApi {
required String password, required String password,
required String deviceId, required String deviceId,
String platform = '', String platform = '',
String osVersion = '',
String appVersion = '', String appVersion = '',
}) async { }) async {
final data = await _client.post( final data = await _client.post(
@@ -108,18 +115,25 @@ class AuthApi {
'password': password, 'password': password,
'deviceId': deviceId, 'deviceId': deviceId,
'platform': platform, 'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion, 'appVersion': appVersion,
}, },
); );
return AuthSession.fromJson((data as Map).cast<String, dynamic>()); return AuthSession.fromJson((data as Map).cast<String, dynamic>());
} }
Future<AuthSession> refresh(String refreshToken) async { Future<AuthSession> refresh(
String refreshToken, {
String platform = '',
String osVersion = '',
}) async {
final data = await _client.post( final data = await _client.post(
'/app/auth/refresh', '/app/auth/refresh',
body: {'refreshToken': refreshToken}, body: {
// 刷新请求本身不带旧令牌重试,失败就是失败 'refreshToken': refreshToken,
skipAuthRetry: true, 'platform': platform,
'osVersion': osVersion,
},
); );
return AuthSession.fromJson((data as Map).cast<String, dynamic>()); return AuthSession.fromJson((data as Map).cast<String, dynamic>());
} }
+5
View File
@@ -36,4 +36,9 @@ class LibraryApi {
Future<void> removeFavorite(String dramaId) async { Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId'); await _client.delete('/app/favorites/$dramaId');
} }
/// 一次取消多部短剧,请求体为 dramaIds 数组。
Future<void> removeFavorites(List<String> dramaIds) async {
await _client.post('/app/favorites/remove', body: {'dramaIds': dramaIds});
}
} }
@@ -13,6 +13,9 @@ abstract interface class FavoriteRepository {
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}); Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
Future<void> remove(String dramaId); Future<void> remove(String dramaId);
/// 一次取消多部短剧。
Future<void> removeMany(List<String> dramaIds);
} }
class HttpFavoriteRepository implements FavoriteRepository { class HttpFavoriteRepository implements FavoriteRepository {
@@ -36,6 +39,10 @@ class HttpFavoriteRepository implements FavoriteRepository {
@override @override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId); Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
@override
Future<void> removeMany(List<String> dramaIds) =>
_api.removeFavorites(dramaIds);
} }
/// 内存实现:单测与 widget 测试注入用。 /// 内存实现:单测与 widget 测试注入用。
@@ -51,6 +58,7 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
bool failRemove = false; bool failRemove = false;
int toggleCalls = 0; int toggleCalls = 0;
int pageCalls = 0; int pageCalls = 0;
int removeManyCalls = 0;
@override @override
Future<bool> toggle(String dramaId) async { Future<bool> toggle(String dramaId) async {
@@ -80,4 +88,11 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
if (failRemove) throw StateError('favorite unavailable'); if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId); _saved.remove(dramaId);
} }
@override
Future<void> removeMany(List<String> dramaIds) async {
removeManyCalls += 1;
if (failRemove) throw StateError('favorite unavailable');
_saved.removeAll(dramaIds);
}
} }
+48 -16
View File
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/auth_session.dart'; import '../models/auth_session.dart';
@@ -9,7 +11,7 @@ import 'auth_session_store.dart';
/// ///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新; /// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 —— /// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。 /// 401 → 刷新 → 重试。令牌被拒绝才清会话,服务中断时保持登录。
class AuthController extends ChangeNotifier { class AuthController extends ChangeNotifier {
AuthController(this._api, this._store); AuthController(this._api, this._store);
@@ -19,6 +21,10 @@ class AuthController extends ChangeNotifier {
AuthSession? _session; AuthSession? _session;
String _deviceId = ''; String _deviceId = '';
bool _loaded = false; bool _loaded = false;
Future<bool>? _refreshing;
/// 登录已过期、账号停用才清本地会话。服务不可达不能当成退出登录。
static const Set<int> _rejectedSessionCodes = {401, 100001};
bool get isLoaded => _loaded; bool get isLoaded => _loaded;
bool get isSignedIn => _session != null; bool get isSignedIn => _session != null;
@@ -43,6 +49,7 @@ class AuthController extends ChangeNotifier {
password: password, password: password,
deviceId: await _store.deviceId(), deviceId: await _store.deviceId(),
platform: _platform, platform: _platform,
osVersion: _osVersion,
); );
await _apply(session); await _apply(session);
} }
@@ -53,14 +60,12 @@ class AuthController extends ChangeNotifier {
password: password, password: password,
deviceId: await _store.deviceId(), deviceId: await _store.deviceId(),
platform: _platform, platform: _platform,
osVersion: _osVersion,
); );
await _apply(session); await _apply(session);
} }
Future<void> sendOtp({ Future<void> sendOtp({required String target, String scene = 'LOGIN'}) async {
required String target,
String scene = 'LOGIN',
}) async {
await _api.sendOtp( await _api.sendOtp(
target: target, target: target,
scene: scene, scene: scene,
@@ -77,6 +82,7 @@ class AuthController extends ChangeNotifier {
code: code, code: code,
deviceId: await _store.deviceId(), deviceId: await _store.deviceId(),
platform: _platform, platform: _platform,
osVersion: _osVersion,
); );
await _apply(session); await _apply(session);
} }
@@ -90,10 +96,17 @@ class AuthController extends ChangeNotifier {
code: code, code: code,
deviceId: await _store.deviceId(), deviceId: await _store.deviceId(),
platform: _platform, platform: _platform,
osVersion: _osVersion,
); );
await _apply(session); await _apply(session);
} }
Future<void> clear() async {
_session = null;
await _store.clear();
notifyListeners();
}
Future<void> signOut() async { Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? ''; final refreshToken = _session?.refreshToken ?? '';
_session = null; _session = null;
@@ -107,29 +120,43 @@ class AuthController extends ChangeNotifier {
} }
} }
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。 /// 供 [ApiClient] 调用:刷新令牌并落盘。
Future<bool> refresh() async { ///
/// 同一时刻只刷新一次,避免重启期间并发 401 把新令牌轮换掉。
/// 网络失败或 5xx 保留本地会话;只有令牌被拒绝才退出登录。
Future<bool> refresh() {
final current = _refreshing;
if (current != null) return current;
final run = _refreshSingle();
_refreshing = run;
return run.whenComplete(() {
if (identical(_refreshing, run)) _refreshing = null;
});
}
Future<bool> _refreshSingle() async {
final refreshToken = _session?.refreshToken ?? ''; final refreshToken = _session?.refreshToken ?? '';
if (refreshToken.isEmpty) { if (refreshToken.isEmpty) {
await clear(); await clear();
return false; return false;
} }
try { try {
await _apply(await _api.refresh(refreshToken)); await _apply(
await _api.refresh(
refreshToken,
platform: _platform,
osVersion: _osVersion,
),
);
return true; return true;
} on ApiException catch (error) {
if (_rejectedSessionCodes.contains(error.code)) await clear();
return false;
} catch (_) { } catch (_) {
await clear();
return false; return false;
} }
} }
Future<void> clear() async {
if (_session == null) return;
_session = null;
await _store.clear();
notifyListeners();
}
/// 资料保存成功后同步登录态摘要,保留当前令牌。 /// 资料保存成功后同步登录态摘要,保留当前令牌。
Future<void> applyProfile(AuthUser user) async { Future<void> applyProfile(AuthUser user) async {
final current = _session; final current = _session;
@@ -154,4 +181,9 @@ class AuthController extends ChangeNotifier {
if (kIsWeb) return 'WEB'; if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID'; return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
} }
String get _osVersion {
if (kIsWeb) return '';
return Platform.operatingSystemVersion;
}
} }
@@ -161,6 +161,37 @@ class FavoriteController extends ChangeNotifier {
} }
} }
/// 一次取消多部收藏。失败时整批回滚。
Future<bool> removeMany(List<String> dramaIds) async {
final ids = dramaIds.toSet();
if (ids.isEmpty) return true;
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
final removed = cards.where((drama) => ids.contains(drama.id)).toList();
if (removed.isEmpty) return true;
cards = cards.where((drama) => !ids.contains(drama.id)).toList();
for (final id in ids) {
_saved.remove(id);
}
total = total > removed.length ? total - removed.length : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.removeMany(dramaIds);
return true;
} catch (_) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
for (final drama in removed) {
_saved.add(drama.id);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。 /// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async { Future<bool> remove(String dramaId) async {
final index = cards.indexWhere((drama) => drama.id == dramaId); final index = cards.indexWhere((drama) => drama.id == dramaId);
+120 -2
View File
@@ -5,6 +5,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/drama.dart'; import '../models/drama.dart';
import '../models/player_port.dart'; import '../models/player_port.dart';
import 'video_player_port.dart';
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerFactory = PlayerPort Function(Episode episode);
typedef PlayerPreparation = typedef PlayerPreparation =
@@ -66,6 +70,7 @@ class PlaybackSession extends ChangeNotifier {
final Set<_PlayerSlot> _live = {}; final Set<_PlayerSlot> _live = {};
_PlayerSlot? _active; _PlayerSlot? _active;
_PlayerSlot? _next; _PlayerSlot? _next;
_PlayerSlot? _handoff;
Future<void> _operations = Future<void>.value(); Future<void> _operations = Future<void>.value();
Future<void> _preloadTask = Future<void>.value(); Future<void> _preloadTask = Future<void>.value();
int _generation = 0; int _generation = 0;
@@ -127,6 +132,15 @@ class PlaybackSession extends ChangeNotifier {
return null; return null;
} }
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
PlayerPort? handoffAt(int index) {
final slot = _handoff;
if (_closed || slot == null || slot.retiring || slot.index != index) {
return null;
}
return slot.player;
}
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
void remapCurrentIndex(int index, {int? preloadIndex}) { void remapCurrentIndex(int index, {int? preloadIndex}) {
if (_closed || index < 0 || index >= episodes.length) return; if (_closed || index < 0 || index >= episodes.length) return;
@@ -184,9 +198,10 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _retire(_PlayerSlot? slot) async { Future<void> _retire(_PlayerSlot? slot) async {
if (slot == null || !_live.contains(slot)) return; if (slot == null || !_live.contains(slot)) return;
slot.retiring = true; slot.retiring = true;
final cleared = _active == slot || _next == slot; final cleared = _active == slot || _next == slot || _handoff == slot;
if (_active == slot) _active = null; if (_active == slot) _active = null;
if (_next == slot) _next = null; if (_next == slot) _next = null;
if (_handoff == slot) _handoff = null;
if (cleared) _notify(); if (cleared) _notify();
await waitForFrame?.call(); await waitForFrame?.call();
await slot.ready; await slot.ready;
@@ -235,6 +250,7 @@ class PlaybackSession extends ChangeNotifier {
_PlayerSlot? target; _PlayerSlot? target;
await _enqueue(() async { await _enqueue(() async {
if (_closed || request != _generation) return; if (_closed || request != _generation) return;
await _retire(_handoff);
final old = _active; final old = _active;
if (old != null && if (old != null &&
old.index == index && old.index == index &&
@@ -272,6 +288,108 @@ class PlaybackSession extends ChangeNotifier {
if (!_closed && request == _generation) _schedulePreload(); if (!_closed && request == _generation) _schedulePreload();
} }
/// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。
Future<bool> replaceCurrent() async {
if (_closed || !_selected) return false;
final index = _currentIndex;
if (index < 0 || index >= episodes.length || isLocked) return false;
if (!canPlay(episodes[index])) return false;
final request = _generation;
final slot = await _prepareReplacement(index, request);
if (slot == null) {
await _resumeActive();
return false;
}
await slot.ready;
if (!_replacementReady(slot, request)) {
await _dropReplacement(slot);
await _resumeActive();
return false;
}
final previous = await _showReplacement(slot, request);
if (_active != slot) {
await _dropReplacement(slot);
return false;
}
await _enqueue(() => _start(slot, request));
await Future<void>.delayed(_qualityHandoffHold);
if (previous != null && _handoff == previous && _active == slot) {
await _enqueue(() => _retire(previous));
}
if (!_closed && request == _generation) _schedulePreload();
return _active == slot && slot.failure == null;
}
Future<_PlayerSlot?> _prepareReplacement(int index, int request) async {
_PlayerSlot? created;
await _enqueue(() async {
if (_closed || request != _generation || _active == null) return;
final warm = _next;
_next = null;
await _retire(_handoff);
await _retire(warm);
if (_closed || request != _generation || _active == null) return;
try {
created = _create(index);
} catch (_) {
created = null;
}
});
return created;
}
bool _replacementReady(_PlayerSlot slot, int request) {
return !_closed &&
request == _generation &&
_active != null &&
slot.failure == null &&
slot.prepared;
}
Future<void> _resumeActive() async {
final slot = _active;
if (slot == null || !_userWantsPlay || _closed) return;
await _enqueue(() => _applyPlayback(slot));
}
Future<void> _dropReplacement(_PlayerSlot slot) {
return _enqueue(() async {
if (_live.contains(slot)) await _retire(slot);
});
}
/// 先把已出画的新播放器交到界面上,旧播放器留给调用方稍后释放。
Future<_PlayerSlot?> _showReplacement(_PlayerSlot slot, int request) async {
_PlayerSlot? previous;
var shown = false;
await _enqueue(() async {
if (!_replacementReady(slot, request)) return;
previous = _active;
await _catchUp(slot, previous);
_handoff = previous;
_active = slot;
shown = true;
if (previous != null) await previous!.player.pause();
_notify();
});
if (!shown || _active != slot) return null;
return previous;
}
/// 旧片仍在播,把新片对齐到当前进度,盖住时画面才接得上。
Future<void> _catchUp(_PlayerSlot slot, _PlayerSlot? previous) async {
final next = slot.player;
final current = previous?.player;
if (next is! VideoPlayerPort || current is! VideoPlayerPort) return;
final position = current.controller.value.position;
if (position <= Duration.zero) return;
try {
await next.seekTo(position);
} catch (_) {
// 对齐失败仍用准备阶段的进度,不因此放弃切换。
}
}
Future<void> _start(_PlayerSlot slot, int request) async { Future<void> _start(_PlayerSlot slot, int request) async {
bool valid() => !_closed && request == _generation && _active == slot; bool valid() => !_closed && request == _generation && _active == slot;
if (!valid()) return; if (!valid()) return;
@@ -387,7 +505,7 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _releasePlayers() { Future<void> _releasePlayers() {
_generation++; _generation++;
return _enqueue(() async { return _enqueue(() async {
_active = _next = null; _active = _next = _handoff = null;
_loading = false; _loading = false;
_notify(); _notify();
for (final slot in _live.toList()) { for (final slot in _live.toList()) {
@@ -122,6 +122,8 @@ abstract interface class SpriteThumbnailAxisSource implements Listenable {
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position); SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
bool hasAxis(String? thumbnailsUrl); bool hasAxis(String? thumbnailsUrl);
Future<void> prepareAt(String? thumbnailsUrl, Duration position);
} }
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。 /// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
@@ -153,6 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
List<String> get spriteCacheKeys => _spriteCache.keys.toList(); List<String> get spriteCacheKeys => _spriteCache.keys.toList();
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
@override
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async { Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
final url = _normalizeUrl(thumbnailsUrl); final url = _normalizeUrl(thumbnailsUrl);
if (_disposed || url == null) return; if (_disposed || url == null) return;
+31
View File
@@ -2,6 +2,37 @@
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// 按后端 `ErrorCode` 取当前语言文案。未收录的业务码回退服务端 message。
String userFacingApiMessage(AppLocalizations l10n, int code, String message) {
final localized = _apiMessage(l10n, code);
if (localized != null) return localized;
if (message.isEmpty) return l10n.authServerError;
return message;
}
/// 已定义错误码。500 用服务繁忙,不展示「系统异常」。
String? _apiMessage(AppLocalizations l10n, int code) {
return switch (code) {
400 => l10n.apiErrorBadRequest,
401 => l10n.apiErrorUnauthorized,
403 => l10n.apiErrorForbidden,
404 => l10n.apiErrorNotFound,
429 => l10n.apiErrorTooManyRequests,
500 => l10n.authServerError,
100000 => l10n.apiErrorLoginFailed,
100001 => l10n.apiErrorAccountDisabled,
100002 => l10n.apiErrorCoinNotEnough,
100003 => l10n.apiErrorEpisodeUnavailable,
100004 => l10n.apiErrorEpisodeUnlocked,
100005 => l10n.apiErrorOrderInvalid,
100006 => l10n.apiErrorVipExpired,
200001 => l10n.apiErrorDataExists,
200002 => l10n.apiErrorDataNotFound,
200003 => l10n.apiErrorNotAllowed,
_ => null,
};
}
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与 /// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。 /// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) { String errorText(AppLocalizations l10n, String? code, String fallback) {
+127
View File
@@ -0,0 +1,127 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../theme/app_colors.dart';
const double _bodySize = 16;
const double _headingSize = 18;
const double _blockGap = 12;
const double _lineHeight = 1.7;
const TextStyle _bodyStyle = TextStyle(
color: AppColors.textPrimary,
fontSize: _bodySize,
height: _lineHeight,
);
/// 把协议页的 HTML 正文画成普通控件。
class LegalArticleView extends StatelessWidget {
const LegalArticleView({super.key, required this.html});
final String html;
@override
Widget build(BuildContext context) {
final fragment = html_parser.parseFragment(html);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final node in fragment.nodes) _block(node),
],
);
}
}
Widget _block(dom.Node node) {
if (node is! dom.Element) {
return _paragraph(node.text ?? '');
}
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
return _heading(node.text);
case 'ul':
case 'ol':
return _list(node);
case 'pre':
return _paragraph(node.text);
case 'blockquote':
return _paragraph(node.text);
default:
return _paragraphSpan(node);
}
}
Widget _heading(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text(
text.trim(),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _headingSize,
height: 1.4,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _list(dom.Element element) {
final items = element.children.where((child) => child.localName == 'li');
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('• ${item.text.trim()}', style: _bodyStyle),
),
],
),
);
}
Widget _paragraph(String text) {
final value = text.trim();
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text(value, style: _bodyStyle),
);
}
Widget _paragraphSpan(dom.Element element) {
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text.rich(TextSpan(style: _bodyStyle, children: _inlines(element))),
);
}
List<InlineSpan> _inlines(dom.Node node) {
if (node is dom.Text) {
return [TextSpan(text: node.text)];
}
if (node is! dom.Element) return const [];
final children = node.nodes.expand(_inlines).toList();
switch (node.localName) {
case 'strong':
case 'b':
return [TextSpan(style: const TextStyle(fontWeight: FontWeight.w700), children: children)];
case 'em':
case 'i':
return [TextSpan(style: const TextStyle(fontStyle: FontStyle.italic), children: children)];
case 'a':
return [TextSpan(style: const TextStyle(color: AppColors.blue), children: children)];
case 'br':
return const [TextSpan(text: '\n')];
default:
return children;
}
}
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../config/app_config.dart';
final RegExp _articlePattern = RegExp(
'<article>([\\s\\S]*)</article>',
caseSensitive: false,
);
/// 从协议页里取出正文。没有 article 时退回整页。
String legalArticle(String html) {
final match = _articlePattern.firstMatch(html);
final body = match?.group(1);
if (body == null || body.trim().isEmpty) return html;
return body.trim();
}
/// 拉取协议页 HTML。不走业务 JSON 包装。
Future<String> fetchLegalArticle(String url) async {
final dio = Dio(
BaseOptions(
connectTimeout: AppConfig.requestTimeout,
receiveTimeout: AppConfig.requestTimeout,
responseType: ResponseType.plain,
validateStatus: (code) => code != null && code >= 200 && code < 300,
),
);
final response = await dio.get<String>(url);
final body = response.data;
if (body == null || body.trim().isEmpty) {
throw StateError('empty legal page');
}
return legalArticle(body);
}
+12
View File
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'legal_web_page.dart';
/// 在当前 App 里打开协议地址。
Future<void> openLegalUrlOrNotify(BuildContext context, String url) {
return Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => LegalWebPage(url: url)),
);
}
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../config/app_config.dart';
/// 隐私协议公开路径,对应 `/privacy/{语言}`。
const String legalPrivacyPath = 'privacy';
/// 服务条款公开路径,对应 `/terms/{语言}`。
const String legalTermsPath = 'terms';
const Set<String> _legalLanguages = {'zh', 'en', 'id'};
/// 界面语言落到协议页支持的三种语言。其余语言用英文。
String legalLanguageCode(Locale locale) {
final code = locale.languageCode;
if (_legalLanguages.contains(code)) return code;
return 'en';
}
/// 拼出当前服务器上的协议页地址。
String legalPageUrl(String path, Locale locale) {
final base = AppConfig.apiBaseUrl;
final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base;
return '$root/$path/${legalLanguageCode(locale)}';
}
+96
View File
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/app_colors.dart';
import '../widgets/plain_page_scaffold.dart';
import 'legal_article_view.dart';
import 'legal_html.dart';
const double _pagePadding = 20;
/// 在应用内展示协议页,不使用系统 WebView。
class LegalWebPage extends StatefulWidget {
const LegalWebPage({super.key, required this.url, this.load});
final String url;
/// 测试时可替换网络请求。
final Future<String> Function(String url)? load;
@override
State<LegalWebPage> createState() => _LegalWebPageState();
}
class _LegalWebPageState extends State<LegalWebPage> {
String? _html;
var _failed = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_html = null;
_failed = false;
});
try {
final loader = widget.load ?? fetchLegalArticle;
final html = await loader(widget.url);
if (!mounted) return;
setState(() => _html = html);
} catch (_) {
if (!mounted) return;
setState(() => _failed = true);
}
}
@override
Widget build(BuildContext context) {
return PlainPageScaffold(
pageKey: const Key('legal-web-page'),
backKey: const Key('legal-web-back'),
title: '',
child: _body(context),
);
}
Widget _body(BuildContext context) {
final html = _html;
if (html != null) {
return ListView(
padding: const EdgeInsets.fromLTRB(_pagePadding, 8, _pagePadding, 28),
children: [
LegalArticleView(html: html),
],
);
}
if (_failed) return _failure(context);
return const Center(
child: CircularProgressIndicator(
key: Key('legal-web-loading'),
color: AppColors.blue,
),
);
}
Widget _failure(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.legalOpenFailed, style: const TextStyle(color: AppColors.muted)),
TextButton(
key: const Key('legal-web-retry'),
onPressed: _load,
child: Text(l10n.retry),
),
],
),
);
}
}
+5
View File
@@ -60,6 +60,8 @@ abstract final class AppColors {
/// 收藏与喜欢(非主色铺色,独立强调)。 /// 收藏与喜欢(非主色铺色,独立强调)。
static const favorite = Color(0xFFFF6A8A); static const favorite = Color(0xFFFF6A8A);
static const doubleTapHeartRed = Color(0xFFFF4D6D);
static const doubleTapHeartPink = Color(0xFFFF7A9A);
/// 金币底色上的文字。 /// 金币底色上的文字。
static const onGold = Color(0xFF3D2408); static const onGold = Color(0xFF3D2408);
@@ -76,6 +78,7 @@ abstract final class AppColors {
/// 中性深灰卡片与控件表面。 /// 中性深灰卡片与控件表面。
static const contentCard = Color(0xFF16161A); static const contentCard = Color(0xFF16161A);
static const contentSurface = Color(0xFF1C1C1E); static const contentSurface = Color(0xFF1C1C1E);
static const skeletonSurface = Color(0xFF141414);
static const contentChip = Color(0xFF2C2C2E); static const contentChip = Color(0xFF2C2C2E);
static const contentBorder = Color(0x1AFFFFFF); static const contentBorder = Color(0x1AFFFFFF);
static const filterChipSelected = Color(0xFF21090E); static const filterChipSelected = Color(0xFF21090E);
@@ -102,6 +105,7 @@ abstract final class AppColors {
static const mineIconSupport = Color(0xFFA78BFA); static const mineIconSupport = Color(0xFFA78BFA);
static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconLanguage = Color(0xFF22D3EE);
static const mineIconSettings = Color(0xFFA1A1AA); static const mineIconSettings = Color(0xFFA1A1AA);
static const mineIconNotices = Color(0xFFFF8A4C);
static const recommendPlaylistGlow = Color(0x26FF6B2C); static const recommendPlaylistGlow = Color(0x26FF6B2C);
/// 次级文字与列表 chevron。 /// 次级文字与列表 chevron。
@@ -110,6 +114,7 @@ abstract final class AppColors {
static const textTertiary = Color(0xFF8E8E93); static const textTertiary = Color(0xFF8E8E93);
static const textTabInactive = Color(0xFFB0B0B0); static const textTabInactive = Color(0xFFB0B0B0);
static const textFilterIcon = Color(0xFF7A7A7E); static const textFilterIcon = Color(0xFF7A7A7E);
static const searchResultMeta = Color(0xFF9E9E9E);
/// 原生开屏与冷启动过渡底色。 /// 原生开屏与冷启动过渡底色。
static const splashInk = ink; static const splashInk = ink;
+12 -1
View File
@@ -62,6 +62,12 @@ abstract final class AppDimens {
static const double searchHeight = 40; static const double searchHeight = 40;
static const double searchRadius = 8; static const double searchRadius = 8;
static const double searchPlaceholderFont = 13; static const double searchPlaceholderFont = 13;
static const double searchResultGap = 12;
static const double searchResultRowGap = 16;
static const double searchResultTitleGap = 8;
static const double searchResultMetaGap = 6;
static const double searchResultMetaFont = 12;
static const double searchResultTextExtent = 68;
static const double navDot = 3; static const double navDot = 3;
static const double glassBlur = 24; static const double glassBlur = 24;
@@ -104,6 +110,11 @@ abstract final class AppDimens {
static const double cardBadgeFont = 9; static const double cardBadgeFont = 9;
static const double cardCoverTitleGap = 6; static const double cardCoverTitleGap = 6;
static const double cardTitleTagGap = 6; static const double cardTitleTagGap = 6;
static const double skeletonBarHeight = 10;
static const double skeletonBarRadius = 4;
static const double skeletonBarGap = 4;
static const double skeletonTitleBarGap = 3;
static const double skeletonBodyExtent = 34;
static const double cardCoverAspectRatio = 3 / 4; static const double cardCoverAspectRatio = 3 / 4;
static const double homeCardBodyExtent = 84; static const double homeCardBodyExtent = 84;
static const double homePrimaryTabFont = 18; static const double homePrimaryTabFont = 18;
@@ -176,7 +187,7 @@ abstract final class AppDimens {
static const double followingAddIconStroke = 1.5; static const double followingAddIconStroke = 1.5;
static const double followingGridGap = 6; static const double followingGridGap = 6;
static const double followingGridRowGap = 12; static const double followingGridRowGap = 12;
static const double followingCardTextExtent = 66; static const double followingCardTextExtent = 70;
static const double followingSubtitleFont = 12; static const double followingSubtitleFont = 12;
static const double followingSubtitleGap = 4; static const double followingSubtitleGap = 4;
static const double followingProgressHeight = 2; static const double followingProgressHeight = 2;
+96
View File
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
class DramaGridSkeleton extends StatelessWidget {
const DramaGridSkeleton({
super.key,
required this.columns,
required this.horizontalPadding,
required this.crossAxisSpacing,
required this.mainAxisSpacing,
this.itemCount = 9,
this.physics = const NeverScrollableScrollPhysics(),
});
final int columns;
final double horizontalPadding;
final double crossAxisSpacing;
final double mainAxisSpacing;
final int itemCount;
final ScrollPhysics physics;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
horizontalPadding * 2 -
crossAxisSpacing * (columns - 1)) /
columns;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.skeletonBodyExtent;
return GridView.builder(
physics: physics,
padding: EdgeInsets.fromLTRB(
horizontalPadding,
AppDimens.spaceMd,
horizontalPadding,
AppDimens.spaceXl,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
mainAxisExtent: itemExtent,
),
itemCount: itemCount,
itemBuilder: (context, index) => Column(
key: Key('drama-grid-skeleton-$index'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Container(
decoration: BoxDecoration(
color: AppColors.skeletonSurface,
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
),
),
),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
const _SkeletonBar(),
const SizedBox(height: AppDimens.skeletonTitleBarGap),
const FractionallySizedBox(
widthFactor: 0.62,
child: _SkeletonBar(),
),
],
),
);
},
);
}
}
class _SkeletonBar extends StatelessWidget {
const _SkeletonBar();
@override
Widget build(BuildContext context) {
return Container(
height: AppDimens.skeletonBarHeight,
decoration: BoxDecoration(
color: AppColors.skeletonSurface,
borderRadius: BorderRadius.circular(AppDimens.skeletonBarRadius),
),
);
}
}
+7 -8
View File
@@ -5,8 +5,8 @@ import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_dimens.dart'; import '../theme/app_dimens.dart';
const double _regularIconSize = 48; const double _regularIconSize = 72;
const double _compactIconSize = 32; const double _compactIconSize = 44;
const double _maxContentWidth = 260; const double _maxContentWidth = 260;
const double _regularHorizontal = 24; const double _regularHorizontal = 24;
const double _regularVertical = 28; const double _regularVertical = 28;
@@ -78,7 +78,7 @@ class EmptyState extends StatelessWidget {
} }
} }
/// 空状态只留一枚图标,不加水印底和描边。 /// 空列表只放一枚大图标,不画线框,也不铺底色。
class _EmptyMark extends StatelessWidget { class _EmptyMark extends StatelessWidget {
const _EmptyMark({required this.icon, required this.compact}); const _EmptyMark({required this.icon, required this.compact});
@@ -91,7 +91,7 @@ class _EmptyMark extends StatelessWidget {
icon, icon,
key: const Key('empty-state-icon'), key: const Key('empty-state-icon'),
size: compact ? _compactIconSize : _regularIconSize, size: compact ? _compactIconSize : _regularIconSize,
color: AppColors.blueSoft, color: AppColors.subtle,
); );
} }
} }
@@ -115,7 +115,6 @@ class _EmptyCopy extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -126,10 +125,10 @@ class _EmptyCopy extends StatelessWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
color: scheme.onSurface, color: AppColors.textTertiary,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w400,
height: 1.35, height: 1.35,
), ),
), ),
+24 -13
View File
@@ -31,34 +31,45 @@ class RemoteImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path); final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _fallback(); if (url.isEmpty) return _fallback(brand: showFallbackIcon);
return Image.network( return Image.network(
url, url,
width: width, width: width,
height: height, height: height,
fit: fit, fit: fit,
errorBuilder: (_, _, _) => _fallback(), gaplessPlayback: true,
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child; if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback(); return _fallback(brand: showFallbackIcon);
}, },
); );
} }
Widget _fallback() { Widget _fallback({required bool brand}) {
final mark = brand
? LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: 120.0;
return Center(
child: Image.asset(
'assets/icon/splash_logo.png',
key: fallbackIconKey,
width: width * 0.72,
fit: BoxFit.contain,
),
);
},
)
: null;
final fallback = Container( final fallback = Container(
width: width, width: width,
height: height, height: height,
color: fallbackColor, color: brand ? AppColors.mediaBlack : fallbackColor,
alignment: Alignment.center, alignment: Alignment.center,
child: showFallbackIcon child: mark,
? Icon(
Icons.image_not_supported_outlined,
key: fallbackIconKey,
color: AppColors.muted,
size: 32,
)
: null,
); );
final ratio = fallbackAspectRatio; final ratio = fallbackAspectRatio;
if (ratio == null) return fallback; if (ratio == null) return fallback;
+10 -2
View File
@@ -13,7 +13,9 @@ const double _titleFontSize = 18;
const double _subtitleFontSize = 12; const double _subtitleFontSize = 12;
const double _actionGap = 8; const double _actionGap = 8;
const double _iconSize = 22; const double _iconSize = 22;
const double _backIconSize = 34;
const double _iconWeight = 600; const double _iconWeight = 600;
const double _backIconWeight = 400;
const double _shadowBlur = 8; const double _shadowBlur = 8;
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。 /// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
@@ -24,6 +26,8 @@ class AppSubpageCircleButton extends StatelessWidget {
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.iconSize = _iconSize,
this.iconWeight = _iconWeight,
this.dark = false, this.dark = false,
}); });
@@ -31,6 +35,8 @@ class AppSubpageCircleButton extends StatelessWidget {
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback onPressed;
final double iconSize;
final double iconWeight;
final bool dark; final bool dark;
@override @override
@@ -65,8 +71,8 @@ class AppSubpageCircleButton extends StatelessWidget {
child: Icon( child: Icon(
icon, icon,
color: dark ? AppColors.onSurface : AppColors.textPrimary, color: dark ? AppColors.onSurface : AppColors.textPrimary,
size: _iconSize, size: iconSize,
weight: _iconWeight, weight: iconWeight,
), ),
), ),
), ),
@@ -96,6 +102,8 @@ class AppSubpageBackButton extends StatelessWidget {
buttonKey: backKey, buttonKey: backKey,
tooltip: MaterialLocalizations.of(context).backButtonTooltip, tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icons.chevron_left_rounded, icon: Icons.chevron_left_rounded,
iconSize: _backIconSize,
iconWeight: _backIconWeight,
dark: dark, dark: dark,
onPressed: onBack ?? () => Navigator.of(context).maybePop(), onPressed: onBack ?? () => Navigator.of(context).maybePop(),
); );
+33 -4
View File
@@ -16,12 +16,20 @@
"currentSeries": "Current series", "currentSeries": "Current series",
"discoverTab": "Theater", "discoverTab": "Theater",
"searchDiscover": "Search dramas, actors, genres...", "searchDiscover": "Search dramas, actors, genres...",
"searchCancel": "Cancel",
"featured": "Featured", "featured": "Featured",
"recommended": "Recommended", "recommended": "Recommended",
"fruitShortsBrand": "FruitShorts", "fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Sign in to sync progress, favorites and coins", "authLandingSubtitle": "Sign in to sync progress, favorites and coins",
"comingSoon": "Coming soon", "comingSoon": "Coming soon",
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
"authLegalLead": "By continuing, you agree to the",
"authTerms": "Terms of Service",
"authLegalJoin": "and",
"authPrivacy": "Privacy Policy",
"legalTerms": "Terms of Service",
"legalPrivacy": "Privacy Policy",
"legalOpenFailed": "Can't open this page right now",
"authContinueGoogle": "Continue with Google", "authContinueGoogle": "Continue with Google",
"authPhoneSignIn": "Phone sign in", "authPhoneSignIn": "Phone sign in",
"authEmailSignIn": "Email sign in", "authEmailSignIn": "Email sign in",
@@ -429,6 +437,22 @@
"authPasswordLength": "Password must be 8-64 characters", "authPasswordLength": "Password must be 8-64 characters",
"authPasswordWeak": "Password needs both letters and numbers", "authPasswordWeak": "Password needs both letters and numbers",
"authNetworkError": "Network error, please try again", "authNetworkError": "Network error, please try again",
"authServerError": "The service is busy. Please try again shortly.",
"apiErrorBadRequest": "Invalid request",
"apiErrorUnauthorized": "Sign in to continue",
"apiErrorForbidden": "You don't have permission",
"apiErrorNotFound": "Not found",
"apiErrorTooManyRequests": "Too many requests. Please try again later.",
"apiErrorLoginFailed": "Incorrect account or password",
"apiErrorAccountDisabled": "This account has been disabled",
"apiErrorCoinNotEnough": "Not enough coins",
"apiErrorEpisodeUnavailable": "This episode is unavailable",
"apiErrorEpisodeUnlocked": "This episode is already unlocked",
"apiErrorOrderInvalid": "This order is already paid or closed",
"apiErrorVipExpired": "Membership has expired",
"apiErrorDataExists": "This record already exists",
"apiErrorDataNotFound": "Not found",
"apiErrorNotAllowed": "This action isn't available right now",
"authSwitchToSignUp": "New here? Create an account", "authSwitchToSignUp": "New here? Create an account",
"authSwitchToSignIn": "Already have an account? Sign in", "authSwitchToSignIn": "Already have an account? Sign in",
"authGateTitle": "Sign in to continue", "authGateTitle": "Sign in to continue",
@@ -585,7 +609,7 @@
"accountUnverified": "Unverified", "accountUnverified": "Unverified",
"accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.", "accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.",
"accountDeactivateReason": "Reason (optional)", "accountDeactivateReason": "Reason (optional)",
"accountDeactivateReasonHint": "Tell us why you are leaving", "accountDeactivateReasonHint": "Add more detail here",
"accountRequestDeactivate": "Request account deletion", "accountRequestDeactivate": "Request account deletion",
"accountConfirmDeactivateTitle": "Request account deletion?", "accountConfirmDeactivateTitle": "Request account deletion?",
"accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.", "accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.",
@@ -665,6 +689,8 @@
"paymentPending": "Waiting for payment.", "paymentPending": "Waiting for payment.",
"orderCreateFailed": "Could not create the order.", "orderCreateFailed": "Could not create the order.",
"playbackQuality": "Quality", "playbackQuality": "Quality",
"qualitySwitched": "Switched to {quality}",
"@qualitySwitched": { "placeholders": { "quality": { "type": "String" } } },
"qualityAuto": "Auto", "qualityAuto": "Auto",
"qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.",
"searchFailed": "Search failed. Please try again.", "searchFailed": "Search failed. Please try again.",
@@ -707,8 +733,7 @@
"homeBadgeHot": "Hot", "homeBadgeHot": "Hot",
"homeBadgeNew": "New", "homeBadgeNew": "New",
"homeBadgeAnime": "Anime", "homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "No dramas match these filters", "emptyContent": "No content yet",
"homeGridClearFilter": "Clear filters",
"recommendTopTab": "Recommend", "recommendTopTab": "Recommend",
"playlistTopTab": "Playlists", "playlistTopTab": "Playlists",
"allDramasTopTab": "All Dramas", "allDramasTopTab": "All Dramas",
@@ -727,6 +752,10 @@
"modernFilter": "Modern", "modernFilter": "Modern",
"edit": "Edit", "edit": "Edit",
"done": "Done", "done": "Done",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"cancelFollowing": "Unfollow",
"confirmCancelFollowing": "Unfollow the selected dramas?",
"visitor": "Visitor", "visitor": "Visitor",
"copyUid": "Copy UID", "copyUid": "Copy UID",
"uidCopied": "UID copied", "uidCopied": "UID copied",
@@ -745,7 +774,7 @@
"watchedToEpisode": "Watched to Episode {episode}", "watchedToEpisode": "Watched to Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Watch all", "homeWatchAll": "Watch all",
"confirmRemove": "Remove this item?", "confirmRemove": "Remove this watch history?",
"followingCountLabel": "Following", "followingCountLabel": "Following",
"watchRecordsCountLabel": "Watch records", "watchRecordsCountLabel": "Watch records",
"dataUnavailable": "—", "dataUnavailable": "—",
+34 -5
View File
@@ -16,12 +16,20 @@
"currentSeries": "Serial saat ini", "currentSeries": "Serial saat ini",
"discoverTab": "Teater", "discoverTab": "Teater",
"searchDiscover": "Cari drama, aktor, genre...", "searchDiscover": "Cari drama, aktor, genre...",
"searchCancel": "Batal",
"featured": "Unggulan", "featured": "Unggulan",
"recommended": "Rekomendasi", "recommended": "Rekomendasi",
"fruitShortsBrand": "FruitShorts", "fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
"comingSoon": "Segera hadir", "comingSoon": "Segera hadir",
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
"authLegalLead": "Dengan melanjutkan, Anda menyetujui",
"authTerms": "Ketentuan Layanan",
"authLegalJoin": "dan",
"authPrivacy": "Kebijakan Privasi",
"legalTerms": "Ketentuan Layanan",
"legalPrivacy": "Kebijakan Privasi",
"legalOpenFailed": "Halaman ini belum bisa dibuka",
"authContinueGoogle": "Lanjutkan dengan Google", "authContinueGoogle": "Lanjutkan dengan Google",
"authPhoneSignIn": "Masuk dengan ponsel", "authPhoneSignIn": "Masuk dengan ponsel",
"authEmailSignIn": "Masuk dengan email", "authEmailSignIn": "Masuk dengan email",
@@ -403,6 +411,22 @@
"authPasswordLength": "Kata sandi harus 8-64 karakter", "authPasswordLength": "Kata sandi harus 8-64 karakter",
"authPasswordWeak": "Kata sandi perlu huruf dan angka", "authPasswordWeak": "Kata sandi perlu huruf dan angka",
"authNetworkError": "Jaringan bermasalah, coba lagi", "authNetworkError": "Jaringan bermasalah, coba lagi",
"authServerError": "Layanan sedang sibuk. Coba lagi sebentar.",
"apiErrorBadRequest": "Permintaan tidak valid",
"apiErrorUnauthorized": "Masuk untuk melanjutkan",
"apiErrorForbidden": "Anda tidak memiliki izin",
"apiErrorNotFound": "Tidak ditemukan",
"apiErrorTooManyRequests": "Terlalu banyak permintaan. Coba lagi nanti.",
"apiErrorLoginFailed": "Akun atau kata sandi salah",
"apiErrorAccountDisabled": "Akun ini telah dinonaktifkan",
"apiErrorCoinNotEnough": "Koin tidak cukup",
"apiErrorEpisodeUnavailable": "Episode ini tidak tersedia",
"apiErrorEpisodeUnlocked": "Episode ini sudah terbuka",
"apiErrorOrderInvalid": "Pesanan ini sudah dibayar atau ditutup",
"apiErrorVipExpired": "Keanggotaan telah berakhir",
"apiErrorDataExists": "Data sudah ada",
"apiErrorDataNotFound": "Data tidak ditemukan",
"apiErrorNotAllowed": "Tindakan ini tidak tersedia saat ini",
"authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignUp": "Baru di sini? Buat akun",
"authSwitchToSignIn": "Sudah punya akun? Masuk", "authSwitchToSignIn": "Sudah punya akun? Masuk",
"authGateTitle": "Masuk untuk melanjutkan", "authGateTitle": "Masuk untuk melanjutkan",
@@ -549,7 +573,7 @@
"accountUnverified": "Belum terverifikasi", "accountUnverified": "Belum terverifikasi",
"accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.", "accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.",
"accountDeactivateReason": "Alasan (opsional)", "accountDeactivateReason": "Alasan (opsional)",
"accountDeactivateReasonHint": "Beri tahu alasan kamu", "accountDeactivateReasonHint": "Tambahkan penjelasan di sini",
"accountRequestDeactivate": "Ajukan penghapusan akun", "accountRequestDeactivate": "Ajukan penghapusan akun",
"accountConfirmDeactivateTitle": "Ajukan penghapusan akun?", "accountConfirmDeactivateTitle": "Ajukan penghapusan akun?",
"accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.", "accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.",
@@ -622,6 +646,8 @@
"paymentPending": "Menunggu pembayaran.", "paymentPending": "Menunggu pembayaran.",
"orderCreateFailed": "Gagal membuat pesanan.", "orderCreateFailed": "Gagal membuat pesanan.",
"playbackQuality": "Kualitas", "playbackQuality": "Kualitas",
"qualitySwitched": "Beralih ke {quality}",
"@qualitySwitched": { "placeholders": { "quality": { "type": "String" } } },
"qualityAuto": "Otomatis", "qualityAuto": "Otomatis",
"qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.",
"searchFailed": "Pencarian gagal. Coba lagi.", "searchFailed": "Pencarian gagal. Coba lagi.",
@@ -664,11 +690,10 @@
"homeBadgeHot": "Populer", "homeBadgeHot": "Populer",
"homeBadgeNew": "Baru", "homeBadgeNew": "Baru",
"homeBadgeAnime": "Anime", "homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", "emptyContent": "Belum ada konten",
"homeGridClearFilter": "Hapus filter",
"recommendTopTab": "Rekomendasi", "recommendTopTab": "Rekomendasi",
"playlistTopTab": "Daftar Putar", "playlistTopTab": "Daftar Putar",
"allDramasTopTab": "Semua Drama", "allDramasTopTab": "Semua",
"weeklyHitsTitle": "Hits Minggu Ini", "weeklyHitsTitle": "Hits Minggu Ini",
"weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!", "weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!",
"weeklyNewTitle": "Drama Baru Minggu Ini", "weeklyNewTitle": "Drama Baru Minggu Ini",
@@ -684,6 +709,10 @@
"modernFilter": "Modern", "modernFilter": "Modern",
"edit": "Edit", "edit": "Edit",
"done": "Selesai", "done": "Selesai",
"selectAll": "Pilih semua",
"deselectAll": "Batal pilih",
"cancelFollowing": "Berhenti mengikuti",
"confirmCancelFollowing": "Berhenti mengikuti drama yang dipilih?",
"visitor": "Pengunjung", "visitor": "Pengunjung",
"copyUid": "Salin UID", "copyUid": "Salin UID",
"uidCopied": "UID disalin", "uidCopied": "UID disalin",
@@ -702,7 +731,7 @@
"watchedToEpisode": "Ditonton hingga Episode {episode}", "watchedToEpisode": "Ditonton hingga Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Tonton semua", "homeWatchAll": "Tonton semua",
"confirmRemove": "Hapus item ini?", "confirmRemove": "Hapus riwayat tontonan ini?",
"followingCountLabel": "Mengikuti", "followingCountLabel": "Mengikuti",
"watchRecordsCountLabel": "Riwayat tontonan", "watchRecordsCountLabel": "Riwayat tontonan",
"dataUnavailable": "—", "dataUnavailable": "—",
+179 -11
View File
@@ -196,6 +196,12 @@ abstract class AppLocalizations {
/// **'Search dramas, actors, genres...'** /// **'Search dramas, actors, genres...'**
String get searchDiscover; String get searchDiscover;
/// No description provided for @searchCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get searchCancel;
/// No description provided for @featured. /// No description provided for @featured.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -232,6 +238,48 @@ abstract class AppLocalizations {
/// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'**
String get authLegal; String get authLegal;
/// No description provided for @authLegalLead.
///
/// In en, this message translates to:
/// **'By continuing, you agree to the'**
String get authLegalLead;
/// No description provided for @authTerms.
///
/// In en, this message translates to:
/// **'Terms of Service'**
String get authTerms;
/// No description provided for @authLegalJoin.
///
/// In en, this message translates to:
/// **'and'**
String get authLegalJoin;
/// No description provided for @authPrivacy.
///
/// In en, this message translates to:
/// **'Privacy Policy'**
String get authPrivacy;
/// No description provided for @legalTerms.
///
/// In en, this message translates to:
/// **'Terms of Service'**
String get legalTerms;
/// No description provided for @legalPrivacy.
///
/// In en, this message translates to:
/// **'Privacy Policy'**
String get legalPrivacy;
/// No description provided for @legalOpenFailed.
///
/// In en, this message translates to:
/// **'Can\'t open this page right now'**
String get legalOpenFailed;
/// No description provided for @authContinueGoogle. /// No description provided for @authContinueGoogle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1852,6 +1900,102 @@ abstract class AppLocalizations {
/// **'Network error, please try again'** /// **'Network error, please try again'**
String get authNetworkError; String get authNetworkError;
/// No description provided for @authServerError.
///
/// In en, this message translates to:
/// **'The service is busy. Please try again shortly.'**
String get authServerError;
/// No description provided for @apiErrorBadRequest.
///
/// In en, this message translates to:
/// **'Invalid request'**
String get apiErrorBadRequest;
/// No description provided for @apiErrorUnauthorized.
///
/// In en, this message translates to:
/// **'Sign in to continue'**
String get apiErrorUnauthorized;
/// No description provided for @apiErrorForbidden.
///
/// In en, this message translates to:
/// **'You don\'t have permission'**
String get apiErrorForbidden;
/// No description provided for @apiErrorNotFound.
///
/// In en, this message translates to:
/// **'Not found'**
String get apiErrorNotFound;
/// No description provided for @apiErrorTooManyRequests.
///
/// In en, this message translates to:
/// **'Too many requests. Please try again later.'**
String get apiErrorTooManyRequests;
/// No description provided for @apiErrorLoginFailed.
///
/// In en, this message translates to:
/// **'Incorrect account or password'**
String get apiErrorLoginFailed;
/// No description provided for @apiErrorAccountDisabled.
///
/// In en, this message translates to:
/// **'This account has been disabled'**
String get apiErrorAccountDisabled;
/// No description provided for @apiErrorCoinNotEnough.
///
/// In en, this message translates to:
/// **'Not enough coins'**
String get apiErrorCoinNotEnough;
/// No description provided for @apiErrorEpisodeUnavailable.
///
/// In en, this message translates to:
/// **'This episode is unavailable'**
String get apiErrorEpisodeUnavailable;
/// No description provided for @apiErrorEpisodeUnlocked.
///
/// In en, this message translates to:
/// **'This episode is already unlocked'**
String get apiErrorEpisodeUnlocked;
/// No description provided for @apiErrorOrderInvalid.
///
/// In en, this message translates to:
/// **'This order is already paid or closed'**
String get apiErrorOrderInvalid;
/// No description provided for @apiErrorVipExpired.
///
/// In en, this message translates to:
/// **'Membership has expired'**
String get apiErrorVipExpired;
/// No description provided for @apiErrorDataExists.
///
/// In en, this message translates to:
/// **'This record already exists'**
String get apiErrorDataExists;
/// No description provided for @apiErrorDataNotFound.
///
/// In en, this message translates to:
/// **'Not found'**
String get apiErrorDataNotFound;
/// No description provided for @apiErrorNotAllowed.
///
/// In en, this message translates to:
/// **'This action isn\'t available right now'**
String get apiErrorNotAllowed;
/// No description provided for @authSwitchToSignUp. /// No description provided for @authSwitchToSignUp.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2431,7 +2575,7 @@ abstract class AppLocalizations {
/// No description provided for @accountDeactivateReasonHint. /// No description provided for @accountDeactivateReasonHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Tell us why you are leaving'** /// **'Add more detail here'**
String get accountDeactivateReasonHint; String get accountDeactivateReasonHint;
/// No description provided for @accountRequestDeactivate. /// No description provided for @accountRequestDeactivate.
@@ -2818,6 +2962,12 @@ abstract class AppLocalizations {
/// **'Quality'** /// **'Quality'**
String get playbackQuality; String get playbackQuality;
/// No description provided for @qualitySwitched.
///
/// In en, this message translates to:
/// **'Switched to {quality}'**
String qualitySwitched(String quality);
/// No description provided for @qualityAuto. /// No description provided for @qualityAuto.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3052,17 +3202,11 @@ abstract class AppLocalizations {
/// **'Anime'** /// **'Anime'**
String get homeBadgeAnime; String get homeBadgeAnime;
/// No description provided for @homeGridFilterEmpty. /// No description provided for @emptyContent.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'No dramas match these filters'** /// **'No content yet'**
String get homeGridFilterEmpty; String get emptyContent;
/// No description provided for @homeGridClearFilter.
///
/// In en, this message translates to:
/// **'Clear filters'**
String get homeGridClearFilter;
/// No description provided for @recommendTopTab. /// No description provided for @recommendTopTab.
/// ///
@@ -3172,6 +3316,30 @@ abstract class AppLocalizations {
/// **'Done'** /// **'Done'**
String get done; String get done;
/// No description provided for @selectAll.
///
/// In en, this message translates to:
/// **'Select all'**
String get selectAll;
/// No description provided for @deselectAll.
///
/// In en, this message translates to:
/// **'Deselect all'**
String get deselectAll;
/// No description provided for @cancelFollowing.
///
/// In en, this message translates to:
/// **'Unfollow'**
String get cancelFollowing;
/// No description provided for @confirmCancelFollowing.
///
/// In en, this message translates to:
/// **'Unfollow the selected dramas?'**
String get confirmCancelFollowing;
/// No description provided for @visitor. /// No description provided for @visitor.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3241,7 +3409,7 @@ abstract class AppLocalizations {
/// No description provided for @confirmRemove. /// No description provided for @confirmRemove.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Remove this item?'** /// **'Remove this watch history?'**
String get confirmRemove; String get confirmRemove;
/// No description provided for @followingCountLabel. /// No description provided for @followingCountLabel.
+94 -6
View File
@@ -57,6 +57,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get searchDiscover => 'Search dramas, actors, genres...'; String get searchDiscover => 'Search dramas, actors, genres...';
@override
String get searchCancel => 'Cancel';
@override @override
String get featured => 'Featured'; String get featured => 'Featured';
@@ -77,6 +80,27 @@ class AppLocalizationsEn extends AppLocalizations {
String get authLegal => String get authLegal =>
'By continuing, you agree to the Terms of Service and Privacy Policy.'; 'By continuing, you agree to the Terms of Service and Privacy Policy.';
@override
String get authLegalLead => 'By continuing, you agree to the';
@override
String get authTerms => 'Terms of Service';
@override
String get authLegalJoin => 'and';
@override
String get authPrivacy => 'Privacy Policy';
@override
String get legalTerms => 'Terms of Service';
@override
String get legalPrivacy => 'Privacy Policy';
@override
String get legalOpenFailed => 'Can\'t open this page right now';
@override @override
String get authContinueGoogle => 'Continue with Google'; String get authContinueGoogle => 'Continue with Google';
@@ -940,6 +964,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get authNetworkError => 'Network error, please try again'; String get authNetworkError => 'Network error, please try again';
@override
String get authServerError =>
'The service is busy. Please try again shortly.';
@override
String get apiErrorBadRequest => 'Invalid request';
@override
String get apiErrorUnauthorized => 'Sign in to continue';
@override
String get apiErrorForbidden => 'You don\'t have permission';
@override
String get apiErrorNotFound => 'Not found';
@override
String get apiErrorTooManyRequests =>
'Too many requests. Please try again later.';
@override
String get apiErrorLoginFailed => 'Incorrect account or password';
@override
String get apiErrorAccountDisabled => 'This account has been disabled';
@override
String get apiErrorCoinNotEnough => 'Not enough coins';
@override
String get apiErrorEpisodeUnavailable => 'This episode is unavailable';
@override
String get apiErrorEpisodeUnlocked => 'This episode is already unlocked';
@override
String get apiErrorOrderInvalid => 'This order is already paid or closed';
@override
String get apiErrorVipExpired => 'Membership has expired';
@override
String get apiErrorDataExists => 'This record already exists';
@override
String get apiErrorDataNotFound => 'Not found';
@override
String get apiErrorNotAllowed => 'This action isn\'t available right now';
@override @override
String get authSwitchToSignUp => 'New here? Create an account'; String get authSwitchToSignUp => 'New here? Create an account';
@@ -1259,7 +1333,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get accountDeactivateReason => 'Reason (optional)'; String get accountDeactivateReason => 'Reason (optional)';
@override @override
String get accountDeactivateReasonHint => 'Tell us why you are leaving'; String get accountDeactivateReasonHint => 'Add more detail here';
@override @override
String get accountRequestDeactivate => 'Request account deletion'; String get accountRequestDeactivate => 'Request account deletion';
@@ -1469,6 +1543,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get playbackQuality => 'Quality'; String get playbackQuality => 'Quality';
@override
String qualitySwitched(String quality) {
return 'Switched to $quality';
}
@override @override
String get qualityAuto => 'Auto'; String get qualityAuto => 'Auto';
@@ -1594,10 +1673,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeBadgeAnime => 'Anime'; String get homeBadgeAnime => 'Anime';
@override @override
String get homeGridFilterEmpty => 'No dramas match these filters'; String get emptyContent => 'No content yet';
@override
String get homeGridClearFilter => 'Clear filters';
@override @override
String get recommendTopTab => 'Recommend'; String get recommendTopTab => 'Recommend';
@@ -1653,6 +1729,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get done => 'Done'; String get done => 'Done';
@override
String get selectAll => 'Select all';
@override
String get deselectAll => 'Deselect all';
@override
String get cancelFollowing => 'Unfollow';
@override
String get confirmCancelFollowing => 'Unfollow the selected dramas?';
@override @override
String get visitor => 'Visitor'; String get visitor => 'Visitor';
@@ -1691,7 +1779,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeWatchAll => 'Watch all'; String get homeWatchAll => 'Watch all';
@override @override
String get confirmRemove => 'Remove this item?'; String get confirmRemove => 'Remove this watch history?';
@override @override
String get followingCountLabel => 'Following'; String get followingCountLabel => 'Following';
+94 -7
View File
@@ -57,6 +57,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get searchDiscover => 'Cari drama, aktor, genre...'; String get searchDiscover => 'Cari drama, aktor, genre...';
@override
String get searchCancel => 'Batal';
@override @override
String get featured => 'Unggulan'; String get featured => 'Unggulan';
@@ -77,6 +80,27 @@ class AppLocalizationsId extends AppLocalizations {
String get authLegal => String get authLegal =>
'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.';
@override
String get authLegalLead => 'Dengan melanjutkan, Anda menyetujui';
@override
String get authTerms => 'Ketentuan Layanan';
@override
String get authLegalJoin => 'dan';
@override
String get authPrivacy => 'Kebijakan Privasi';
@override
String get legalTerms => 'Ketentuan Layanan';
@override
String get legalPrivacy => 'Kebijakan Privasi';
@override
String get legalOpenFailed => 'Halaman ini belum bisa dibuka';
@override @override
String get authContinueGoogle => 'Lanjutkan dengan Google'; String get authContinueGoogle => 'Lanjutkan dengan Google';
@@ -944,6 +968,55 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get authNetworkError => 'Jaringan bermasalah, coba lagi'; String get authNetworkError => 'Jaringan bermasalah, coba lagi';
@override
String get authServerError => 'Layanan sedang sibuk. Coba lagi sebentar.';
@override
String get apiErrorBadRequest => 'Permintaan tidak valid';
@override
String get apiErrorUnauthorized => 'Masuk untuk melanjutkan';
@override
String get apiErrorForbidden => 'Anda tidak memiliki izin';
@override
String get apiErrorNotFound => 'Tidak ditemukan';
@override
String get apiErrorTooManyRequests =>
'Terlalu banyak permintaan. Coba lagi nanti.';
@override
String get apiErrorLoginFailed => 'Akun atau kata sandi salah';
@override
String get apiErrorAccountDisabled => 'Akun ini telah dinonaktifkan';
@override
String get apiErrorCoinNotEnough => 'Koin tidak cukup';
@override
String get apiErrorEpisodeUnavailable => 'Episode ini tidak tersedia';
@override
String get apiErrorEpisodeUnlocked => 'Episode ini sudah terbuka';
@override
String get apiErrorOrderInvalid => 'Pesanan ini sudah dibayar atau ditutup';
@override
String get apiErrorVipExpired => 'Keanggotaan telah berakhir';
@override
String get apiErrorDataExists => 'Data sudah ada';
@override
String get apiErrorDataNotFound => 'Data tidak ditemukan';
@override
String get apiErrorNotAllowed => 'Tindakan ini tidak tersedia saat ini';
@override @override
String get authSwitchToSignUp => 'Baru di sini? Buat akun'; String get authSwitchToSignUp => 'Baru di sini? Buat akun';
@@ -1259,7 +1332,7 @@ class AppLocalizationsId extends AppLocalizations {
String get accountDeactivateReason => 'Alasan (opsional)'; String get accountDeactivateReason => 'Alasan (opsional)';
@override @override
String get accountDeactivateReasonHint => 'Beri tahu alasan kamu'; String get accountDeactivateReasonHint => 'Tambahkan penjelasan di sini';
@override @override
String get accountRequestDeactivate => 'Ajukan penghapusan akun'; String get accountRequestDeactivate => 'Ajukan penghapusan akun';
@@ -1469,6 +1542,11 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get playbackQuality => 'Kualitas'; String get playbackQuality => 'Kualitas';
@override
String qualitySwitched(String quality) {
return 'Beralih ke $quality';
}
@override @override
String get qualityAuto => 'Otomatis'; String get qualityAuto => 'Otomatis';
@@ -1595,10 +1673,7 @@ class AppLocalizationsId extends AppLocalizations {
String get homeBadgeAnime => 'Anime'; String get homeBadgeAnime => 'Anime';
@override @override
String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter'; String get emptyContent => 'Belum ada konten';
@override
String get homeGridClearFilter => 'Hapus filter';
@override @override
String get recommendTopTab => 'Rekomendasi'; String get recommendTopTab => 'Rekomendasi';
@@ -1607,7 +1682,7 @@ class AppLocalizationsId extends AppLocalizations {
String get playlistTopTab => 'Daftar Putar'; String get playlistTopTab => 'Daftar Putar';
@override @override
String get allDramasTopTab => 'Semua Drama'; String get allDramasTopTab => 'Semua';
@override @override
String get weeklyHitsTitle => 'Hits Minggu Ini'; String get weeklyHitsTitle => 'Hits Minggu Ini';
@@ -1656,6 +1731,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get done => 'Selesai'; String get done => 'Selesai';
@override
String get selectAll => 'Pilih semua';
@override
String get deselectAll => 'Batal pilih';
@override
String get cancelFollowing => 'Berhenti mengikuti';
@override
String get confirmCancelFollowing => 'Berhenti mengikuti drama yang dipilih?';
@override @override
String get visitor => 'Pengunjung'; String get visitor => 'Pengunjung';
@@ -1694,7 +1781,7 @@ class AppLocalizationsId extends AppLocalizations {
String get homeWatchAll => 'Tonton semua'; String get homeWatchAll => 'Tonton semua';
@override @override
String get confirmRemove => 'Hapus item ini?'; String get confirmRemove => 'Hapus riwayat tontonan ini?';
@override @override
String get followingCountLabel => 'Mengikuti'; String get followingCountLabel => 'Mengikuti';
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more