Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,47 @@ class MainActivity : FlutterActivity() {
}
}

@Volatile
private var pictureInPictureVerified = false

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

private fun pictureInPictureStatus(): Map<String, Any> {
return mapOf(
"supported" to pictureInPictureSupported(),
"granted" to pictureInPicturePermissionGranted(),
"active" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode)
)
}

private fun pictureInPicturePermissionGranted(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
return runCatching {
val manager = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
manager.unsafeCheckOpNoThrow(
android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
android.os.Process.myUid(),
packageName
)
} else {
@Suppress("DEPRECATION")
manager.checkOpNoThrow(
android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
android.os.Process.myUid(),
packageName
)
}
mode != android.app.AppOpsManager.MODE_IGNORED && mode != android.app.AppOpsManager.MODE_ERRORED
}.getOrDefault(true)
}

private fun enterPlayerPictureInPicture(
width: Int,
height: Int,
Expand All @@ -249,7 +278,7 @@ class MainActivity : FlutterActivity() {
right: Int?,
bottom: Int?
): Map<String, Any> {
if (!pictureInPictureSupported()) return pictureInPictureStatus()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return pictureInPictureStatus()
val safeWidth = width.coerceIn(1, 10000)
val safeHeight = height.coerceIn(1, 10000)
return runCatching {
Expand All @@ -263,6 +292,7 @@ class MainActivity : FlutterActivity() {
builder.setAutoEnterEnabled(true)
}
val entered = enterPictureInPictureMode(builder.build())
if (entered) pictureInPictureVerified = true
pictureInPictureStatus() + ("requested" to entered)
}.getOrElse { pictureInPictureStatus() }
}
Expand All @@ -272,6 +302,7 @@ class MainActivity : FlutterActivity() {
newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (isInPictureInPictureMode) pictureInPictureVerified = true
deviceChannel?.invokeMethod(
"pictureInPictureChanged",
mapOf("active" to isInPictureInPictureMode)
Expand Down
128 changes: 128 additions & 0 deletions integration_test/plan_probe_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import 'package:duanju_app/core_bridge.dart';
import 'package:duanju_app/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:media_kit/media_kit.dart';

const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL');

/// Resolves each synthetic fixture through the native core, then plays the
/// resulting plan through media_kit exactly as the app does, including the
/// decryption key for the CENC fixture.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('resolve and play every fixture through media_kit', (tester) async {
MediaKit.ensureInitialized();
final repository = NativeRepository();
await repository.initialize();

const drama = Drama(
id: 'hongguo:700001',
source: 'hongguo',
title: '设备播放验证',
episodes: 3,
);
final fixtures = {
1: ('clear.mp4', ''),
2: ('index.m3u8', ''),
3: ('encrypted.mp4', '00112233445566778899aabbccddeeff'),
};

final report = <Map<String, Object?>>[];
for (final entry in fixtures.entries) {
final number = entry.key;
final (file, key) = entry.value;
final episode = Episode({
'id': '$number',
'source': 'hongguo',
'currentEpisode': number,
'title': '第$number集',
'videoUrl': '$fixtureBase/$file',
'referer': '$fixtureBase/',
}, number);

String? resolveError;
PlaybackPlan? plan;
try {
plan = await repository.resolve(drama, episode);
} catch (error) {
resolveError = error.toString();
}
if (plan == null) {
// ignore: avoid_print
print('[PlanProbe] ep$number ($file) resolve FAILED: $resolveError');
report.add({
'episode': number,
'file': file,
'resolveError': resolveError,
'playable': false,
});
continue;
}

// ignore: avoid_print
print('[PlanProbe] ep$number ($file) resolved url=${plan.url} '
'local=${plan.local} keyFromPlan=${plan.decryptionKey.length}');

final effectiveKey = key.isNotEmpty ? key : plan.decryptionKey;
final player = Player(
configuration: const PlayerConfiguration(bufferSize: 8 * 1024 * 1024),
);
String? playError;
try {
final platform = player.platform;
if (platform is NativePlayer) {
await platform.setProperty(
'demuxer-lavf-o',
[
'seg_max_retry=3',
'strict=experimental',
'allowed_extensions=ALL',
'protocol_whitelist=[http,https,tcp,tls,crypto,data,file]',
if (effectiveKey.isNotEmpty) 'decryption_key=$effectiveKey',
].join(','),
);
}
await player.open(
Media(plan.url, httpHeaders: plan.headers),
play: true,
);
final deadline = DateTime.now().add(const Duration(seconds: 30));
while (DateTime.now().isBefore(deadline)) {
if (player.state.position.inMilliseconds > 300) break;
await Future<void>.delayed(const Duration(milliseconds: 250));
}
} catch (error) {
playError = error.toString();
}
// Headless runs have no video surface, so width stays null; real progress
// and a known duration are the reliable signals that decoding worked.
final playable =
playError == null &&
player.state.duration.inMilliseconds > 0 &&
player.state.position.inMilliseconds > 300;
// ignore: avoid_print
print('[PlanProbe] ep$number playable=$playable err=$playError '
'width=${player.state.width} dur=${player.state.duration} '
'pos=${player.state.position}');
report.add({
'episode': number,
'file': file,
'playable': playable,
'playError': playError,
'width': player.state.width,
'durationMs': player.state.duration.inMilliseconds,
'positionMs': player.state.position.inMilliseconds,
});
await player.dispose();
await repository.release(plan.session);
}

binding.reportData ??= {};
binding.reportData!['planProbe'] = report;
final allPlayable = report.every((row) => row['playable'] == true);
expect(allPlayable, isTrue,
reason: 'every fixture must play through media_kit: $report');
}, timeout: const Timeout(Duration(minutes: 5)));
}
2 changes: 2 additions & 0 deletions integration_test/playback_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class DeviceFixtureRepository extends AppRepository {
@override
Future<void> cancelPlayback() => native.cancelPlayback();
@override
Future<List<String>> playbackProbe() => native.playbackProbe();
@override
Future<void> release(String session) async {
if (session.isNotEmpty) released.add(session);
await native.release(session);
Expand Down
111 changes: 111 additions & 0 deletions integration_test/ui_cenc_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:duanju_app/core_bridge.dart';
import 'package:duanju_app/local_store.dart';
import 'package:duanju_app/models.dart';
import 'package:duanju_app/player_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:shared_preferences/shared_preferences.dart';

const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL');

/// Mounts the real PlayerScreen for the CENC fixture and checks that the app
/// renders a video surface and advances, which is what the user sees.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('player screen renders and plays the CENC episode', (tester) async {
MediaKit.ensureInitialized();
final observed = Player(
configuration: const PlayerConfiguration(
bufferSize: 32 * 1024 * 1024,
logLevel: MPVLogLevel.error,
),
);
final repository = _CencRepository();
await repository.initialize();
final store = LocalStore(await SharedPreferences.getInstance());
final detail = await repository.detail(_CencRepository.drama);

await tester.pumpWidget(
MaterialApp(
home: PlayerScreen(
detail: detail,
initialIndex: 0,
repository: repository,
store: store,
playerFactory: () => observed,
),
),
);

Future<void> until(bool Function() ready, String step) async {
final timer = Stopwatch()..start();
while (!ready()) {
if (timer.elapsed > const Duration(seconds: 45)) {
fail('Timed out: $step');
}
await tester.pump(const Duration(milliseconds: 200));
}
}

await until(() => find.byType(Video).evaluate().isNotEmpty, 'video surface');
await until(
() =>
observed.state.position.inMilliseconds > 500 &&
observed.state.duration.inSeconds >= 18,
'cenc playback advanced',
);
expect(find.text('暂时无法播放'), findsNothing);

binding.reportData ??= {};
binding.reportData!['uiCenc'] = {
'positionMs': observed.state.position.inMilliseconds,
'durationMs': observed.state.duration.inMilliseconds,
};
// ignore: avoid_print
print('[UiCenc] surface=true pos=${observed.state.position} '
'dur=${observed.state.duration}');
}, timeout: const Timeout(Duration(minutes: 4)));
}

