From d14f36f9a26c6652d3fac46d93bef1c4751c0fea Mon Sep 17 00:00:00 2001 From: fengs2021 Date: Mon, 5 Oct 2026 22:22:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Android=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=20media=5Fkit/libmpv=20=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E7=BA=A2=E6=9E=9C=20CENC=EF=BC=8C=E4=BB=A3=E7=90=86=E5=85=91?= =?UTF-8?q?=E7=8E=B0=E5=BF=BD=E7=95=A5=20Range=20=E7=9A=84=20CDN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 红果内容以 CENC(AES-128-CTR) 加密,密钥由 spade_a 解出后经 plan.decryptionKey 传给播放器。video_player 插件不提供 DRM 接口,ExoPlayer 拿到加密数据既无法解密 也不报错,表现为 Source error 或长时间卡住;AVD 实测同一 CENC 文件 ExoPlayer 挂起 而 libmpv 正常播放。libmpv 本就随 media_kit 打包在 APK 内,Android 恢复与桌面 一致的内核路径,demuxer-lavf-o 传入 decryption_key。 本地代理同步修复四个真实缺陷(均有 Go 回归测试): - 上游 CDN 忽略 Range 返回 200 整文件时,代理自行跳过前缀、限制长度并补 206 头, 否则 seek 后播放器拿到错误偏移(TestNativeUpstreamIgnoringRangeStillHonoursOffsets) - 空闲播放会话不再被其他会话回收(410),已出过数据的会话受保护 - HLS 判定不再用 url 包含 hls 的启发式,改为路径后缀 - Range 请求强制 identity 编码;响应头立即 Flush,规避 Media3 8 秒连接超时 诊断:启动日志读取版本常量并打印包名;播放日记记录密钥位数/线路;代理环形探针 日志经 playbackProbe 桥暴露,失败时 LunaExoPlayer 侧输出往返记录。 版本 0.2.72+79。 --- integration_test/plan_probe_test.dart | 128 ++++++++++++ integration_test/playback_test.dart | 2 + integration_test/ui_cenc_test.dart | 111 +++++++++++ lib/app_build.dart | 2 + lib/app_layout.dart | 2 +- lib/core_bridge.dart | 13 ++ lib/local_media_screen.dart | 2 + lib/luna_exo_player.dart | 74 ++++++- lib/main.dart | 4 +- lib/player_screen.dart | 24 ++- native/core/app_playback_routes.go | 34 +++- native/core/app_runtime.go | 5 + native/core/app_stream.go | 257 ++++++++++++++++++++++--- native/core/app_stream_range_test.go | 116 +++++++++++ native/core/app_stream_session_test.go | 103 ++++++++++ native/core/network_media.go | 3 + pubspec.yaml | 2 +- test/fixtures.dart | 2 + 18 files changed, 839 insertions(+), 45 deletions(-) create mode 100644 integration_test/plan_probe_test.dart create mode 100644 integration_test/ui_cenc_test.dart create mode 100644 native/core/app_stream_range_test.go create mode 100644 native/core/app_stream_session_test.go diff --git a/integration_test/plan_probe_test.dart b/integration_test/plan_probe_test.dart new file mode 100644 index 0000000..e996457 --- /dev/null +++ b/integration_test/plan_probe_test.dart @@ -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 = >[]; + 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.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))); +} diff --git a/integration_test/playback_test.dart b/integration_test/playback_test.dart index b08a20c..8efcc35 100644 --- a/integration_test/playback_test.dart +++ b/integration_test/playback_test.dart @@ -92,6 +92,8 @@ class DeviceFixtureRepository extends AppRepository { @override Future cancelPlayback() => native.cancelPlayback(); @override + Future> playbackProbe() => native.playbackProbe(); + @override Future release(String session) async { if (session.isNotEmpty) released.add(session); await native.release(session); diff --git a/integration_test/ui_cenc_test.dart b/integration_test/ui_cenc_test.dart new file mode 100644 index 0000000..27955ec --- /dev/null +++ b/integration_test/ui_cenc_test.dart @@ -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 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 detail(Drama drama) async => DramaDetail(drama, [ + Episode({ + 'id': '1', + 'source': 'hongguo', + 'currentEpisode': 1, + 'title': '第1集', + 'videoUrl': '$fixtureBase/encrypted.mp4', + 'referer': '$fixtureBase/', + }, 1), + ]); + + @override + Future 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, + ); + } +} diff --git a/lib/app_build.dart b/lib/app_build.dart index 4e96e6f..2468680 100644 --- a/lib/app_build.dart +++ b/lib/app_build.dart @@ -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: 79); +const appPackageId = 'com.duanju.duanju_app'; diff --git a/lib/app_layout.dart b/lib/app_layout.dart index 800ea32..c1a3c61 100644 --- a/lib/app_layout.dart +++ b/lib/app_layout.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; export 'app_build.dart'; -const appVersion = '0.2.64'; +const appVersion = '0.2.72'; ThemeData televisionTheme(ThemeData theme) { final colors = theme.colorScheme; diff --git a/lib/core_bridge.dart b/lib/core_bridge.dart index e3cc90a..d67cb4e 100644 --- a/lib/core_bridge.dart +++ b/lib/core_bridge.dart @@ -184,6 +184,7 @@ abstract class AppRepository { Future fallback(PlaybackPlan current); Future cancelPlayback(); Future release(String session); + Future> playbackProbe(); } class NativeRepository extends AppRepository { @@ -742,6 +743,18 @@ class NativeRepository extends AppRepository { await _call({'action': 'cancelPlayback', 'sequence': ++_playbackSequence}); } + /// 读取原生代理最近记录的请求与响应,用于播放失败后定位原因。 + Future> 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; diff --git a/lib/local_media_screen.dart b/lib/local_media_screen.dart index 988f940..84b9702 100644 --- a/lib/local_media_screen.dart +++ b/lib/local_media_screen.dart @@ -445,4 +445,6 @@ class _LocalFileRepository extends AppRepository { Future cancelPlayback() async {} @override Future release(String session) async {} + @override + Future> playbackProbe() async => const []; } diff --git a/lib/luna_exo_player.dart b/lib/luna_exo_player.dart index e28fdf5..def5063 100644 --- a/lib/luna_exo_player.dart +++ b/lib/luna_exo_player.dart @@ -86,10 +86,13 @@ class LunaPlayerStreams implements PlayerStream { /// 采用和 LunaTV-Mobile 完全相同架构的 ExoPlayer (AndroidX Media3) 播放器封装, /// 同时对上层红果鉴播控 UI (PlayerControls & TelevisionControls) 暴露完全兼容的 Player 契约。 class LunaExoPlayer implements Player { - LunaExoPlayer() { + LunaExoPlayer({this.probeSource}) { state = const PlayerState().copyWith(volume: 100.0); } + /// 可选的原生代理探针读取器,用于播放失败后记录代理实际往返。 + final Future> Function()? probeSource; + VideoPlayerController? _controller; VoidCallback? _valueListener; Timer? _positionPollTimer; @@ -148,6 +151,7 @@ class LunaExoPlayer implements Player { headers.addAll(Map.from(media.httpHeaders!)); } } catch (_) {} + headers.removeWhere((key, _) => key.toLowerCase() == 'accept-encoding'); // 针对防盗链 CDN 注入标准移动端 UA if (!headers.keys.any((k) => k.toLowerCase() == 'user-agent')) { @@ -155,9 +159,11 @@ class LunaExoPlayer implements Player { 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; } - bool isHls = url.toLowerCase().contains('.m3u8') || - url.toLowerCase().contains('hls') || - (headers['accept']?.contains('mpegurl') ?? false); + final lowerUrl = url.toLowerCase(); + final pathOnly = lowerUrl.split('?').first.split('#').first; + bool isHls = pathOnly.endsWith('.m3u8') || + pathOnly.endsWith('.m3u') || + (headers['accept']?.toLowerCase().contains('mpegurl') ?? false); final uri = Uri.tryParse(url); if (uri == null && !url.startsWith('/')) { @@ -215,6 +221,11 @@ class LunaExoPlayer implements Player { '[ExoPlayer] c.initialize() 成功! duration=${c.value.duration}, size=${c.value.size}, isInitialized=${c.value.isInitialized}'); } catch (e, stack) { DiaryService.add('[ExoPlayer] 首次 initialize 失败: $e'); + DiaryService.add('[ExoPlayer] controller 状态: ${c.value.errorDescription ?? "无附加信息"}'); + // 失败瞬间回查本机代理,记录播放器实际会看到的响应,便于定位是代理 + // 返回了错误状态还是播放器自身解析失败。 + await _probeProxy(uri, headers); + await _dumpNativeProbe(); // 如果首次尝试失败,且为网络视频,则原地使用交替格式(HLS <-> MP4)自愈重试 if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { final alternateFormat = formatHint == VideoFormat.hls ? null : VideoFormat.hls; @@ -323,6 +334,61 @@ class LunaExoPlayer implements Player { revision.value++; } + /// 播放失败后回查本机代理:分别模拟播放器的无 Range 请求与开放 Range 请求, + /// 状态码异常时记录响应体(Go 侧错误信息),用于区分代理故障与解码故障。 + Future _probeProxy(Uri? uri, Map headers) async { + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) return; + for (final range in [null, 'bytes=0-']) { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + final request = await client.getUrl(uri); + headers.forEach((key, value) { + if (key.toLowerCase() != 'accept-encoding') { + request.headers.set(key, value); + } + }); + if (range != null) request.headers.set('Range', range); + final response = await request.close().timeout(const Duration(seconds: 8)); + final body = await response + .take(1) + .toList() + .timeout(const Duration(seconds: 5), onTimeout: () => []); + final head = body.isEmpty + ? '' + : String.fromCharCodes( + body.first.take(24), + ).replaceAll(RegExp(r'[\x00-\x1f]'), '.'); + DiaryService.add( + '[Probe] range=${range ?? "无"} status=${response.statusCode} ' + 'type=${response.headers.contentType} ' + 'len=${response.headers.contentLength} ' + 'range=${response.headers.value("content-range")} head=$head', + ); + client.close(force: true); + } catch (error) { + DiaryService.add('[Probe] range=${range ?? "无"} 回查失败: $error'); + } + } + } + + /// 记录原生代理侧最近的请求与响应,直接反映播放器拿到的字节数。 + Future _dumpNativeProbe() async { + final source = probeSource; + if (source == null) return; + try { + final entries = await source(); + if (entries.isEmpty) { + DiaryService.add('[Probe] 原生代理无记录(播放器可能未发起请求)'); + return; + } + for (final entry in entries) { + DiaryService.add('[Probe] $entry'); + } + } catch (error) { + DiaryService.add('[Probe] 读取原生记录失败: $error'); + } + } + void _startPositionPolling() { _positionPollTimer?.cancel(); _positionPollTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { diff --git a/lib/main.dart b/lib/main.dart index a698e46..adc87fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,9 @@ import 'diary_service.dart'; Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); - DiaryService.add('[App] 应用启动,版本: 0.2.64+71, 平台: ${Platform.operatingSystem}'); + DiaryService.add( + '[App] 应用启动,版本: $appVersion+$duanjuBuildNumber,包名: $appPackageId,平台: ${Platform.operatingSystem}', + ); if (Platform.isAndroid) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(AppTheme.systemBars(Brightness.dark)); diff --git a/lib/player_screen.dart b/lib/player_screen.dart index dcebf72..e31ade0 100644 --- a/lib/player_screen.dart +++ b/lib/player_screen.dart @@ -182,15 +182,15 @@ class _PlayerScreenState extends State widget.store.addListener(_accessChanged); _player = widget.playerFactory?.call() ?? - (Platform.isAndroid - ? LunaExoPlayer() - : Player( - configuration: const PlayerConfiguration( - bufferSize: 32 * 1024 * 1024, - logLevel: MPVLogLevel.error, - ), - )); - _video = widget.videoBuilder == null && !Platform.isAndroid + Player( + configuration: const PlayerConfiguration( + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); + // media_kit (libmpv) 是唯一能解密红果 CENC 内容的路径:video_player 插件 + // 不提供 DRM 接口,ExoPlayer 拿到加密数据后既无法解密也不会报错,只会卡住。 + _video = widget.videoBuilder == null ? VideoController( _player, configuration: VideoControllerConfiguration( @@ -1045,7 +1045,11 @@ class _PlayerScreenState extends State _plan = plan; installed = true; _acceptErrors = true; - DiaryService.add('[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}'); + DiaryService.add( + '[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}, ' + '密钥=${plan.decryptionKey.isEmpty ? "无" : "${plan.decryptionKey.length} 位"}, ' + '线路=${plan.routeIndex + 1}/${plan.routeCount}, 本地=${plan.local}', + ); await _player.open( Media( plan.url, diff --git a/native/core/app_playback_routes.go b/native/core/app_playback_routes.go index 03db971..1025c0e 100644 --- a/native/core/app_playback_routes.go +++ b/native/core/app_playback_routes.go @@ -9,6 +9,8 @@ import ( "time" ) +const nativePlaybackLimit = 8 + type nativePlaybackChoice struct { danmakuSeries string danmakuVideo string @@ -108,15 +110,35 @@ func (engine *nativeEngine) nativeOpenPlayback(ctx context.Context, choice nativ delete(engine.playbacks, token) } } - if len(engine.playbacks) >= 8 { - oldest := "" + if len(engine.playbacks) >= nativePlaybackLimit { + // 真正被读取过的会话就是正在观看的那一集,必须保留;预加载、重试等 + // 推测性会话从未送出过数据,优先回收。播放器在两次分片请求之间是空闲的, + // 所以不能以「当前有无请求在途」作为判据。 + victim := "" + var victimAt time.Time for token, old := range engine.playbacks { - if oldest == "" || old.created.Before(engine.playbacks[oldest].created) { - oldest = token + if engine.stream.served(old.streamSession) { + continue + } + if victim == "" || old.created.Before(victimAt) { + victim, victimAt = token, old.created + } + } + if victim == "" { + // 全部会话都在使用中(例如多集同时播放):回收最久未使用的非在用会话。 + var oldestAt time.Time + for token, old := range engine.playbacks { + at := old.created + if used, ok := engine.stream.usedAt(old.streamSession); ok { + at = used + } + if victim == "" || at.Before(oldestAt) { + victim, oldestAt = token, at + } } } - expired = append(expired, engine.playbacks[oldest].streamSession) - delete(engine.playbacks, oldest) + expired = append(expired, engine.playbacks[victim].streamSession) + delete(engine.playbacks, victim) } engine.playbacks[plan.Session] = choice stream := engine.stream diff --git a/native/core/app_runtime.go b/native/core/app_runtime.go index dca170d..2ee7d55 100644 --- a/native/core/app_runtime.go +++ b/native/core/app_runtime.go @@ -475,6 +475,11 @@ func nativeDispatch(input nativeInput) (any, error) { case "cancelPlayback": engine.nativeCancelPlayback(input.Sequence) return true, nil + case "playbackProbe": + if engine.stream == nil { + return map[string]any{"entries": []string{}}, nil + } + return map[string]any{"entries": engine.stream.nativeProbeLog()}, nil case "release": engine.nativeReleasePlayback(input.Session) return true, nil diff --git a/native/core/app_stream.go b/native/core/app_stream.go index f58a21c..396cd5d 100644 --- a/native/core/app_stream.go +++ b/native/core/app_stream.go @@ -8,12 +8,14 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "net" "net/http" "net/url" "path" "regexp" + "strconv" "strings" "sync" "time" @@ -37,6 +39,49 @@ type nativeStreamSession struct { ctx context.Context cancel context.CancelFunc lastUsed time.Time + inflight int + served int +} + +// served reports whether the session ever delivered media to the player. +func (session *nativeStreamSession) servedCount() int { + session.mu.Lock() + defer session.mu.Unlock() + return session.served +} + +// inFlight reports whether the session is currently serving a response. +func (session *nativeStreamSession) inFlight() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.inflight > 0 +} + +// served reports whether the identified session ever delivered media. +func (stream *nativeStreamServer) served(token string) bool { + if token == "" { + return false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + return session != nil && session.servedCount() > 0 +} + +// usedAt reports when the identified session last served a response. +func (stream *nativeStreamServer) usedAt(token string) (time.Time, bool) { + if token == "" { + return time.Time{}, false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + if session == nil { + return time.Time{}, false + } + session.mu.Lock() + defer session.mu.Unlock() + return session.lastUsed, true } type nativeStreamServer struct { @@ -45,6 +90,26 @@ type nativeStreamServer struct { address string sessions map[string]*nativeStreamSession server *http.Server + probeMu sync.Mutex + probes []string +} + +// nativeProbe records what the player asked the local proxy for and what the +// proxy answered, so a playback failure can be traced from the app log. +func (stream *nativeStreamServer) nativeProbe(format string, args ...any) { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + stream.probes = append(stream.probes, fmt.Sprintf(format, args...)) + if len(stream.probes) > 64 { + stream.probes = stream.probes[len(stream.probes)-64:] + } +} + +// nativeProbeLog returns the recorded proxy exchanges. +func (stream *nativeStreamServer) nativeProbeLog() []string { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + return append([]string(nil), stream.probes...) } func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Response, error) { @@ -55,6 +120,31 @@ func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Re var nativePlaylistURI = regexp.MustCompile(`URI="([^"]+)"`) +func nativeHLSManifest(address string) bool { + parsed, err := url.Parse(address) + if err != nil { + return false + } + return strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") +} + +// nativeDefiniteMedia reports whether a content type already identifies media +// that cannot be a playlist, so the body never needs to be sniffed first. +func nativeDefiniteMedia(contentType string) bool { + value := strings.ToLower(strings.TrimSpace(contentType)) + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = strings.TrimSpace(value[:semicolon]) + } + if value == "" || value == "application/octet-stream" || strings.Contains(value, "mpegurl") { + return false + } + return strings.HasPrefix(value, "video/") || + strings.HasPrefix(value, "audio/") || + value == "application/mp4" +} + +const nativeStreamMaxAge = 10 * time.Minute + func newNativeStreamServer(d *Downloader) (*nativeStreamServer, error) { listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { @@ -76,30 +166,19 @@ func (stream *nativeStreamServer) nativeOpen(media providerMedia) (string, strin ctx, cancel := context.WithCancel(providerMediaContext(context.Background(), media.credentials)) session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} stream.mu.Lock() + // 只按空闲时长回收。会话数量由 engine.playbacks 统一约束,并在那里调用 + // nativeRelease;这里再按数量淘汰会踢掉正在观看、恰好两次分片请求之间 + // 空闲的会话,播放器随后收到 410。 for id, old := range stream.sessions { - if time.Since(old.lastUsed) > 10*time.Minute { + if time.Since(old.lastUsed) > nativeStreamMaxAge && !old.inFlight() { old.cancel() delete(stream.sessions, id) } } - if len(stream.sessions) >= 8 { - oldest := "" - for id, old := range stream.sessions { - if oldest == "" || old.lastUsed.Before(stream.sessions[oldest].lastUsed) { - oldest = id - } - } - stream.sessions[oldest].cancel() - delete(stream.sessions, oldest) - } stream.sessions[token] = session stream.mu.Unlock() entry := nativeStreamAsset{address: media.URL, contentType: "video/mp4"} - isHLS := media.Playlist != "" || len(media.HLSKey) > 0 || strings.Contains(strings.ToLower(media.URL), "m3u8") || strings.Contains(strings.ToLower(media.URL), "hls") - if isHLS { - entry.contentType = "application/vnd.apple.mpegurl" - } - if parsed, err := url.Parse(media.URL); err == nil && strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") { + if media.Playlist != "" || len(media.HLSKey) > 0 || nativeHLSManifest(media.URL) { entry.contentType = "application/vnd.apple.mpegurl" } if media.Playlist != "" { @@ -214,6 +293,59 @@ func (stream *nativeStreamServer) nativeRewrite(token string, session *nativeStr return strings.Join(output, "\n"), nil } +// nativeRange 表示客户端请求的字节区间。suffix 为真时表示 "bytes=-N"(末尾 N 字节)。 +type nativeRange struct { + start int64 + end int64 + suffix bool + open bool +} + +// nativeParseRange 解析单区间 Range 头。不支持的写法返回 ok=false,交由原样转发。 +func nativeParseRange(value string) (nativeRange, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") { + return nativeRange{}, false + } + parts := strings.SplitN(strings.TrimPrefix(value, "bytes="), "-", 2) + if len(parts) != 2 { + return nativeRange{}, false + } + start, end := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if start == "" { + if end == "" { + return nativeRange{}, false + } + size, err := strconv.ParseInt(end, 10, 64) + if err != nil || size <= 0 { + return nativeRange{}, false + } + return nativeRange{suffix: true, end: size}, true + } + from, err := strconv.ParseInt(start, 10, 64) + if err != nil || from < 0 { + return nativeRange{}, false + } + if end == "" { + return nativeRange{start: from, open: true}, true + } + to, err := strconv.ParseInt(end, 10, 64) + if err != nil || to < from { + return nativeRange{}, false + } + return nativeRange{start: from, end: to}, true +} + +// nativeSkip 丢弃上游响应开头的 n 字节。上游忽略 Range 并返回完整内容时, +// 代理必须自行跳过前缀,否则播放器拿到的是从 0 开始的错误偏移。 +func nativeSkip(reader io.Reader, n int64) error { + if n <= 0 { + return nil + } + _, err := io.CopyN(io.Discard, reader, n) + return err +} + func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.WriteHeader(http.StatusMethodNotAllowed) @@ -231,9 +363,21 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques } stream.mu.Unlock() if session == nil { + stream.nativeProbe("GET %s -> 410 会话不存在", parts[1]) http.Error(writer, "播放已结束", http.StatusGone) return } + stream.nativeProbe("GET %s range=%q", parts[1], request.Header.Get("Range")) + session.mu.Lock() + session.inflight++ + session.served++ + session.mu.Unlock() + defer func() { + session.mu.Lock() + session.inflight-- + session.lastUsed = time.Now() + session.mu.Unlock() + }() session.mu.Lock() asset, found := session.assets[parts[1]] session.mu.Unlock() @@ -301,12 +445,60 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques if response.Request != nil && response.Request.URL != nil { finalURL = response.Request.URL } - playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || strings.HasSuffix(strings.ToLower(finalURL.Path), ".m3u8") || strings.Contains(strings.ToLower(finalURL.String()), "m3u8") || len(session.key) > 0 + // 上游可能忽略 Range 而返回 200 加完整内容(部分 CDN 如此)。此时必须由代理 + // 兑现客户端的区间请求:跳过前缀、限制长度并补上正确的 206 头,否则播放器 + // 会拿到从 0 开始的字节流,seek 之后解析错位并报 Source error。 + wanted, wantRange := nativeParseRange(request.Header.Get("Range")) + rewriteRange := wantRange && response.StatusCode == http.StatusOK + rangeStart, rangeEnd := int64(0), int64(-1) + if rewriteRange { + total := response.ContentLength + if total < 0 { + rewriteRange = false + } else { + switch { + case wanted.suffix: + if wanted.end > total { + rangeStart = 0 + } else { + rangeStart = total - wanted.end + } + rangeEnd = total - 1 + case wanted.open: + rangeStart = wanted.start + rangeEnd = total - 1 + default: + rangeStart = wanted.start + rangeEnd = min(wanted.end, total-1) + } + if rangeStart > rangeEnd || rangeStart >= total { + writer.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total)) + writer.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + } + } + playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || nativeHLSManifest(finalURL.String()) || len(session.key) > 0 reader := bufio.NewReader(response.Body) - if !playlist && request.Method == http.MethodGet { - peek, _ := reader.Peek(512) - if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")), "#EXTM3U") { + if playlist { + rewriteRange = false + } + if rewriteRange && request.Method == http.MethodGet { + if err := nativeSkip(reader, rangeStart); err != nil { + stream.nativeProbe(" -> 跳过前缀失败: %v", err) + http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) + return + } + } + // 仅在类型不确定时才探测首字节。Peek 会一直阻塞到上游送出数据为止,而播放器 + // 对响应头有连接超时(Media3 默认 8 秒);上游稍慢就会让大文件开播失败。 + if !playlist && request.Method == http.MethodGet && !nativeDefiniteMedia(contentType) { + peek, peekErr := reader.Peek(512) + prefix := strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")) + if strings.HasPrefix(prefix, "#EXTM3U") { playlist = true + } else if peekErr == nil { + playlist = false } } if playlist && request.Method == http.MethodHead { @@ -339,8 +531,29 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques writer.Header().Set(name, value) } } - writer.WriteHeader(response.StatusCode) + status := response.StatusCode + if rewriteRange { + status = http.StatusPartialContent + writer.Header().Set("Accept-Ranges", "bytes") + writer.Header().Set("Content-Length", strconv.FormatInt(rangeEnd-rangeStart+1, 10)) + writer.Header().Set("Content-Range", + fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, response.ContentLength)) + } + writer.WriteHeader(status) + // 立即把响应头送给播放器。否则头部会一直留在缓冲里,直到上游送出第一个 + // 字节才发出;上游稍慢就会触发播放器 8 秒连接超时(Media3 默认值)。 + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } if request.Method == http.MethodGet { - _, _ = io.Copy(writer, reader) + source := io.Reader(reader) + if rewriteRange && rangeEnd >= rangeStart { + source = io.LimitReader(reader, rangeEnd-rangeStart+1) + } + written, copyErr := io.Copy(writer, source) + stream.nativeProbe(" -> %d bytes err=%v (upstream %d %q, range=%v)", written, copyErr, + response.StatusCode, response.Header.Get("Content-Type"), rewriteRange) + } else { + stream.nativeProbe(" -> %d (head)", response.StatusCode) } } diff --git a/native/core/app_stream_range_test.go b/native/core/app_stream_range_test.go new file mode 100644 index 0000000..19dff53 --- /dev/null +++ b/native/core/app_stream_range_test.go @@ -0,0 +1,116 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// rangeProbeReader emits a payload where byte i equals i%251, so a wrong offset is +// immediately detectable. +type rangeProbeReader struct { + total int + offset int +} + +func (r *rangeProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 256<<10 { + n = 256 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} + +// TestZZ14UpstreamIgnoresRange reproduces the real CDN behaviour observed in the +// device log: a Range request is answered with 200 and the whole file. The proxy +// must still honour the requested offset, otherwise a seek hands the player +// mismatched bytes and it reports a source error. +func TestNativeUpstreamIgnoringRangeStillHonoursOffsets(t *testing.T) { + const total = 5 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Deliberately ignore Range: always 200 with the entire body. + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Length", strconv.Itoa(total)) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &zz14Reader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + stream, err := newNativeStreamServer(engine.downloader) + if err != nil { + t.Fatal(err) + } + defer stream.server.Close() + address, token := stream.nativeOpen(providerMedia{ + URL: upstream.URL + "/episode.mp4", + Referer: "https://example.test/watch", + }) + defer stream.nativeRelease(token) + + client := &http.Client{Timeout: 60 * time.Second} + type probe struct { + rangeHeader string + start int64 + length int64 + } + probes := []probe{ + {"bytes=0-", 0, total}, + {"bytes=1048576-", 1048576, total - 1048576}, + {"bytes=124194-", 124194, total - 124194}, + {"bytes=1000-1999", 1000, 1000}, + {fmt.Sprintf("bytes=-%d", 4096), total - 4096, 4096}, + } + for _, p := range probes { + request, _ := http.NewRequest(http.MethodGet, address, nil) + request.Header.Set("Range", p.rangeHeader) + start := time.Now() + response, err := client.Do(request) + if err != nil { + t.Fatalf("[range] %s: %v", p.rangeHeader, err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + response.Body.Close() + fmt.Fprintf(os.Stderr, "[range] %-20s -> status=%d len=%q range=%q got=%d err=%v in %v\n", + p.rangeHeader, response.StatusCode, response.Header.Get("Content-Length"), + response.Header.Get("Content-Range"), len(body), readErr, time.Since(start)) + + if response.StatusCode != http.StatusPartialContent { + t.Errorf("[range] %s: expected 206, got %d (player asked for an offset but got the whole file)", + p.rangeHeader, response.StatusCode) + } + if len(body) > 0 { + if want := byte(p.start % 251); body[0] != want { + t.Errorf("[range] %s: first byte %d, want %d (wrong offset delivered)", + p.rangeHeader, body[0], want) + } + } + if int64(len(body)) != p.length { + t.Errorf("[range] %s: got %d bytes, want %d", p.rangeHeader, len(body), p.length) + } + if want := fmt.Sprintf("bytes %d-%d/%d", p.start, p.start+p.length-1, total); response.Header.Get("Content-Range") != want { + t.Errorf("[range] %s: Content-Range %q, want %q", + p.rangeHeader, response.Header.Get("Content-Range"), want) + } + } +} diff --git a/native/core/app_stream_session_test.go b/native/core/app_stream_session_test.go new file mode 100644 index 0000000..c07d259 --- /dev/null +++ b/native/core/app_stream_session_test.go @@ -0,0 +1,103 @@ +package core + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// TestZZ12IdleSessionEvictedByNewSessions reproduces the desync between the two +// separate session caches: the engine bounds engine.playbacks while the stream +// server independently bounds its own sessions. A player that pauses between +// range requests (the normal case) holds no in-flight request, so its session +// can be evicted underneath it and the next request answers 410 播放已结束. +func TestNativeIdlePlaybackSessionSurvivesOtherSessions(t *testing.T) { + const total = 16 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("ETag", `"session-probe"`) + w.Header().Set("Content-Length", strconv.Itoa(total)) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &sessionProbeReader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + open := func(index int) nativePlan { + choice := nativePlaybackChoice{media: []providerMedia{{ + URL: upstream.URL + fmt.Sprintf("/episode-%d.mp4", index), + Referer: "https://example.test/watch", + }}} + plan, openErr := engine.nativeOpenPlayback(context.Background(), choice) + if openErr != nil { + t.Fatalf("open %d: %v", index, openErr) + } + return plan + } + + // The user starts watching episode 0. + playing := open(0) + fetch := func() int { + request, _ := http.NewRequest(http.MethodGet, playing.URL, nil) + request.Header.Set("Range", "bytes=0-65535") + response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request) + if err != nil { + return -1 + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode + } + if code := fetch(); code != http.StatusOK && code != http.StatusPartialContent { + t.Fatalf("[session] cannot start playback: %d", code) + } + + // Playback pauses (buffering, user pausing, or simply between range reads) + // while the app opens other episodes: retries, preloading, browsing. + for i := 1; i <= 12; i++ { + open(i) + } + + code := fetch() + fmt.Fprintf(os.Stderr, "[session] resuming the original episode -> %d\n", code) + if code == http.StatusGone { + t.Errorf("[session] the episode being watched was evicted while idle: %d (播放已结束)", code) + } + if code != http.StatusOK && code != http.StatusPartialContent { + t.Errorf("[session] unexpected status %d", code) + } +} + +type sessionProbeReader struct { + total int + offset int +} + +func (r *sessionProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 128<<10 { + n = 128 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} diff --git a/native/core/network_media.go b/native/core/network_media.go index 1e1cc6c..213cae3 100644 --- a/native/core/network_media.go +++ b/native/core/network_media.go @@ -31,6 +31,9 @@ func mediaRequestHeaders(request *http.Request, referer string) { if origin, err := url.Parse(referer); err == nil && origin.Host != "" { request.Header.Set("Origin", origin.Scheme+"://"+origin.Host) } + if strings.HasPrefix(strings.ToLower(request.Header.Get("Range")), "bytes=") { + request.Header.Set("Accept-Encoding", "identity") + } } func (d *Downloader) isHuangguoVideoURL(address *url.URL) bool { diff --git a/pubspec.yaml b/pubspec.yaml index d4a890e..e28a16a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.64+71 +version: 0.2.72+79 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/test/fixtures.dart b/test/fixtures.dart index d0f3d61..c2dc68b 100644 --- a/test/fixtures.dart +++ b/test/fixtures.dart @@ -112,5 +112,7 @@ class FixtureRepository extends AppRepository { @override Future cancelPlayback() async {} @override + Future> playbackProbe() async => const []; + @override Future release(String session) async {} } From 67e7acbaef939c9066773ed5e0d165911b4e5b1b Mon Sep 17 00:00:00 2001 From: fengs2021 Date: Tue, 6 Oct 2026 01:52:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=94=BB=E4=B8=AD=E7=94=BB=E4=BB=A5?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E8=BF=9B=E5=85=A5=E7=BB=93=E6=9E=9C=E4=B8=BA?= =?UTF-8?q?=E5=87=86=E5=B9=B6=E5=8C=BA=E5=88=86=E7=B3=BB=E7=BB=9F=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=A6=81=E7=94=A8=EF=BC=8C=E6=96=B0=E5=A2=9E=E7=BA=A2?= =?UTF-8?q?=E6=9E=9C=E5=BC=B9=E5=B9=95=20live=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分鸿蒙 / OEM 不声明 FEATURE_PICTURE_IN_PICTURE 或默认禁用画中画权限, 此前一律提示 当前设备不支持画中画。现在 enter 直接尝试并以结果置位验证 标志,AppOps 检查区分 权限被禁 与 设备不支持 两种提示。 版本 0.2.73+80。 --- .../com/duanju/duanju_app/MainActivity.kt | 37 +++++++++- lib/app_build.dart | 2 +- lib/app_layout.dart | 2 +- lib/player_screen.dart | 5 +- native/core/live_danmaku_test.go | 68 +++++++++++++++++++ pubspec.yaml | 2 +- 6 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 native/core/live_danmaku_test.go diff --git a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt index 48753c4..e2d8c95 100644 --- a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt @@ -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 { 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, @@ -249,7 +278,7 @@ class MainActivity : FlutterActivity() { right: Int?, bottom: Int? ): Map { - 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 { @@ -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() } } @@ -272,6 +302,7 @@ class MainActivity : FlutterActivity() { newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) pictureInPictureVerified = true deviceChannel?.invokeMethod( "pictureInPictureChanged", mapOf("active" to isInPictureInPictureMode) diff --git a/lib/app_build.dart b/lib/app_build.dart index 2468680..6077ab6 100644 --- a/lib/app_build.dart +++ b/lib/app_build.dart @@ -1,5 +1,5 @@ const allSourcesEnabled = bool.fromEnvironment('ALL_SOURCES'); const appName = allSourcesEnabled ? '真果鉴' : '红果鉴'; const appSlug = allSourcesEnabled ? 'zhenguojian' : 'hongguojian'; -const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 79); +const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 80); const appPackageId = 'com.duanju.duanju_app'; diff --git a/lib/app_layout.dart b/lib/app_layout.dart index c1a3c61..851259b 100644 --- a/lib/app_layout.dart +++ b/lib/app_layout.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; export 'app_build.dart'; -const appVersion = '0.2.72'; +const appVersion = '0.2.73'; ThemeData televisionTheme(ThemeData theme) { final colors = theme.colorScheme; diff --git a/lib/player_screen.dart b/lib/player_screen.dart index e31ade0..3fe5b55 100644 --- a/lib/player_screen.dart +++ b/lib/player_screen.dart @@ -494,6 +494,7 @@ class _PlayerScreenState extends State ); if (!mounted || _closed) return; final supported = status?['supported'] == true; + final granted = status?['granted'] != false; final active = status?['active'] == true; final requested = status?['requested'] == true; _setPictureInPictureStatus( @@ -501,8 +502,10 @@ class _PlayerScreenState extends State active: active, requested: requested && !active, ); - if (!supported || (!active && !requested)) { + if (!supported && !active && !requested) { _notice('当前设备不支持画中画'); + } else if (!granted && !active && !requested) { + _notice('系统已禁用画中画,请在 设置 > 应用 > 画中画 中允许'); } else if (requested && !active) { _pictureInPictureExitTimer?.cancel(); _pictureInPictureExitTimer = Timer(const Duration(seconds: 2), () { diff --git a/native/core/live_danmaku_test.go b/native/core/live_danmaku_test.go new file mode 100644 index 0000000..f2d7d83 --- /dev/null +++ b/native/core/live_danmaku_test.go @@ -0,0 +1,68 @@ +package core + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLiveHongguoDanmaku(t *testing.T) { + if os.Getenv("CHECK_LIVE_PROVIDERS") != "true" { + t.Skip("set CHECK_LIVE_PROVIDERS=true to touch live provider text APIs") + } + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.downloads.close) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + catalog, err := engine.nativeCatalog(ctx, nativeInput{Action: "catalog", Source: sourceHongguo, Page: 1}) + if err != nil || len(catalog.Items) == 0 { + t.Fatalf("catalog failed: count=%d err=%v", len(catalog.Items), err) + } + probed := 0 + for _, drama := range catalog.Items { + if probed >= 5 { + break + } + probed++ + detail, err := engine.nativeDetail(ctx, drama) + if err != nil { + t.Logf("%s detail failed: %v", drama.ID, err) + continue + } + chapters, ok := detail.(map[string]any)["chapters"].([]Chapter) + if !ok || len(chapters) == 0 { + t.Logf("%s detail empty", drama.ID) + continue + } + chapter := chapters[0] + if !strings.HasPrefix(chapter.VideoURL, "hongguo-cenc://") { + t.Logf("%s ep1 not cenc: %q", drama.ID, chapter.VideoURL) + continue + } + plan, err := engine.nativeResolve(ctx, nativeInput{Action: "resolve", Drama: drama, Chapter: chapter, Index: 1}) + if err != nil { + t.Logf("%s resolve failed: %v", drama.ID, err) + continue + } + input := nativeInput{Action: "danmaku", PlaybackSession: plan.Session, StartMS: 0, DurationMS: 60_000} + page, err := engine.nativeDanmaku(ctx, input) + if err != nil { + t.Errorf("%s danmaku FAILED: %v", drama.ID, err) + } else { + t.Logf("%s danmaku ok: items=%d total=%d next=%d", drama.ID, len(page.Items), page.Total, page.NextMS) + for i, item := range page.Items { + if i >= 3 { + break + } + t.Logf(" id=%s t=%dms text=%q", item.ID, item.TimeMS, item.Text) + } + } + engine.nativeReleasePlayback(plan.Session) + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e28a16a..b93c725 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.72+79 +version: 0.2.73+80 environment: sdk: '>=3.12.0 <4.0.0'