diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
index 8f25c38..b423dd4 100644
--- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
@@ -4,6 +4,7 @@ package com.stormg.lumadrama
import android.content.Intent
import android.app.PictureInPictureParams
+import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
@@ -12,6 +13,8 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.provider.Settings
+import android.telephony.TelephonyManager
import android.util.Rational
import android.content.pm.PackageManager
import android.view.WindowManager
@@ -21,6 +24,7 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
+import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@@ -65,6 +69,23 @@ class MainActivity : FlutterActivity() {
result.success(false)
}
}
+ // 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device")
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "ssaid" -> {
+ val value = try {
+ Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
+ } catch (_: Exception) {
+ null
+ }
+ result.success(value)
+ }
+ // 设备区域:注册时作为服务端 IP 解析失败后的兜底
+ "region" -> result.success(currentRegion())
+ else -> result.notImplemented()
+ }
+ }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share")
.setMethodCallHandler { call, result ->
if (call.method != "shareText") {
@@ -156,6 +177,28 @@ class MainActivity : FlutterActivity() {
?.bottom
?: 0
+ /** 网络运营商国家优先,其次 SIM 卡,最后系统语言;都拿不到返回空串。 */
+ private fun currentRegion(): String {
+ return try {
+ val telephony = getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
+ val localeCountry = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ resources.configuration.locales.get(0).country
+ } else {
+ @Suppress("DEPRECATION")
+ resources.configuration.locale.country
+ }
+ val candidates = listOfNotNull(
+ telephony?.networkCountryIso,
+ telephony?.simCountryIso,
+ localeCountry
+ )
+ candidates.firstOrNull { it.isNotBlank() }?.uppercase(Locale.ROOT) ?: ""
+ } catch (_: Exception) {
+ // 个别 ROM 读运营商信息会抛 SecurityException,别让注册流程卡住
+ ""
+ }
+ }
+
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index b33feff..6b22270 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -63,6 +63,7 @@ final class RunnerFlutterViewController: FlutterViewController {
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerWirelessDataChannel(engineBridge)
+ registerDeviceIdChannel(engineBridge)
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/share",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
@@ -144,6 +145,29 @@ final class RunnerFlutterViewController: FlutterViewController {
}
}
+ /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
+ /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
+ private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
+ let channel = FlutterMethodChannel(
+ name: "com.stormg.lumadrama/device",
+ binaryMessenger: engineBridge.applicationRegistrar.messenger()
+ )
+ channel.setMethodCallHandler { call, result in
+ switch call.method {
+ case "idfv":
+ result(UIDevice.current.identifierForVendor?.uuidString)
+ case "region":
+ if #available(iOS 16.0, *) {
+ result(Locale.current.region?.identifier)
+ } else {
+ result(Locale.current.regionCode)
+ }
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+
private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: WirelessDataPrompt.channelName,
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 80b4716..1e27100 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -48,7 +48,7 @@
NSIncludesSubdomains
- 10.43.47.1
+ 192.168.31.243
NSExceptionAllowsInsecureHTTPLoads
diff --git a/lib/app.dart b/lib/app.dart
index 53133db..5fd2cf8 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -14,6 +14,7 @@ import 'core/data/remote/catalog_api.dart';
import 'core/data/remote/category_api.dart';
import 'core/data/remote/comment_api.dart';
import 'core/data/remote/commerce_api.dart';
+import 'core/data/remote/device_profile.dart';
import 'core/data/remote/download_api.dart';
import 'core/data/remote/interaction_api.dart';
import 'core/data/remote/library_api.dart';
@@ -117,12 +118,13 @@ class _LumaDramaAppState extends State {
late final AuthSessionStore _sessionStore = AuthSessionStore();
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
- /// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
+ /// 都自动带 `Authorization`、`X-Device-Id` 与平台头,401 的刷新重试也只有一处实现。
late final ApiClient _apiClient = ApiClient(
accessTokenReader: () => _auth.accessToken,
tokenRefresher: () => _auth.refresh(),
onUnauthorized: () => _auth.clear(),
deviceIdReader: _sessionStore.deviceId,
+ platformReader: clientPlatform,
);
late final AuthController _auth = AuthController(
@@ -141,17 +143,11 @@ class _LumaDramaAppState extends State {
late final WatchHistoryRepository _watchHistory =
widget.watchHistoryRepository ??
- HttpWatchHistoryRepository(
- WatchApi(_apiClient),
- isSignedIn: () => _auth.isSignedIn,
- );
+ HttpWatchHistoryRepository(WatchApi(_apiClient));
late final FavoriteRepository _favorites =
widget.favoriteRepository ??
- HttpFavoriteRepository(
- LibraryApi(_apiClient),
- isSignedIn: () => _auth.isSignedIn,
- );
+ HttpFavoriteRepository(LibraryApi(_apiClient));
late final PlaybackRepository _playback =
widget.playbackRepository ??
@@ -219,7 +215,7 @@ class _LumaDramaAppState extends State {
unawaited(_localeController.load());
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged);
- if (_auth.isSignedIn) unawaited(_favoriteController.load());
+ unawaited(_favoriteController.load());
if (_auth.isSignedIn) unawaited(_walletController.load());
unawaited(_downloadController.restore());
}
@@ -235,6 +231,7 @@ class _LumaDramaAppState extends State {
unawaited(_walletController.load());
} else {
_favoriteController.clear();
+ unawaited(_favoriteController.load());
_downloadController.reset();
_walletController.reset();
}
@@ -293,9 +290,7 @@ class _LumaDramaAppState extends State {
),
ChangeNotifierProvider(create: (_) => _createCategories()),
if (playlists != null)
- ChangeNotifierProvider(
- create: (_) => PlaylistController(playlists),
- ),
+ ChangeNotifierProvider(create: (_) => PlaylistController(playlists)),
ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _walletController),
diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart
index d9ed463..391fcaa 100644
--- a/lib/core/config/app_config.dart
+++ b/lib/core/config/app_config.dart
@@ -14,7 +14,7 @@ class AppConfig {
/// 开发调试默认服务地址,当前指向本机局域网 IP。
///
/// 换网络时只改这一行,地址不要带结尾斜杠。
- static const String devHostBaseUrl = 'http://10.43.47.1:8081';
+ static const String devHostBaseUrl = 'http://192.168.31.243:8081';
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart
index 0ca516e..c02e6dd 100644
--- a/lib/core/data/models/drama.dart
+++ b/lib/core/data/models/drama.dart
@@ -60,6 +60,20 @@ class Episode {
posterUrl: posterUrl ?? this.posterUrl,
);
+ Episode withStats({int? likeCount, int? commentCount}) => Episode(
+ id: id,
+ number: number,
+ title: title,
+ isFree: isFree,
+ videoUrl: videoUrl,
+ durationSec: durationSec,
+ locked: locked,
+ likeCount: likeCount ?? this.likeCount,
+ commentCount: commentCount ?? this.commentCount,
+ thumbnailsUrl: thumbnailsUrl,
+ posterUrl: posterUrl,
+ );
+
factory Episode.fromJson(Map json) {
return Episode(
id: (json['id'] ?? '').toString(),
diff --git a/lib/core/data/models/episode_stats.dart b/lib/core/data/models/episode_stats.dart
new file mode 100644
index 0000000..65a29d6
--- /dev/null
+++ b/lib/core/data/models/episode_stats.dart
@@ -0,0 +1,55 @@
+// Author: fruit.com.cn
+
+import 'json_numbers.dart';
+
+/// 播放页轻量互动计数快照,不包含播放地址或票据信息。
+class EpisodeStats {
+ const EpisodeStats({
+ required this.episodeId,
+ required this.dramaId,
+ required this.likeCount,
+ required this.commentCount,
+ required this.favoriteCount,
+ required this.liked,
+ required this.favorited,
+ });
+
+ final String episodeId;
+ final String dramaId;
+ final int likeCount;
+ final int commentCount;
+ final int favoriteCount;
+ final bool liked;
+ final bool favorited;
+
+ EpisodeStats copyWith({
+ String? dramaId,
+ int? likeCount,
+ int? commentCount,
+ int? favoriteCount,
+ bool? liked,
+ bool? favorited,
+ }) {
+ return EpisodeStats(
+ episodeId: episodeId,
+ dramaId: dramaId ?? this.dramaId,
+ likeCount: likeCount ?? this.likeCount,
+ commentCount: commentCount ?? this.commentCount,
+ favoriteCount: favoriteCount ?? this.favoriteCount,
+ liked: liked ?? this.liked,
+ favorited: favorited ?? this.favorited,
+ );
+ }
+
+ factory EpisodeStats.fromJson(Map json) {
+ return EpisodeStats(
+ episodeId: jsonToId(json['episodeId']),
+ dramaId: jsonToId(json['dramaId']),
+ likeCount: jsonToInt(json['likeCount']),
+ commentCount: jsonToInt(json['commentCount']),
+ favoriteCount: jsonToInt(json['favoriteCount']),
+ liked: json['liked'] == true,
+ favorited: json['favorited'] == true,
+ );
+ }
+}
diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart
index b00700f..259ec26 100644
--- a/lib/core/data/models/interaction.dart
+++ b/lib/core/data/models/interaction.dart
@@ -58,14 +58,16 @@ class LikeToggleResult {
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
class OperationResult {
- const OperationResult._(this.data, this.errorMessage);
+ const OperationResult._(this.data, this.errorMessage, this.errorCode);
- const OperationResult.success([T? data]) : this._(data, null);
+ const OperationResult.success([T? data]) : this._(data, null, null);
- const OperationResult.failure(this.errorMessage) : data = null;
+ const OperationResult.failure(this.errorMessage, {this.errorCode})
+ : data = null;
final T? data;
final String? errorMessage;
+ final int? errorCode;
bool get isSuccess => errorMessage == null;
}
diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart
index e37543e..41f73dd 100644
--- a/lib/core/data/remote/api_client.dart
+++ b/lib/core/data/remote/api_client.dart
@@ -24,6 +24,7 @@ class ApiClient {
this.tokenRefresher,
this.onUnauthorized,
this.deviceIdReader,
+ this.platformReader,
}) : _fixedBaseUrl = baseUrl {
_dio = Dio(
BaseOptions(
@@ -40,6 +41,7 @@ class ApiClient {
AuthHeaderInterceptor(
accessTokenReader: accessTokenReader,
deviceIdReader: deviceIdReader,
+ platformReader: platformReader,
),
const EnvelopeInterceptor(),
NetworkLogInterceptor(),
@@ -67,6 +69,9 @@ class ApiClient {
/// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用
final Future Function()? deviceIdReader;
+ /// 客户端平台(`X-Client-Platform`):后台区分 iOS / Android
+ final String Function()? platformReader;
+
Future