diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b6cae25..22499b7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,9 @@ plugins { android { namespace = "com.stormg.lumadrama" - compileSdk = flutter.compileSdkVersion + // permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37, + // 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。 + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e7df0ed..27046bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + Void)? + /// 影子在画中画里播完当前集。 + var onPlaybackEnded: (() -> Void)? + + static var isSupported: Bool { + AVPictureInPictureController.isPictureInPictureSupported() + } + + private var keyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } + + /// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。 + /// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。 + func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) { + teardown() + log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)") + guard Self.isSupported else { return } + guard let url = URL(string: urlString) else { + log("prepare failed bad url \(urlString)") + return + } + guard let window = keyWindow else { + log("prepare failed no key window") + return + } + let item = AVPlayerItem(url: url) + let p = AVPlayer(playerItem: item) + p.isMuted = true + p.actionAtItemEnd = .pause + let layer = AVPlayerLayer() + layer.player = p + let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + host.isUserInteractionEnabled = false + host.backgroundColor = .clear + window.addSubview(host) + layer.frame = host.bounds + host.layer.addSublayer(layer) + hostView = host + guard let c = AVPictureInPictureController(playerLayer: layer) else { + p.pause() + log("prepare failed no pip controller") + return + } + c.canStartPictureInPictureAutomaticallyFromInline = autoEnter + c.delegate = self + player = p + playerLayer = layer + controller = c + // 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。 + p.play() + possibleObservation = c.observe( + \.isPictureInPicturePossible, + options: [.new, .initial] + ) { [weak self] controller, change in + let value = change.newValue ?? controller.isPictureInPicturePossible + self?.log("possible changed to \(value)") + guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return } + self.pendingStart = false + // 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。 + DispatchQueue.main.async { + guard self.controller === controller, controller.isPictureInPicturePossible else { + self.log("start via kvo aborted (shadow replaced)") + return + } + self.log("start via kvo") + controller.startPictureInPicture() + } + } + log("shadow ready, seeking to \(positionMs)ms") + if positionMs > 0 { + p.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + registerEndObserver(for: item) + activateAudioSession() + } + + /// 退出 App 前(inactive)把影子对齐到当前进度并静音起播: + /// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。 + func armAutoEnter(positionMs: Int64) { + guard let player, let controller else { + log("arm failed, shadow missing") + return + } + guard controller.canStartPictureInPictureAutomaticallyFromInline else { + log("arm failed, autoEnter disabled on shadow") + return + } + log("arm auto enter at \(positionMs)ms") + if positionMs > 0 { + player.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: CMTime(value: 800, timescale: 1000), + toleranceAfter: CMTime(value: 800, timescale: 1000) + ) + } + player.play() + } + + /// 回前台后取消待命:影子暂停回静音,等待下一次退后台。 + func disarmAutoEnter() { + guard let player else { return } + log("disarm auto enter") + player.pause() + player.isMuted = true + } + + /// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。 + /// 仅当影子从未创建成功(prepare 失败)时返回 false。 + func start() -> Bool { + guard let controller else { + log("start failed, shadow not created") + return false + } + log("start requested possible=\(controller.isPictureInPicturePossible)") + pendingStart = true + if controller.isPictureInPicturePossible { + pendingStart = false + controller.startPictureInPicture() + } + scheduleStartRetries() + return true + } + + /// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。 + private func scheduleStartRetries() { + startRetryWorkItems.forEach { $0.cancel() } + startRetryWorkItems.removeAll() + for delay in [1.0, 2.5] { + let item = DispatchWorkItem { [weak self] in + guard + let self, + self.pendingStart, + let controller = self.controller, + controller.isPictureInPicturePossible + else { return } + self.log("start via retry after \(delay)s") + self.pendingStart = false + controller.startPictureInPicture() + } + startRetryWorkItems.append(item) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + } + + /// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。 + func replaceItem(urlString: String, positionMs: Int64) { + guard let player, let url = URL(string: urlString) else { return } + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + registerEndObserver(for: item) + if positionMs > 0 { + player.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + } + + /// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。 + func unmuteAndPlay() { + player?.isMuted = false + player?.play() + } + + func positionMs() -> Int64 { + Int64(max(0, player?.currentTime().seconds ?? 0) * 1000) + } + + func stopPip() { + controller?.stopPictureInPicture() + } + + func teardown() { + startRetryWorkItems.forEach { $0.cancel() } + startRetryWorkItems.removeAll() + possibleObservation?.invalidate() + possibleObservation = nil + pendingStart = false + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + endObserver = nil + player?.pause() + player?.replaceCurrentItem(with: nil) + playerLayer?.removeFromSuperlayer() + playerLayer = nil + hostView?.removeFromSuperview() + hostView = nil + controller?.delegate = nil + controller = nil + player = nil + } + + /// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。 + private func log(_ message: String) { + NSLog("pip: %@", message) + channel?.invokeMethod("pipDebug", arguments: message) + } + + private func registerEndObserver(for item: AVPlayerItem) { + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + self?.log("item did play to end") + self?.onPlaybackEnded?() + } + } + + /// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。 + private func activateAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + // 会话激活失败只影响后台出声,不阻断画中画。 + } + } + + func pictureInPictureControllerWillStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("will start") + pendingStart = false + onPipChanged?(true, 0) + } + + func pictureInPictureControllerWillStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("will stop, showing restore overlay") + showRestoreOverlay() + } + + func pictureInPictureControllerDidStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("did stop") + showRestoreOverlay() + onPipChanged?(false, positionMs()) + } + + /// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。 + func showRestoreOverlay() { + guard let window = keyWindow else { return } + restoreOverlay?.removeFromSuperview() + let overlay = UIView(frame: window.bounds) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + let gradient = CAGradientLayer() + gradient.frame = overlay.bounds + gradient.colors = [ + UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor, + UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor, + UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor, + ] + gradient.locations = [0, 0.22, 0.62] + gradient.startPoint = CGPoint(x: 0.5, y: 0) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + overlay.layer.addSublayer(gradient) + window.addSubview(overlay) + restoreOverlay = overlay + } + + /// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。 + func hideRestoreOverlay() { + guard let overlay = restoreOverlay else { return } + restoreOverlay = nil + UIView.animate( + withDuration: 0.25, + animations: { overlay.alpha = 0 } + ) { _ in overlay.removeFromSuperview() } + } +} + @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private let wirelessDataPrompt = WirelessDataPrompt() @@ -64,6 +377,7 @@ final class RunnerFlutterViewController: FlutterViewController { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) registerWirelessDataChannel(engineBridge) registerDeviceIdChannel(engineBridge) + registerPipChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() @@ -145,6 +459,91 @@ final class RunnerFlutterViewController: FlutterViewController { } } + /// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。 + private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) { + let channel = FlutterMethodChannel( + name: "com.stormg.lumadrama/pip", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + let pip = PipShadowCoordinator.shared + pip.channel = channel + channel.setMethodCallHandler { call, result in + switch call.method { + case "isAvailable": + result(PipShadowCoordinator.isSupported) + case "enter": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(false) + return + } + let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0 + pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false) + // 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。 + DispatchQueue.main.async { + result(pip.start()) + } + case "prepareShadow": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(nil) + return + } + pip.prepare( + urlString: url, + positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0, + autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false + ) + result(nil) + case "armAutoEnter": + pip.armAutoEnter( + positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0 + ) + result(nil) + case "showRestoreOverlay": + pip.showRestoreOverlay() + result(nil) + case "hideRestoreOverlay": + pip.hideRestoreOverlay() + result(nil) + case "disarmAutoEnter": + pip.disarmAutoEnter() + result(nil) + case "unmute": + pip.unmuteAndPlay() + result(nil) + case "replaceItem": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(nil) + return + } + pip.replaceItem( + urlString: url, + positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0 + ) + result(nil) + case "stop": + pip.stopPip() + result(nil) + case "teardown": + pip.teardown() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + pip.onPipChanged = { active, positionMs in + channel.invokeMethod( + "onPipChanged", + arguments: ["active": active, "positionMs": positionMs] + ) + } + pip.onPlaybackEnded = { + channel.invokeMethod("onPipPlaybackEnded", arguments: nil) + } + } + /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。 private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1e27100..daecf90 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -87,6 +87,10 @@ + UIBackgroundModes + + audio + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/data/services/android_picture_in_picture.dart b/lib/core/data/services/android_picture_in_picture.dart index efb349d..a2f73fb 100644 --- a/lib/core/data/services/android_picture_in_picture.dart +++ b/lib/core/data/services/android_picture_in_picture.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -/// Native Android activity PiP; the Flutter playback session keeps the player. +/// 原生活动级画中画桥:Android 是 Activity PiP(画面即 Flutter 渲染), +/// iOS 是影子播放器接力(画面/声音来自原生 AVPlayer,退出时进度回传)。 class AndroidPictureInPicture { AndroidPictureInPicture({MethodChannel? channel}) : _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip'); @@ -11,22 +12,147 @@ class AndroidPictureInPicture { final MethodChannel _channel; bool get isPlatformSupported => - !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); - void setStateListener(ValueChanged? listener) { - _channel.setMethodCallHandler( - listener == null - ? null - : (call) async { - if (call.method == 'onPipChanged') { - listener(call.arguments == true); - } - }, - ); + void Function(bool active, int finalPositionMs)? _pipChangedListener; + VoidCallback? _playbackEndedListener; + + /// PiP 状态回调:iOS 停止时 [finalPositionMs] 为影子最终进度(毫秒)。 + void setStateListener(void Function(bool active, int finalPositionMs)? listener) { + _pipChangedListener = listener; + _refreshHandler(); } - Future enter() async { + /// iOS 画中画内影子播完当前集(Android 不会触发)。 + void setPlaybackEndedListener(VoidCallback? listener) { + _playbackEndedListener = listener; + _refreshHandler(); + } + + void _refreshHandler() { + if (_pipChangedListener == null && _playbackEndedListener == null) { + _channel.setMethodCallHandler(null); + return; + } + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'pipDebug': + debugPrint('pip[native]: ${call.arguments}'); + case 'onPipChanged': + final listener = _pipChangedListener; + if (listener == null) return; + final args = call.arguments; + if (args is Map) { + listener( + args['active'] == true, + (args['positionMs'] as num?)?.toInt() ?? 0, + ); + } else { + // Android 只回传 bool。 + listener(args == true, 0); + } + case 'onPipPlaybackEnded': + _playbackEndedListener?.call(); + } + }); + } + + /// 进入画中画。Android 忽略片源参数;iOS 用 [url] 与 [positionMs] + /// 初始化影子播放器(对齐到当前进度、静音待命)。 + Future enter({required String url, required int positionMs}) async { if (!isPlatformSupported) return false; - return await _channel.invokeMethod('enter') ?? false; + return await _channel.invokeMethod('enter', { + 'url': url, + 'positionMs': positionMs, + }) ?? + false; + } + + /// 创建影子播放器但不起播(仅 iOS): + /// 自动小窗待命形态——静音暂停,退后台由 [armAutoEnter] 拉起。 + Future prepareShadow({ + required String url, + required int positionMs, + bool autoEnter = false, + }) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('prepareShadow', { + 'url': url, + 'positionMs': positionMs, + 'autoEnter': autoEnter, + }); + } catch (_) {} + } + + /// 退后台瞬间把影子对齐到当前进度并静音起播,触发系统自动画中画(仅 iOS)。 + Future armAutoEnter(int positionMs) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('armAutoEnter', { + 'positionMs': positionMs, + }); + } catch (_) {} + } + + /// 回前台后影子回待命(暂停、静音)(仅 iOS)。 + Future disarmAutoEnter() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('disarmAutoEnter'); + } catch (_) {} + } + + /// 显示原生还原过渡层(仅 iOS;还原动画开始时原生侧也会自动盖上)。 + Future showRestoreOverlay() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('showRestoreOverlay'); + } catch (_) {} + } + + /// 淡出原生还原过渡层(仅 iOS;视频首帧确认后调用)。 + Future hideRestoreOverlay() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('hideRestoreOverlay'); + } catch (_) {} + } + + /// 解除影子静音并起播(仅 iOS 有意义,进入 PiP 后声音交给影子)。 + Future unmute() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('unmute'); + } catch (_) {} + } + + /// iOS 画中画内换集:影子只换播放项,窗口不中断。 + Future replaceItem({required String url, required int positionMs}) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('replaceItem', { + 'url': url, + 'positionMs': positionMs, + }); + } catch (_) {} + } + + /// 请求退出画中画(iOS;触发 onPipChanged(false) 后由 Dart 收尾)。 + Future stop() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('stop'); + } catch (_) {} + } + + /// 销毁影子播放器(仅 iOS)。 + Future teardown() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('teardown'); + } catch (_) {} } } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa3e95e..acdbb65 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -1,12 +1,16 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; +import 'package:floating/floating.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/episode_stats.dart'; @@ -44,6 +48,16 @@ typedef _PlaybackSnapshot = ({ }); class PlayerLogic extends ChangeNotifier { + /// Android PiP 管理器(floating 插件);负责退后台自动进小窗。 + static final Floating _floating = Floating(); + + /// 挂了自动小窗的播放器实例(引用计数):沉浸流与独立播放页可能 + /// 同时在树上,只有全部不需要时才真正注销 OnLeavePiP。 + static final Set _pipOwners = {}; + + /// OnLeavePiP 当前在原生侧的挂载状态(进程级,与 [_pipOwners] 对应)。 + static bool _pipNativeArmed = false; + PlayerLogic({ required this.history, required this.playback, @@ -112,7 +126,31 @@ class PlayerLogic extends ChangeNotifier { VideoPlayerPort? scrubbingPlayer; bool pip = false; bool pipPreparing = false; - final Map pickedEpisode = {}; + bool _wasPlayingBeforeIosPip = false; + Timer? _pipStartTimer; + + /// iOS 自动小窗开关(对齐红果:开启后退 App 自动进入画中画)。 + static const _iosAutoPipPrefKey = 'player.ios.autoEnterPip'; + bool iosAutoPipEnabled = false; + + /// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。 + int pipResumeToken = 0; + + /// 画中画还原过渡期:为 true 时播放区盖当前集封面,等纹理出帧后淡出, + /// 盖住回前台瞬间的黑纹理帧。700ms 后自动复位。 + bool pipRecovering = false; + Timer? _pipRecoverTimer; + + /// iOS 影子播放器当前承载的集 id(全局单例状态);用于避免重复换源。 + static String? iosShadowEpisodeId; + + /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 + bool get iosDevice => !kIsWeb && Platform.isIOS; + /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: + /// 推荐页每页一个 logic,若只保留最后一次注册的 handler, + /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 + static final Set _pipSubscribers = {}; + static final Map pickedEpisode = {}; final Set _unlockedEpisodeIds = {}; final Map _episodeStats = {}; final Map _episodeStatsFetchedAt = {}; @@ -214,7 +252,10 @@ class PlayerLogic extends ChangeNotifier { _started = true; _sessionFactory = factory; _sessionCoordinator = coordinator; - pictureInPicture.setStateListener(onPipChanged); + _pipSubscribers.add(this); + pictureInPicture.setStateListener(_dispatchPipChanged); + pictureInPicture.setPlaybackEndedListener(_dispatchPipPlaybackEnded); + unawaited(_loadIosAutoPipPref()); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); @@ -850,6 +891,11 @@ class PlayerLogic extends ChangeNotifier { if (pip || pipPreparing) { pip = false; pipPreparing = false; + if (iosDevice) { + // 部分回前台路径不回调 didStop:销毁影子、seek 刷纹理并恢复播放, + // 否则停在 PiP 期间的暂停态表现为黑屏;自动小窗开着则重建待命。 + unawaited(_recoverFromIosPipFallback()); + } notify(); notifyImmersive(); } @@ -873,6 +919,26 @@ class PlayerLogic extends ChangeNotifier { state == AppLifecycleState.resumed || pip || pipPreparing, ); if (state != AppLifecycleState.resumed) cancelScrub(); + // iOS 自动小窗:退后台瞬间把影子对齐到当前进度起播,系统据此自动进画中画; + // 回前台取消待命。手动小窗的恢复由 onPipChanged(false) 处理。 + if (iosDevice && iosAutoPipEnabled && !pip) { + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (state == AppLifecycleState.inactive && + video != null && + session.userWantsPlay) { + debugPrint('pip: lifecycle inactive -> arm auto enter'); + unawaited( + pictureInPicture.armAutoEnter( + video.controller.value.position.inMilliseconds, + ), + ); + } else if (state == AppLifecycleState.resumed) { + debugPrint('pip: lifecycle resumed -> disarm auto enter'); + unawaited(pictureInPicture.disarmAutoEnter()); + } + } } Future _refreshTicketThenResume( @@ -904,7 +970,13 @@ class PlayerLogic extends ChangeNotifier { void dispose() { if (_disposed) return; _disposed = true; - pictureInPicture.setStateListener(null); + _pipSubscribers.remove(this); + _pipStartTimer?.cancel(); + if (_pipSubscribers.isEmpty) { + pictureInPicture.setStateListener(null); + pictureInPicture.setPlaybackEndedListener(null); + } + unawaited(syncPipAutoArm(false)); unawaited(saveCurrentProgress()); _detachTrackedPlayerListener(); if (_started) { @@ -918,31 +990,117 @@ class PlayerLogic extends ChangeNotifier { void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); + /// 按 [want] 挂/摘退后台自动小窗(引用计数): + /// - 沉浸流:随所在 tab 的激活状态调用(active 时挂,切走时摘); + /// - 独立播放页:进入时挂、销毁时摘; + /// - 权限未授权时静默跳过(申请统一在首次点「画中画」时,见 [enterPip])。 + Future syncPipAutoArm(bool want) async { + if (!pictureInPicture.isPlatformSupported) return; + // iOS 无「退后台自动小窗」(系统不开放 Activity 级自动进入),走手动入口。 + if (!kIsWeb && Platform.isIOS) return; + try { + if (want) { + _pipOwners.add(this); + } else { + _pipOwners.remove(this); + } + final arm = _pipOwners.isNotEmpty; + if (arm == _pipNativeArmed) return; + if (arm) { + if (!await _floating.isPipAvailable) return; + if (!await Permission.systemAlertWindow.isGranted) return; + await _floating.enable(const OnLeavePiP(aspectRatio: Rational(9, 16))); + _pipNativeArmed = true; + } else { + _pipNativeArmed = false; + await _floating.cancelOnLeavePiP(); + } + } catch (_) { + // 自动小窗挂载失败不影响正常播放。 + } + } + Future enterPip(BuildContext context) async { - if (pip || pipPreparing) return; - if (session.active is! VideoPlayerPort) { + if (pip || pipPreparing) { + debugPrint('pip: tap ignored (pip=$pip preparing=$pipPreparing)'); + return; + } + final isIos = !kIsWeb && Platform.isIOS; + debugPrint('pip: tap platform=${isIos ? "ios" : "android"}'); + final activeVideo = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (activeVideo == null) { + debugPrint('pip: no active video port'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), ); return; } + // 首次点击「画中画」:申请「显示在其他应用上层」权限(特殊权限, + // 会跳系统设置页);拒绝则视为设备无法使用画中画。仅 Android 需要。 + if (!isIos && pictureInPicture.isPlatformSupported) { + try { + if (!await Permission.systemAlertWindow.isGranted) { + await Permission.systemAlertWindow.request(); + } + if (await Permission.systemAlertWindow.isGranted) { + _pipOwners.add(this); + if (!_pipNativeArmed && await _floating.isPipAvailable) { + await _floating.enable( + const OnLeavePiP(aspectRatio: Rational(9, 16)), + ); + _pipNativeArmed = true; + } + } else { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.pipUnavailable), + ), + ); + return; + } + } catch (_) { + // 权限流程异常时按原有流程继续尝试直接进入。 + } + } pipPreparing = true; notify(); notifyImmersive(); await WidgetsBinding.instance.endOfFrame; + final url = VideoPlayerPort.resolveUri( + sessionEpisodes[session.currentIndex], + ).toString(); + final positionMs = activeVideo.controller.value.position.inMilliseconds; + debugPrint('pip: invoke enter pos=$positionMs'); bool entered = false; try { - entered = await pictureInPicture.enter(); - } catch (_) { + entered = await pictureInPicture.enter(url: url, positionMs: positionMs); + } catch (e) { + debugPrint('pip: enter threw $e'); // Android reports unavailable for devices without PiP support. } + debugPrint('pip: entered=$entered'); if (!context.mounted) return; if (entered) { - pip = true; - pipPreparing = false; - notify(); - notifyImmersive(); - session.setAppResumed(true); + if (isIos) { + // iOS 等影子流就绪后自动进入小窗(原生 pendingStart 兜底不会过期); + // 超时仅复位按钮状态,避免长时间卡在「准备中」。 + _pipStartTimer?.cancel(); + _pipStartTimer = Timer(const Duration(seconds: 6), () { + if (!isMounted() || pip || !pipPreparing) return; + pipPreparing = false; + notify(); + notifyImmersive(); + }); + } else { + pip = true; + pipPreparing = false; + notify(); + notifyImmersive(); + session.setAppResumed(true); + } } else { pipPreparing = false; notify(); @@ -953,10 +1111,28 @@ class PlayerLogic extends ChangeNotifier { } } - void onPipChanged(bool enabled) { + static void _dispatchPipChanged(bool enabled, int finalPositionMs) { + for (final logic in List.of(_pipSubscribers)) { + logic.onPipChanged(enabled, finalPositionMs: finalPositionMs); + } + } + + static void _dispatchPipPlaybackEnded() { + for (final logic in List.of(_pipSubscribers)) { + logic._handleIosPipPlaybackEnded(); + } + } + + void onPipChanged(bool enabled, {int finalPositionMs = 0}) { if (!isMounted()) return; + if (!kIsWeb && Platform.isIOS) { + _onIosPipChanged(enabled, finalPositionMs); + return; + } pip = enabled; pipPreparing = false; + _pipStartTimer?.cancel(); + _pipStartTimer = null; notify(); notifyImmersive(); session.setAppResumed( @@ -965,6 +1141,94 @@ class PlayerLogic extends ChangeNotifier { ); } + /// iOS 影子接力:进入小窗后画面/声音交给原生影子播放器, + /// Flutter 播放器暂停(避免双声);退出时按影子进度 seek 对齐恢复。 + void _onIosPipChanged(bool enabled, int finalPositionMs) { + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + pip = enabled; + pipPreparing = false; + _pipStartTimer?.cancel(); + _pipStartTimer = null; + if (enabled) { + _wasPlayingBeforeIosPip = session.userWantsPlay; + session.setUserWantsPlay(false); + if (video != null) unawaited(video.pause()); + unawaited(pictureInPicture.unmute()); + session.setAppResumed(true); + } else { + unawaited(pictureInPicture.teardown()); + PlayerLogic.iosShadowEpisodeId = null; + pipResumeToken++; + _beginPipRecover(); + if (video != null) { + final target = finalPositionMs > 0 + ? clampResumePosition( + Duration(milliseconds: finalPositionMs), + video.controller.value.duration, + ) + : video.controller.value.position; + // seek 同时兜住进度对齐与回前台纹理黑屏(强制 AVPlayer 重新出帧)。 + unawaited(video.seekTo(target)); + } + session.setUserWantsPlay(_wasPlayingBeforeIosPip); + session.setAppResumed( + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + // 自动小窗开着时影子回待命,等待下次退后台。 + if (iosAutoPipEnabled) { + final episode = trackedEpisode; + if (episode != null) unawaited(_ensureIosShadowStandby(episode)); + } + } + notify(); + notifyImmersive(); + } + + /// iOS 画中画里影子播完当前集:小窗内直接换下一集片源无缝连播(不翻页), + /// 同时用无动画跳页把 App 内位置同步到新集,还原后正好停在新集; + /// 没有下一集或下一集锁定时退出画中画回到 App。 + Future _handleIosPipPlaybackEnded() async { + if (!isMounted() || !pip || !(!kIsWeb && Platform.isIOS)) return; + final index = session.currentIndex; + final positionInVisible = visibleIndices.indexOf(index) + 1; + if (positionInVisible <= 0 || positionInVisible >= visibleIndices.length) { + await pictureInPicture.stop(); + return; + } + final nextIndex = visibleIndices[positionInVisible]; + if (nextIndex < 0 || nextIndex >= sessionEpisodes.length) { + await pictureInPicture.stop(); + return; + } + if (isEpisodeLocked(sessionEpisodes[nextIndex])) { + await pictureInPicture.stop(); + return; + } + final episode = sessionEpisodes[nextIndex]; + debugPrint('pip: auto next episode=${episode.id}'); + // 影子直接切下一集从头播,窗口不中断。 + try { + await pictureInPicture.replaceItem( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + ); + await pictureInPicture.unmute(); + PlayerLogic.iosShadowEpisodeId = episode.id; + } catch (_) { + await pictureInPicture.stop(); + return; + } + // 同步 App 内会话与页面位置(无动画),还原后停在当前连播到的集。 + final controller = pages; + try { + if (controller.hasClients) controller.jumpToPage(positionInVisible); + } catch (_) { + // 页面可能已离树,影子连播不受影响。 + } + } + Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; try { @@ -1020,6 +1284,121 @@ class PlayerLogic extends ChangeNotifier { _completionProgressSaved = false; lastProgressWrite = DateTime.now(); player.controller.addListener(_handleTrackedPlayerChanged); + // iOS:小窗内换集则影子直切新片源继续播;自动小窗开启时影子换源保持待命。 + // 影子已在该集(连播刚换过源)时跳过,避免重置回片头。 + if (!kIsWeb && Platform.isIOS) { + if (pip) { + if (PlayerLogic.iosShadowEpisodeId != episode.id) { + unawaited(_syncIosShadowToEpisode(episode, playWhenPip: true)); + } + } else if (iosAutoPipEnabled) { + unawaited(_ensureIosShadowStandby(episode)); + } + } + } + + Future _syncIosShadowToEpisode( + Episode episode, { + required bool playWhenPip, + }) async { + debugPrint('pip: shadow sync episode=${episode.id} play=$playWhenPip'); + try { + await pictureInPicture.replaceItem( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + ); + PlayerLogic.iosShadowEpisodeId = episode.id; + if (playWhenPip) await pictureInPicture.unmute(); + } catch (_) { + // 影子切换失败时保持小窗停在旧集末帧,不阻断 Flutter 侧换集。 + } + } + + /// 读取自动小窗开关;开启时影子待命由集激活钩子建立。 + Future _loadIosAutoPipPref() async { + if (!iosDevice) return; + try { + iosAutoPipEnabled = + await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false; + } catch (_) { + iosAutoPipEnabled = false; + } + } + + /// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。 + Future setIosAutoPip(bool enabled) async { + debugPrint('pip: auto-pip switch -> $enabled'); + if (!iosDevice || iosAutoPipEnabled == enabled) return; + iosAutoPipEnabled = enabled; + try { + await SharedPreferencesAsync().setBool(_iosAutoPipPrefKey, enabled); + } catch (_) { + // 偏好写失败只影响下次启动的记忆,本次会话照常生效。 + } + if (enabled) { + final index = session.currentIndex; + if (index >= 0 && index < sessionEpisodes.length) { + await _ensureIosShadowStandby(sessionEpisodes[index]); + } + } else { + await pictureInPicture.teardown(); + PlayerLogic.iosShadowEpisodeId = null; + } + } + + /// 还原过渡:盖封面直至视频真正出帧(onVideoVisible 时收起),兜底 900ms。 + void _beginPipRecover() { + _pipRecoverTimer?.cancel(); + pipRecovering = true; + _pipRecoverTimer = Timer(const Duration(milliseconds: 900), endPipRecover); + } + + /// 视频首帧确认后立即收起还原封面(由播放视图回调)。 + void endPipRecover() { + _pipRecoverTimer?.cancel(); + if (pipRecovering) { + pipRecovering = false; + notify(); + } + // 原生过渡层在还原动画开始时已盖上,这里等表面恢复后淡出。 + unawaited(pictureInPicture.hideRestoreOverlay()); + } + + /// 回前台兜底恢复:销毁影子、seek 强制重新出帧并恢复播放, + /// 修复部分还原路径不回调 didStop 导致的黑屏 + 暂停。 + Future _recoverFromIosPipFallback() async { + pipResumeToken++; + _beginPipRecover(); + await pictureInPicture.teardown(); + PlayerLogic.iosShadowEpisodeId = null; + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (video != null) { + await video.seekTo(video.controller.value.position); + session.setUserWantsPlay(true); + } + session.setAppResumed(true); + final episode = trackedEpisode; + if (iosAutoPipEnabled && episode != null) { + await _ensureIosShadowStandby(episode); + } + } + + /// 影子待命形态:静音暂停在片头,退后台时由系统自动切画中画。 + Future _ensureIosShadowStandby(Episode episode) async { + if (!iosDevice || pip) return; + try { + debugPrint('pip: prepare shadow standby (autoEnter)'); + await pictureInPicture.prepareShadow( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + autoEnter: true, + ); + PlayerLogic.iosShadowEpisodeId = episode.id; + } catch (e) { + debugPrint('pip: prepare standby failed: $e'); + } } void _detachTrackedPlayerListener() { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6aede6d..2144391 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -200,6 +200,8 @@ class _PlayerPageState extends State ); } _pageController = logic.pages; + // 自动小窗:独立播放页进入即挂;沉浸流随所在 tab 的激活状态挂/摘。 + unawaited(logic.syncPipAutoArm(widget.embedded ? widget.active : true)); if (!widget.embedded && !logic.isEpisodeLocked(logic.session.currentEpisode)) { _loadingVisible = true; @@ -217,6 +219,10 @@ class _PlayerPageState extends State void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); + // 沉浸流所在 tab 切换时同步自动小窗(激活挂、切走摘)。 + if (widget.embedded && oldWidget.active != widget.active) { + unawaited(logic.syncPipAutoArm(widget.active)); + } if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { _cancelFeedScrub(); } @@ -703,6 +709,13 @@ class _PlayerPageState extends State onRefresh: _refreshHomeFeed, child: pages, ); + // 进画中画时预加载当前集海报:还原过渡封面立即可显示,避免再黑一次。 + if (logic.pip) { + final poster = logic.trackedEpisode?.posterUrl?.trim(); + if (poster != null && poster.isNotEmpty) { + unawaited(precacheImage(NetworkImage(poster), context)); + } + } return Stack( fit: StackFit.expand, clipBehavior: Clip.none, @@ -711,7 +724,13 @@ class _PlayerPageState extends State pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, + resumeSignal: logic.pipResumeToken, ), + if (logic.pipRecovering) + Positioned.fill( + key: const Key('player-pip-recover-cover'), + child: _PipRecoverCover(episode: logic.trackedEpisode), + ), if (!widget.embedded && !_loadingRemoved) Center( child: IgnorePointer( @@ -804,6 +823,9 @@ class _PlayerPageState extends State void _handleCurrentVideoVisible(int? index) { if (!mounted || _loadingRemoved) return; + // 收起还原封面要放在 early-return 之前:恢复时集未变, + // _videoReadyIndex 已相等,只有这里能接到淡入完成的回调。 + logic.endPipRecover(); final currentIndex = logic.session.currentIndex; if (index == null || index != currentIndex || @@ -1447,3 +1469,43 @@ class _PlayerPageState extends State ); } } + +/// 画中画还原过渡封面:盖住回前台瞬间的黑纹理帧,视频出帧后由 +/// [PlayerLogic.endPipRecover] 收起。背景用主题渐变,海报预加载命中前也有过渡观感。 +class _PipRecoverCover extends StatelessWidget { + const _PipRecoverCover({required this.episode}); + + final Episode? episode; + + @override + Widget build(BuildContext context) { + final poster = episode?.posterUrl?.trim(); + final thumbnail = episode?.thumbnailsUrl?.trim(); + final url = poster != null && poster.isNotEmpty + ? poster + : (thumbnail != null && thumbnail.isNotEmpty ? thumbnail : null); + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + Color(0xFF101012), + ], + stops: [0, 0.22, 0.62], + ), + ), + child: url == null + ? const SizedBox.shrink() + : Image.network( + url, + key: const Key('player-pip-recover-cover-image'), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ); + } +} diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index d78f3d0..3337dcf 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -37,9 +37,14 @@ extension _PlayerViewActions on _PlayerPageState { onQualityChanged: (quality) => _switchQuality(context, l10n, quality), onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), - onPip: logic.pictureInPicture.isPlatformSupported + // iOS 不显示手动「画中画」入口(走下方「退出后自动小窗」开关)。 + onPip: logic.pictureInPicture.isPlatformSupported && !logic.iosDevice ? () => unawaited(logic.enterPip(context)) : null, + autoPipEnabled: logic.iosAutoPipEnabled, + onAutoPipChanged: logic.iosDevice + ? (value) => unawaited(logic.setIosAutoPip(value)) + : null, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 0c1a46b..64d1b19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,10 @@ dependencies: sdk: flutter path_provider: 2.1.5 html: ^0.15.6 + # 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗 + floating: ^6.0.0 + # 运行时权限:systemAlertWindow(「显示在其他应用上层」) + permission_handler: ^13.0.2 dev_dependencies: flutter_test: @@ -86,6 +90,7 @@ flutter: - assets/icon/splash_logo.png - assets/icon/splash_logo_android12.png - assets/icon/rail/ + - assets/auth/ # To add assets to your application, add an assets section, like this: # assets: