Skip to content

Commit feed23a

Browse files
committed
merge: 主银 fork V1-V10 + fengs2021 PR zhoufuweigg#2 修复红果 CENC
合并 fengs2021/fix/android-cenc-playback (PR zhoufuweigg#2) 进主银 fork。 PR zhoufuweigg#2 真凶(vs V1-V10): - 0.2.64 把 Android 从 media_kit/libmpv 切成 LunaExoPlayer(package:video_player) - 红果内容是 CENC (AES-128-CTR) 加密,密钥 spade_a → plan.decryptionKey - ExoPlayer 不提供任何 DRM/解密接口 → 加密数据当明文喂 MediaCodec - 短视频蒙混过关,长视频 Source error(之前以为的 bytevc codec 问题是表象) - 切回 libmpv + decryption_key= 参数走原生 mpv 解密链路,AVD 实测 playable=true PR zhoufuweigg#2 顺手修 Go 代理 4 缺陷: 1. Range 兑现:CDN 忽略 Range 时代理自己跳前缀 + 限长度 + 补 206/Content-Range (TestNativeUpstreamIgnoringRangeStillHonoursOffsets) 2. 会话误回收:served/inflight 计数 + served(token)/usedAt(token) (TestNativeIdlePlaybackSessionSurvivesOtherSessions) 3. HLS 启发式误判:去掉 url.contains('hls'),改路径后缀 .m3u8/.m3u 4. Range 请求强制 Accept-Encoding: identity + 响应头立即 Flush(Media3 8s 连接超时兜底) PR zhoufuweigg#2 顺手修诊断能力: - lib/main.dart 启动日志改读 appVersion+duanjuBuildNumber 常量(不再硬编码 0.2.64+71) → 解决此前版本号误判问题 - 播放日记记密钥位数/线路/本地标志 - 64 条环形 probe 缓冲 + playbackProbe action 桥暴露 冲突解决: - pubspec.yaml: 选 PR zhoufuweigg#2 的 0.2.73+80(更新版本号 = 真 bug 修复后版本) - native/core/app_stream.go: 合并字段(主银 V3 加 seriesID/videoID + PR zhoufuweigg#2 加 inflight/served) + 保留 PR zhoufuweigg#2 的 5 个配套方法 (servedCount/inFlight/served/usedAt) - native/core/network_media.go: git 自动合并成功(两边都是单行 Set Accept-Encoding: identity) build trigger: workflow_dispatch 一次(push 触发那个会被 concurrency 自动 cancel) version: 0.2.73+80
1 parent b4a7a56 commit feed23a

20 files changed

Lines changed: 944 additions & 49 deletions

‎android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,18 +229,47 @@ class MainActivity : FlutterActivity() {
229229
}
230230
}
231231

232+
@Volatile
233+
private var pictureInPictureVerified = false
234+
232235
private fun pictureInPictureSupported(): Boolean {
233-
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
234-
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
236+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
237+
if (pictureInPictureVerified || isInPictureInPictureMode) return true
238+
// 部分鸿蒙 / OEM 系统不声明 FEATURE_PICTURE_IN_PICTURE 但实际支持,
239+
// 因此以真实进入结果为准,特性声明只作为初始提示。
240+
return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
235241
}
236242

237243
private fun pictureInPictureStatus(): Map<String, Any> {
238244
return mapOf(
239245
"supported" to pictureInPictureSupported(),
246+
"granted" to pictureInPicturePermissionGranted(),
240247
"active" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode)
241248
)
242249
}
243250