class _CencRepository extends NativeRepository {
static final drama = Drama(
id: 'hongguo:900001',
source: 'hongguo',
title: 'CENC 界面验证',
episodes: 1,
);

@override
Future<DramaDetail> detail(Drama drama) async => DramaDetail(drama, [
Episode({
'id': '1',
'source': 'hongguo',
'currentEpisode': 1,
'title': '第1集',
'videoUrl': '$fixtureBase/encrypted.mp4',
'referer': '$fixtureBase/',
}, 1),
]);

@override
Future<PlaybackPlan> resolve(
Drama drama,
Episode episode, {
int quality = 0,
}) async {
final plan = await super.resolve(drama, episode, quality: quality);
return PlaybackPlan(
url: plan.url,
local: plan.local,
headers: plan.headers,
decryptionKey: '00112233445566778899aabbccddeeff',
session: plan.session,
routeIndex: plan.routeIndex,
routeCount: plan.routeCount,
);
}
}
2 changes: 2 additions & 0 deletions lib/app_build.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const allSourcesEnabled = bool.fromEnvironment('ALL_SOURCES');
const appName = allSourcesEnabled ? '真果鉴' : '红果鉴';
const appSlug = allSourcesEnabled ? 'zhenguojian' : 'hongguojian';
const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 80);
const appPackageId = 'com.duanju.duanju_app';
2 changes: 1 addition & 1 deletion lib/app_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import 'package:flutter/services.dart';

export 'app_build.dart';

const appVersion = '0.2.64';
const appVersion = '0.2.73';

ThemeData televisionTheme(ThemeData theme) {
final colors = theme.colorScheme;
Expand Down
13 changes: 13 additions & 0 deletions lib/core_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ abstract class AppRepository {
Future<PlaybackPlan> fallback(PlaybackPlan current);
Future<void> cancelPlayback();
Future<void> release(String session);
Future<List<String>> playbackProbe();
}

class NativeRepository extends AppRepository {
Expand Down Expand Up @@ -742,6 +743,18 @@ class NativeRepository extends AppRepository {
await _call({'action': 'cancelPlayback', 'sequence': ++_playbackSequence});
}

/// 读取原生代理最近记录的请求与响应,用于播放失败后定位原因。
Future<List<String>> playbackProbe() async {
try {
final result = await _call({'action': 'playbackProbe'});
return (result['entries'] as List? ?? [])
.map((entry) => entry.toString())
.toList();
} catch (_) {
return const [];
}
}

@override
bool get supportsDownloads => access?.canDownload ?? true;

Expand Down
2 changes: 2 additions & 0 deletions lib/local_media_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -445,4 +445,6 @@ class _LocalFileRepository extends AppRepository {
Future<void> cancelPlayback() async {}
@override
Future<void> release(String session) async {}
@override
Future<List<String>> playbackProbe() async => const [];
}
Loading