跳转到主要内容

crayonxiaoxin

Flutter Android 画中画实现

前言

在 Yam TV 的 Flutter 端中,用户希望在播放视频时能够切换到画中画模式——视频以悬浮小窗继续播放,同时可以浏览其他内容。本文分享 Android PiP 在 Flutter 中的完整实现方案。

问题背景

画中画(Picture-in-Picture,PiP)是 Android 8.0+(API 26)提供的系统级功能,允许 Activity 以悬浮小窗形式继续运行。在 Flutter 中实现 PiP 需要解决几个关键问题:

1. **仅显示视频区域**:Android PiP 捕获的是整个 Activity,而非某个 Widget。如果不做处理,AppBar、按钮等 UI 元素也会出现在小窗中

2. **双端通信**:进入/退出 PiP 时,Flutter 需要响应状态变化来隐藏/恢复 UI

3. **视频播放不中断**:进入 PiP 后视频需继续播放,退出后无缝恢复

4. **多页面适配**:普通播放页和短视频页都需要支持

实现方案

1. Android 端配置

首先在 AndroidManifest.xml 中声明 PiP 支持:

<activity
    android:supportsPictureInPicture="true"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|...">
</activity>

2. MethodChannel:Flutter ↔ Android 通信

建立 yamtv/pip 通道,Flutter 通过它调用 Android PiP API,Android 通过它回调 PiP 状态变化:

// MainActivity.kt
pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "yamtv/pip").apply {
    setMethodCallHandler { call, result ->
        when (call.method) {
            "isAvailable" -> {
                result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
                    packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
            }
            "enter" -> {
                val params = PictureInPictureParams.Builder()
                    .setAspectRatio(Rational(16, 9))
                    .build()
                enterPictureInPictureMode(params)
                result.success(true)
            }
        }
    }
}

// 监听 PiP 模式变化
override fun onPictureInPictureModeChanged(
    isInPictureInPictureMode: Boolean,
    newConfig: Configuration
) {
    super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
    pipChannel?.invokeMethod("onPipModeChanged", isInPictureInPictureMode)
}

3. Flutter 端封装

Dart 端封装为工具类,自动初始化通道回调:

class PictureInPicture {
  static PipModeCallback? _onPipModeChanged;

  static void setOnPipModeChanged(PipModeCallback? callback) {
    _ensureInitialized();
    _onPipModeChanged = callback;
  }

  static void _ensureInitialized() {
    if (_initialized) return;
    _channel.setMethodCallHandler((call) async {
      if (call.method == 'onPipModeChanged' && call.arguments is bool) {
        _onPipModeChanged?.call(call.arguments as bool);
      }
    });
  }

  static Future<void> enter() async {
    _ensureInitialized();
    await _channel.invokeMethod('enter');
  }
}

4. UI 适配:隐藏非视频元素

PiP 进入时最核心的问题是:Android 捕获的是整个 Activity 的内容。必须让 Flutter 页面「只剩播放器」。

initState 中注册 PiP 回调:

PictureInPicture.setOnPipModeChanged((isPip) {
  if (!mounted) return;
  setState(() => _pipActive = isPip);
});

build 方法中根据 _pipActive 控制 UI:

appBar: _pipActive ? null : glassAppBar(context, title: Text(title)),

对于短视频页,还需要将底部信息区推出屏幕:

final infoLift = active && chromeVisible
    ? kYamEmbeddedPlayerBottomChromeHeight
    : pipActive ? -200.0 : 0.0;

5. 播放器工具栏按钮

YamVideoPlayer 的底部控制栏添加 PiP 按钮,用户可直接在播放器中触发 PiP:

IconButton(
  tooltip: '画中画',
  icon: const Icon(Icons.picture_in_picture_alt, color: Colors.white),
  onPressed: () {
    unawaited(PictureInPicture.enter());
  },
),

6. 退出 PiP 时恢复 UI

退出 PiP 时,Android 回调 onPictureInPictureModeChangedisInPictureInPictureMode = false,Flutter 端收到回调后将 _pipActive 设回 false,所有隐藏的 UI 自动恢复。

对于短视频页,还需要记住 PiP 前的 chrome 状态:

if (isPip) {
  _chromeBeforePip = _chromeVisible;
  _chromeVisible = false;
} else {
  _chromeVisible = _chromeBeforePip;
}

总结

Android PiP 在 Flutter 中的实现要点:

1. **PiP 捕获的是整个 Activity**,必须主动隐藏非视频元素

2. 通过 MethodChannel 实现 Flutter ↔ Android 的双向状态同步

3. PictureInPictureParams.setAspectRatio 控制小窗比例

4. 短视频和普通播放页的适配逻辑相似,核心都是 _pipActive 状态驱动 UI 显隐

这套方案在 Yam TV 中已落地,支持普通播放和短视频两种场景的 PiP 切换。

讨论

还没有留言,来留下第一条评论吧!

留下足迹