251+
private fun pictureInPicturePermissionGranted(): Boolean {
252+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
253+
return runCatching {
254+
val manager = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
255+
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
256+
manager.unsafeCheckOpNoThrow(
257+
android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
258+
android.os.Process.myUid(),
259+
packageName
260+
)
261+
} else {
262+
@Suppress("DEPRECATION")
263+
manager.checkOpNoThrow(
264+
android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
265+
android.os.Process.myUid(),
266+
packageName
267+
)
268+
}
269+
mode != android.app.AppOpsManager.MODE_IGNORED && mode != android.app.AppOpsManager.MODE_ERRORED
270+
}.getOrDefault(true)
271+
}
272+
244273
private fun enterPlayerPictureInPicture(
245274
width: Int,
246275
height: Int,
@@ -249,7 +278,7 @@ class MainActivity : FlutterActivity() {
249278
right: Int?,
250279
bottom: Int?
251280
): Map<String, Any> {
252-
if (!pictureInPictureSupported()) return pictureInPictureStatus()
281+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return pictureInPictureStatus()
253282
val safeWidth = width.coerceIn(1, 10000)
254283
val safeHeight = height.coerceIn(1, 10000)
255284
return runCatching {
@@ -263,6 +292,7 @@ class MainActivity : FlutterActivity() {
263292
builder.setAutoEnterEnabled(true)
264293
}
265294
val entered = enterPictureInPictureMode(builder.build())
295+
if (entered) pictureInPictureVerified = true
266296
pictureInPictureStatus() + ("requested" to entered)
267297
}.getOrElse { pictureInPictureStatus() }
268298
}
@@ -272,6 +302,7 @@ class MainActivity : FlutterActivity() {
272302
newConfig: Configuration
273303
) {
274304
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
305+
if (isInPictureInPictureMode) pictureInPictureVerified = true
275306
deviceChannel?.invokeMethod(
276307
"pictureInPictureChanged",
277308
mapOf("active" to isInPictureInPictureMode)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import 'package:duanju_app/core_bridge.dart';
2+
import 'package:duanju_app/models.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:integration_test/integration_test.dart';
5+
import 'package:media_kit/media_kit.dart';
6+
7+
const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL');
8+
9+
/// Resolves each synthetic fixture through the native core, then plays the
10+
/// resulting plan through media_kit exactly as the app does, including the
11+
/// decryption key for the CENC fixture.
12+
void main() {
13+
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
14+
15+
testWidgets('resolve and play every fixture through media_kit', (tester) async {
16+
MediaKit.ensureInitialized();
17+
final repository = NativeRepository();
18+
await repository.initialize();
19+
20+
const drama = Drama(
21+
id: 'hongguo:700001',
22+
source: 'hongguo',
23+
title: '设备播放验证',
24+
episodes: 3,
25+
);
26+
final fixtures = {
27+
1: ('clear.mp4', ''),
28+
2: ('index.m3u8', ''),
29+
3: ('encrypted.mp4', '00112233445566778899aabbccddeeff'),
30+
};
31+
32+
final report = <Map<String, Object?>>[];
33+
for (final entry in fixtures.entries) {
34+
final number = entry.key;
35+
final (file, key) = entry.value;
36+
final episode = Episode({
37+
'id': '$number',
38+
'source': 'hongguo',
39+
'currentEpisode': number,
40+
'title': '第$number集',
41+
'videoUrl': '$fixtureBase/$file',
42+
'referer': '$fixtureBase/',
43+
}, number);
44+
45+
String? resolveError;
46+
PlaybackPlan? plan;
47+
try {
48+
plan = await repository.resolve(drama, episode);
49+
} catch (error) {
50+
resolveError = error.toString();
51+
}
52+
if (plan == null) {
53+
// ignore: avoid_print
54+
print('[PlanProbe] ep$number ($file) resolve FAILED: $resolveError');
55+
report.add({
56+
'episode': number,
57+
'file': file,
58+
'resolveError': resolveError,
59+
'playable': false,
60+
});
61+
continue;
62+
}
63+
64+
// ignore: avoid_print
65+
print('[PlanProbe] ep$number ($file) resolved url=${plan.url} '
66+
'local=${plan.local} keyFromPlan=${plan.decryptionKey.length}');
67+
68+
final effectiveKey = key.isNotEmpty ? key : plan.decryptionKey;
69+
final player = Player(
70+
configuration: const PlayerConfiguration(bufferSize: 8 * 1024 * 1024),
71+
);
72+
String? playError;
73+
try {
74+
final platform = player.platform;
75+
if (platform is NativePlayer) {
76+
await platform.setProperty(
77+
'demuxer-lavf-o',
78+
[
79+
'seg_max_retry=3',
80+
'strict=experimental',
81+
'allowed_extensions=ALL',
82+
'protocol_whitelist=[http,https,tcp,tls,crypto,data,file]',
83+
if (effectiveKey.isNotEmpty) 'decryption_key=$effectiveKey',
84+
].join(','),
85+
);
86+
}
87+
await player.open(
88+
Media(plan.url, httpHeaders: plan.headers),
89+
play: true,
90+
);
91+
final deadline = DateTime.now().add(const Duration(seconds: 30));
92+
while (DateTime.now().isBefore(deadline)) {
93+
if (player.state.position.inMilliseconds > 300) break;
94+
await Future<void>.delayed(const Duration(milliseconds: 250));
95+
}
96+
} catch (error) {
97+
playError = error.toString();
98+
}
99+
// Headless runs have no video surface, so width stays null; real progress
100+
// and a known duration are the reliable signals that decoding worked.
101+
final playable =
102+
playError == null &&
103+
player.state.duration.inMilliseconds > 0 &&
104+
player.state.position.inMilliseconds > 300;
105+
// ignore: avoid_print
106+
print('[PlanProbe] ep$number playable=$playable err=$playError '
107+
'width=${player.state.width} dur=${player.state.duration} '
108+
'pos=${player.state.position}');
109+
report.add({
110+
'episode': number,
111+
'file': file,
112+
'playable': playable,
113+
'playError': playError,
114+
'width': player.state.width,
115+
'durationMs': player.state.duration.inMilliseconds,
116+
'positionMs': player.state.position.inMilliseconds,
117+
});
118+
await player.dispose();
119+
await repository.release(plan.session);
120+
}
121+
122+
binding.reportData ??= {};
123+
binding.reportData!['planProbe'] = report;
124+
final allPlayable = report.every((row) => row['playable'] == true);
125+
expect(allPlayable, isTrue,
126+
reason: 'every fixture must play through media_kit: $report');
127+
}, timeout: const Timeout(Duration(minutes: 5)));
128+
}

‎integration_test/playback_test.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ class DeviceFixtureRepository extends AppRepository {
9292
@override
9393
Future<void> cancelPlayback() => native.cancelPlayback();
9494
@override
95+
Future<List<String>> playbackProbe() => native.playbackProbe();
96+
@override
9597
Future<void> release(String session) async {
9698
if (session.isNotEmpty) released.add(session);
9799
await native.release(session);

‎integration_test/ui_cenc_test.dart‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import 'package:duanju_app/core_bridge.dart';
2+
import 'package:duanju_app/local_store.dart';
3+
import 'package:duanju_app/models.dart';
4+
import 'package:duanju_app/player_screen.dart';
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:integration_test/integration_test.dart';
8+
import 'package:media_kit/media_kit.dart';
9+
import 'package:media_kit_video/media_kit_video.dart';
10+
import 'package:shared_preferences/shared_preferences.dart';
11+
12+
const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL');
13+
14+
/// Mounts the real PlayerScreen for the CENC fixture and checks that the app
15+
/// renders a video surface and advances, which is what the user sees.
16+
void main() {
17+
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
18+
19+
testWidgets('player screen renders and plays the CENC episode', (tester) async {
20+
MediaKit.ensureInitialized();
21+
final observed = Player(
22+
configuration: const PlayerConfiguration(
23+
bufferSize: 32 * 1024 * 1024,
24+
logLevel: MPVLogLevel.error,
25+
),
26+
);
27+
final repository = _CencRepository();
28+
await repository.initialize();
29+
final store = LocalStore(await SharedPreferences.getInstance());
30+
final detail = await repository.detail(_CencRepository.drama);
31+
32+
await tester.pumpWidget(
33+
MaterialApp(
34+
home: PlayerScreen(
35+
detail: detail,
36+
initialIndex: 0,
37+
repository: repository,
38+
store: store,
39+
playerFactory: () => observed,
40+
),
41+
),
42+
);
43+
44+
Future<void> until(bool Function() ready, String step) async {
45+
final timer = Stopwatch()..start();
46+
while (!ready()) {
47+
if (timer.elapsed > const Duration(seconds: 45)) {
48+
fail('Timed out: $step');
49+
}
50+
await tester.pump(const Duration(milliseconds: 200));
51+
}
52+
}
53+
54+
await until(() => find.byType(Video).evaluate().isNotEmpty, 'video surface');
55+
await until(
56+
() =>
57+
observed.state.position.inMilliseconds > 500 &&
58+
observed.state.duration.inSeconds >= 18,
59+
'cenc playback advanced',
60+
);
61+
expect(find.text('暂时无法播放'), findsNothing);
62+
63+
binding.reportData ??= {};
64+
binding.reportData!['uiCenc'] = {
65+
'positionMs': observed.state.position.inMilliseconds,
66+
'durationMs': observed.state.duration.inMilliseconds,
67+
};
68+
// ignore: avoid_print
69+
print('[UiCenc] surface=true pos=${observed.state.position} '
70+
'dur=${observed.state.duration}');
71+
}, timeout: const Timeout(Duration(minutes: 4)));
72+
}
73+
74+
class _CencRepository extends NativeRepository {
75+
static final drama = Drama(
76+
id: 'hongguo:900001',
77+
source: 'hongguo',
78+
title: 'CENC 界面验证',
79+
episodes: 1,
80+
);
81+
82+
@override
83+
Future<DramaDetail> detail(Drama drama) async => DramaDetail(drama, [
84+
Episode({
85+
'id': '1',
86+
'source': 'hongguo',
87+
'currentEpisode': 1,
88+
'title': '第1集',
89+
'videoUrl': '$fixtureBase/encrypted.mp4',
90+
'referer': '$fixtureBase/',
91+
}, 1),
92+
]);
93+
94+
@override
95+
Future<PlaybackPlan> resolve(
96+
Drama drama,
97+
Episode episode, {
98+
int quality = 0,
99+
}) async {
100+
final plan = await super.resolve(drama, episode, quality: quality);
101+
return PlaybackPlan(
102+
url: plan.url,
103+
local: plan.local,
104+
headers: plan.headers,
105+
decryptionKey: '00112233445566778899aabbccddeeff',
106+
session: plan.session,
107+
routeIndex: plan.routeIndex,
108+
routeCount: plan.routeCount,
109+
);
110+
}
111+
}

‎lib/app_build.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
const allSourcesEnabled = bool.fromEnvironment('ALL_SOURCES');
22
const appName = allSourcesEnabled ? '真果鉴' : '红果鉴';
33
const appSlug = allSourcesEnabled ? 'zhenguojian' : 'hongguojian';
4+
const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 80);
5+
const appPackageId = 'com.duanju.duanju_app';

‎lib/app_layout.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import 'package:flutter/services.dart';
66

77
export 'app_build.dart';
88

9-
const appVersion = '0.2.64';
9+
const appVersion = '0.2.73';
1010

1111
ThemeData televisionTheme(ThemeData theme) {
1212
final colors = theme.colorScheme;

‎lib/core_bridge.dart‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ abstract class AppRepository {
184184
Future<PlaybackPlan> fallback(PlaybackPlan current);
185185
Future<void> cancelPlayback();
186186
Future<void> release(String session);
187+
Future<List<String>> playbackProbe();
187188
}
188189

189190
class NativeRepository extends AppRepository {
@@ -742,6 +743,18 @@ class NativeRepository extends AppRepository {
742743
await _call({'action': 'cancelPlayback', 'sequence': ++_playbackSequence});
743744
}
744745

746+
/// 读取原生代理最近记录的请求与响应,用于播放失败后定位原因。
747+
Future<List<String>> playbackProbe() async {
748+
try {
749+
final result = await _call({'action': 'playbackProbe'});
750+
return (result['entries'] as List? ?? [])
751+
.map((entry) => entry.toString())
752+
.toList();
753+
} catch (_) {
754+
return const [];
755+
}
756+
}
757+
745758
@override
746759
bool get supportsDownloads => access?.canDownload ?? true;
747760

‎lib/local_media_screen.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,4 +445,6 @@ class _LocalFileRepository extends AppRepository {
445445
Future<void> cancelPlayback() async {}
446446
@override
447447
Future<void> release(String session) async {}
448+
@override
449+
Future<List<String>> playbackProbe() async => const [];
448450
}

0 commit comments

Comments
 (0)