Skip to content

feat #220

Description

@UcnacDx2

TV端播放器独立重构 - 详细实施计划

请注意,一定遵循:TV模式检测(暂时不检测,后期再开发,便于我在pc调试,仅新增标志位且标志位默认为tv版)

📋 现状分析

当前代码库使用:

  • 播放器控制器PlPlayerController 1
  • 播放器视图PLVideoPlayer 2
  • 平台检测工具Utils.isMobileUtils.isDesktop 3

🎯 实施计划(分6个阶段)

阶段 1:基础设施建设(预计2天)

1.1 添加TV模式检测(暂时不检测,后期再开发,便于我在pc调试,仅新增标志位且标志位默认为tv版)

lib/utils/utils.dart 中添加TV设备检测:

文件位置lib/utils/utils.dart
修改点3

需要添加的功能

// 在现有代码后添加
static bool? _isTvMode;
static Future<bool> get isTvMode async {
  if (!Platform.isAndroid) return false;
  return _isTvMode ??= await _checkTvMode();
}

static Future<bool> _checkTvMode() async {
  try {
    // 通过 MethodChannel 调用 Android 原生代码检测
    final result = await channel.invokeMethod<bool>('checkTvMode');
    return result ?? false;
  } catch (e) {
    return false;
  }
}

1.2 Android原生检测代码

文件位置android/app/src/main/kotlin/com/example/piliplus/MainActivity.kt

需要实现原生方法检测TV模式:

  • 使用 PackageManager.FEATURE_LEANBACK
  • 或检测 UiModeManager.UI_MODE_TYPE_TELEVISION

阶段 2:创建TV专用控制器(预计3天)

2.1 创建TV播放器控制器

新建文件lib/plugin/pl_player/tv_controller.dart

继承关系:继承或组合 PlPlayerController 4

核心功能模块

  1. 焦点状态管理
class TvPlayerController extends PlPlayerController {
  // 焦点节点定义
  final FocusNode focusNodeA = FocusNode(); // 顶部区域
  final FocusNode focusNodeB = FocusNode(); // 进度条区域
  final FocusNode focusNodeC = FocusNode(); // 底部区域
  
  // 当前激活的焦点区域
  final Rx<FocusArea> currentFocusArea = FocusArea.none.obs;
  
  // 遮罩显示状态(继承自父类 showControls)
  // 已存在于 PlPlayerController
}
  1. 按键事件监听器
    复用现有的键盘控制逻辑框架 5

  2. Layer 1 盲操逻辑

// 在遮罩隐藏时的按键处理
KeyEventResult handleKeyEventWhenHidden(KeyEvent event) {
  if (event is! KeyDownEvent) return KeyEventResult.ignored;
  
  switch (event.logicalKey) {
    case LogicalKeyboardKey.select: // OK/Enter
      showControlsAndFocus(FocusArea.progress);
      return KeyEventResult.handled;
    case LogicalKeyboardKey.arrowUp: // DPAD_UP
      showControlsAndFocus(FocusArea.top);
      return KeyEventResult.handled;
    case LogicalKeyboardKey.arrowDown: // DPAD_DOWN
      showControlsAndFocus(FocusArea.bottom);
      return KeyEventResult.handled;
    // ... 其他按键处理
  }
}

2.2 提取公共工具类

新建文件lib/plugin/pl_player/utils/player_utils.dart

从现有控制器中提取:

  • 快进快退时间计算 6
  • 播放速度管理 7
  • 时间格式化工具(已在 DurationUtils 中)

阶段 3:创建TV专用UI组件(预计4天)

3.1 TV播放器主视图

新建文件lib/plugin/pl_player/tv_view.dart

基于现有 PLVideoPlayer 创建TV版本 8

核心修改点

  1. 移除触摸手势监听(垂直滑动、水平滑动等)
  2. 添加焦点管理系统
  3. 实现三段式布局

3.2 区域A:顶部控制栏

新建文件lib/plugin/pl_player/widgets/tv_top_control.dart

组件结构

class TvTopControl extends StatelessWidget {
  // 包含:返回按钮、标题、播放/暂停、下一集
  // 所有按钮都是可聚焦的 Focusable Widget
}

复用现有的 HeaderControl 部分逻辑 9

3.3 区域B:进度条控制

新建文件lib/plugin/pl_player/widgets/tv_progress_control.dart

关键实现

class TvProgressBar extends StatefulWidget {
  // 核心:整个进度条作为一个可聚焦组件
  // 重写按键处理逻辑
  
  @override
  Widget build(BuildContext context) {
    return Focus(
      focusNode: focusNode,
      onKeyEvent: (node, event) {
        if (event is KeyDownEvent) {
          switch (event.logicalKey) {
            case LogicalKeyboardKey.select: // OK键
              controller.togglePlayPause(); // 播放/暂停
              return KeyEventResult.handled;
            case LogicalKeyboardKey.arrowLeft:
              controller.seekBackward(); // 快退
              return KeyEventResult.handled;
            case LogicalKeyboardKey.arrowRight:
              controller.seekForward(); // 快进
              return KeyEventResult.handled;
            // 上下键移动焦点由框架自动处理
          }
        }
        return KeyEventResult.ignored;
      },
      child: // 进度条UI
    );
  }
}

复用现有进度条逻辑 10

3.4 区域C:底部功能区

新建文件lib/plugin/pl_player/widgets/tv_bottom_control.dart

基于现有的底部控制逻辑改造 11

改造要点

  • 将所有 PopupMenuButton 改为可聚焦的按钮
  • 使用 Flutter 的 Focus 系统管理焦点流转
  • 保留画质、倍速、字幕等功能按钮

阶段 4:入口分流逻辑(预计1天)

4.1 修改播放器入口

修改文件lib/pages/video/view.dart

在播放器初始化处添加设备类型判断,选择不同的控制器和视图:

// 伪代码示例
Future<void> initPlayer() async {
  final isTv = await Utils.isTvMode;
  
  if (isTv) {
    // TV模式:使用TV专用控制器和视图
    plPlayerController = TvPlayerController.getInstance();
    playerWidget = TvVideoPlayer(/* TV专用参数 */);
  } else {
    // Mobile/Desktop模式:使用原有逻辑
    plPlayerController = PlPlayerController.getInstance();
    playerWidget = PLVideoPlayer(/* 原有参数 */);
  }
}

修改位置参考:视频播放器初始化逻辑附近


阶段 5:焦点导航与状态机(预计3天)

5.1 实现焦点导航系统

文件lib/plugin/pl_player/tv_controller.dart

// 焦点切换逻辑
void showControlsAndFocus(FocusArea area) {
  showControls.value = true;
  currentFocusArea.value = area;
  
  switch (area) {
    case FocusArea.top:
      focusNodeA.requestFocus();
      break;
    case FocusArea.progress:
      focusNodeB.requestFocus();
      break;
    case FocusArea.bottom:
      focusNodeC.requestFocus();
      break;
  }
  
  // 启动自动隐藏计时器
  startAutoHideTimer();
}

5.2 自动隐藏机制

复用现有的控制条自动隐藏逻辑 12

增强点

  • 检测是否有二级菜单打开
  • 如有菜单打开则暂停自动隐藏计时器

5.3 返回键处理

KeyEventResult handleBackKey() {
  if (showControls.value) {
    // 隐藏遮罩
    showControls.value = false;
    return KeyEventResult.handled;
  } else {
    // 退出播放器(交给上层处理)
    return KeyEventResult.ignored;
  }
}

阶段 6:测试与优化(预计3天)

6.1 功能测试清单

隔离性测试

  • TV设备加载TV控制器
  • Mobile设备加载原有控制器
  • Desktop设备不受影响

盲操测试

  • OK键唤起到进度条
  • 上方向键唤起到顶部区域
  • 下方向键唤起到底部区域

进度条交互测试

  • 焦点在进度条时OK键切换播放/暂停
  • 左右键快进快退且焦点不丢失
  • 上下键移动到其他区域

导航测试

  • A ↔ B ↔ C 三区域流畅切换
  • 返回键正确处理遮罩显示/隐藏
  • 自动隐藏在无操作5秒后触发

6.2 性能优化

  • 焦点变化时避免不必要的重绘
  • 使用 RepaintBoundary 隔离重绘区域
  • 优化按键事件处理性能

📁 文件结构总览

lib/
├── utils/
│   └── utils.dart                      # [修改] 添加 isTvMode
├── plugin/
│   └── pl_player/
│       ├── controller.dart             # [保持] 原有控制器
│       ├── tv_controller.dart          # [新增] TV控制器
│       ├── view.dart                   # [保持] 原有视图
│       ├── tv_view.dart               # [新增] TV视图
│       ├── utils/
│       │   └── player_utils.dart       # [新增] 公共工具
│       └── widgets/
│           ├── tv_top_control.dart     # [新增] 顶部控制栏
│           ├── tv_progress_control.dart # [新增] 进度条控制
│           └── tv_bottom_control.dart  # [新增] 底部功能区
└── pages/
    └── video/
        └── view.dart                   # [修改] 添加入口分流

🔑 关键技术要点

1. 焦点管理系统

使用 Flutter 原生的 FocusFocusNode 系统,配合 focusNode.requestFocus() 实现焦点跳转。

2. 按键事件处理

通过 Focus widget 的 onKeyEvent 回调处理遥控器按键:

  • LogicalKeyboardKey.select → OK键
  • LogicalKeyboardKey.arrowUp/Down/Left/Right → 方向键
  • LogicalKeyboardKey.goBack → 返回键

3. 代码复用策略

  • 完全复用:时间格式化、弹幕解析、播放器核心逻辑
  • 部分复用:底部控制按钮功能(改造UI为可聚焦)
  • 完全重写:手势识别、焦点管理、按键监听

⚠️ 注意事项

1. 不影响现有功能

所有修改都通过 if (isTvMode) 分支隔离,原有 Mobile/Desktop 逻辑保持不变。

2. AndroidManifest 配置

虽然当前 manifest 已包含 leanback 相关配置 13 ,但需要确认:

  • android:banner 图标(TV启动器需要)
  • android.software.leanback feature 声明

3. 测试设备

建议使用以下设备测试:

  • 真实Android TV设备(如小米盒子、NVIDIA Shield)
  • Android TV模拟器
  • 带遥控器的电视盒子

📅 时间估算

阶段 工作量 关键里程碑
阶段1:基础设施 2天 TV模式检测可用
阶段2:TV控制器 3天 控制器框架完成
阶段3:UI组件 4天 三段式布局完成
阶段4:入口分流 1天 分流逻辑生效
阶段5:焦点导航 3天 完整交互流程
阶段6:测试优化 3天 通过验收标准
总计 16天 完整功能上线

Notes

  1. 平台检测依赖:当前代码已有良好的平台检测基础 3 ,可在此基础上扩展TV检测。

  2. 播放器架构稳定:现有 PlPlayerController 使用了 media_kit 作为底层 14 ,无需修改核心播放逻辑。

  3. UI组件可复用性高:底部控制栏的功能按钮定义清晰 15 ,改造为TV可聚焦按钮工作量较小。

  4. 建议使用渐进式开发:先完成基础框架和单个区域(如进度条),验证焦点管理系统可行后再扩展其他区域。

  5. 考虑未来扩展:预留接口支持TV端特有功能(如语音搜索、推荐内容等)。

Citations

File: lib/plugin/pl_player/controller.dart (L56-57)

import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';

File: lib/plugin/pl_player/controller.dart (L62-586)

class PlPlayerController {
  Player? _videoPlayerController;
  VideoController? _videoController;

  // 添加一个私有静态变量来保存实例
  static PlPlayerController? _instance;

  // 流事件  监听播放状态变化
  // StreamSubscription? _playerEventSubs;

  /// [playerStatus] has a [status] observable
  final playerStatus = PlPlayerStatus(PlayerStatus.playing);

  ///
  final PlPlayerDataStatus dataStatus = PlPlayerDataStatus();

  // bool controlsEnabled = false;

  /// 响应数据
  /// 带有Seconds的变量只在秒数更新时更新,以避免频繁触发重绘
  // 播放位置
  final Rx<Duration> position = Rx(Duration.zero);
  final RxInt positionSeconds = 0.obs;

  /// 进度条位置
  final Rx<Duration> sliderPosition = Rx(Duration.zero);
  final RxInt sliderPositionSeconds = 0.obs;
  // 展示使用
  final Rx<Duration> sliderTempPosition = Rx(Duration.zero);

  /// 视频时长
  final Rx<Duration> duration = Rx(Duration.zero);
  final Rx<Duration> durationSeconds = Duration.zero.obs;

  /// 视频缓冲
  final Rx<Duration> buffered = Rx(Duration.zero);
  final RxInt bufferedSeconds = 0.obs;

  int _playerCount = 0;

  late double lastPlaybackSpeed = 1.0;
  final RxDouble _playbackSpeed = Pref.playSpeedDefault.obs;
  late final RxDouble _longPressSpeed = Pref.longPressSpeedDefault.obs;

  /// 音量控制条
  final RxDouble volume = RxDouble(
    Utils.isDesktop ? Pref.desktopVolume : 1.0,
  );
  final setSystemBrightness = Pref.setSystemBrightness;

  /// 亮度控制条
  final RxDouble brightness = (-1.0).obs;

  /// 是否展示控制条
  final RxBool showControls = false.obs;

  /// 音量控制条展示/隐藏
  final RxBool showVolumeStatus = false.obs;

  /// 亮度控制条展示/隐藏
  final RxBool showBrightnessStatus = false.obs;

  /// 是否长按倍速
  final RxBool longPressStatus = false.obs;

  /// 屏幕锁 为true时,关闭控制栏
  final RxBool controlsLock = false.obs;

  /// 全屏状态
  final RxBool isFullScreen = false.obs;
  // 默认投稿视频格式
  bool isLive = false;

  bool _isVertical = false;

  /// 视频比例
  final Rx<VideoFitType> videoFit = Rx(VideoFitType.contain);

  StreamSubscription<DataStatus>? _dataListenerForVideoFit;
  StreamSubscription<DataStatus>? _dataListenerForEnterFullScreen;

  void _stopListenerForVideoFit() {
    _dataListenerForVideoFit?.cancel();
    _dataListenerForVideoFit = null;
  }

  void _stopListenerForEnterFullScreen() {
    _dataListenerForEnterFullScreen?.cancel();
    _dataListenerForEnterFullScreen = null;
  }

  /// 后台播放
  late final RxBool continuePlayInBackground =
      Pref.continuePlayInBackground.obs;

  ///
  final RxBool isSliderMoving = false.obs;

  /// 是否循环
  PlaylistMode _looping = PlaylistMode.none;
  bool _autoPlay = false;

  // 记录历史记录
  int? _aid;
  String? _bvid;
  int? cid;
  int? _epid;
  int? _seasonId;
  int? _pgcType;
  VideoType _videoType = VideoType.ugc;
  int _heartDuration = 0;
  int? width;
  int? height;

  late final tryLook = !Accounts.get(AccountType.video).isLogin && Pref.p1080;

  late DataSource dataSource;

  Timer? _timer;
  Timer? _timerForSeek;
  Timer? _timerForShowingVolume;

  Box setting = GStorage.setting;

  // final Durations durations;

  String get bvid => _bvid!;

  /// 视频播放速度
  double get playbackSpeed => _playbackSpeed.value;

  // 长按倍速
  double get longPressSpeed => _longPressSpeed.value;

  /// [videoPlayerController] instance of Player
  Player? get videoPlayerController => _videoPlayerController;

  /// [videoController] instance of Player
  VideoController? get videoController => _videoController;

  bool isMuted = false;

  /// 听视频
  late final RxBool onlyPlayAudio = false.obs;

  /// 镜像
  late final RxBool flipX = false.obs;

  late final RxBool flipY = false.obs;

  final RxBool isBuffering = true.obs;

  /// 全屏方向
  bool get isVertical => _isVertical;

  /// 弹幕开关
  late final RxBool _enableShowDanmaku = Pref.enableShowDanmaku.obs;
  late final RxBool _enableShowLiveDanmaku = Pref.enableShowLiveDanmaku.obs;
  RxBool get enableShowDanmaku =>
      isLive ? _enableShowLiveDanmaku : _enableShowDanmaku;

  late final bool autoPiP = Pref.autoPiP;
  bool get isPipMode =>
      (Platform.isAndroid && Floating().isPipMode) ||
      (Utils.isDesktop && isDesktopPip);
  late bool isDesktopPip = false;
  late Rect _lastWindowBounds;

  Offset initialFocalPoint = Offset.zero;

  Future<void> exitDesktopPip() async {
    isDesktopPip = false;
    await Future.wait([
      windowManager.setTitleBarStyle(TitleBarStyle.normal),
      windowManager.setMinimumSize(const Size(400, 700)),
      windowManager.setBounds(_lastWindowBounds),
      windowManager.setAlwaysOnTop(false),
      windowManager.setAspectRatio(0),
      setting.putAll({
        SettingBoxKey.windowSize: [
          _lastWindowBounds.width,
          _lastWindowBounds.height,
        ],
        SettingBoxKey.windowPosition: [
          _lastWindowBounds.left,
          _lastWindowBounds.top,
        ],
      }),
    ]);
  }

  Future<void> enterDesktopPip() async {
    if (isFullScreen.value) return;

    isDesktopPip = true;

    _lastWindowBounds = await windowManager.getBounds();

    windowManager.setTitleBarStyle(TitleBarStyle.hidden);

    late final Size size;
    final state = videoController!.player.state;
    final width = state.width ?? this.width ?? 16;
    final height = state.height ?? this.height ?? 9;
    if (height > width) {
      size = Size(280.0, 280.0 * height / width);
    } else {
      size = Size(280.0 * width / height, 280.0);
    }

    await windowManager.setMinimumSize(size);
    windowManager
      ..setSize(size)
      ..setAlwaysOnTop(true)
      ..setAspectRatio(width / height);
  }

  void toggleDesktopPip() {
    if (isDesktopPip) {
      exitDesktopPip();
    } else {
      enterDesktopPip();
    }
  }

  late bool _shouldSetPip = false;

  bool get _isCurrVideoPage {
    final currentRoute = Get.currentRoute;
    return currentRoute.startsWith('/video') ||
        currentRoute.startsWith('/liveRoom');
  }

  bool get _isPreviousVideoPage {
    final previousRoute = Get.previousRoute;
    return previousRoute.startsWith('/video') ||
        previousRoute.startsWith('/liveRoom');
  }

  void enterPip({bool isAuto = false}) {
    if (videoController != null) {
      final state = videoController!.player.state;
      PageUtils.enterPip(
        isAuto: isAuto,
        width: state.width ?? width,
        height: state.height ?? height,
      );
    }
  }

  void disableAutoEnterPipIfNeeded() {
    if (!_isPreviousVideoPage) {
      disableAutoEnterPip();
    }
  }

  void disableAutoEnterPip() {
    if (_shouldSetPip) {
      Utils.channel.invokeMethod('setPipAutoEnterEnabled', {
        'autoEnable': false,
      });
    }
  }

  /// 弹幕权重
  late final enableTapDm = Utils.isMobile && Pref.enableTapDm;
  late int danmakuWeight = Pref.danmakuWeight;
  late RuleFilter filters = Pref.danmakuFilterRule;
  // 关联弹幕控制器
  DanmakuController<DanmakuExtra>? danmakuController;
  bool showDanmaku = true;
  Set<int> dmState = <int>{};
  late final mergeDanmaku = Pref.mergeDanmaku;
  late final String midHash = Crc32Xz()
      .convert(utf8.encode(Accounts.main.mid.toString()))
      .toRadixString(16);
  // 弹幕相关配置
  late Set<int> blockTypes = Pref.danmakuBlockType;
  late bool blockColorful = blockTypes.contains(6);
  late double showArea = Pref.danmakuShowArea;
  late RxDouble danmakuOpacity = Pref.danmakuOpacity.obs;
  late double danmakuFontScale = Pref.danmakuFontScale;
  late double danmakuFontScaleFS = Pref.danmakuFontScaleFS;
  late double danmakuStrokeWidth = Pref.strokeWidth;
  late int danmakuFontWeight = Pref.fontWeight;
  late bool massiveMode = Pref.danmakuMassiveMode;
  late double danmakuDuration = Pref.danmakuDuration;
  late double danmakuStaticDuration = Pref.danmakuStaticDuration;
  late List<double> speedList = Pref.speedList;
  late bool enableAutoLongPressSpeed = Pref.enableAutoLongPressSpeed;
  late final showControlDuration = Pref.enableLongShowControl
      ? const Duration(seconds: 30)
      : const Duration(seconds: 3);
  late double subtitleFontScale = Pref.subtitleFontScale;
  late double subtitleFontScaleFS = Pref.subtitleFontScaleFS;
  late double danmakuLineHeight = Pref.danmakuLineHeight;
  late int subtitlePaddingH = Pref.subtitlePaddingH;
  late int subtitlePaddingB = Pref.subtitlePaddingB;
  late double subtitleBgOpaticy = Pref.subtitleBgOpaticy;
  final bool showVipDanmaku = Pref.showVipDanmaku; // loop unswitching
  late double subtitleStrokeWidth = Pref.subtitleStrokeWidth;
  late int subtitleFontWeight = Pref.subtitleFontWeight;

  late final pgcSkipType = Pref.pgcSkipType;
  late final enablePgcSkip = Pref.pgcSkipType != SkipType.disable;
  // sponsor block
  late final bool enableSponsorBlock = Pref.enableSponsorBlock;
  late final bool enableBlock = enableSponsorBlock || enablePgcSkip;
  late final double blockLimit = Pref.blockLimit;
  late final blockSettings = Pref.blockSettings;
  late final List<Color> blockColor = Pref.blockColor;
  late final Set<String> enableList = blockSettings
      .where((item) => item.second != SkipType.disable)
      .map((item) => item.first.name)
      .toSet();

  // settings
  late final showFSActionItem = Pref.showFSActionItem;
  late final enableShrinkVideoSize = Pref.enableShrinkVideoSize;
  late final darkVideoPage = Pref.darkVideoPage;
  late final enableSlideVolumeBrightness = Pref.enableSlideVolumeBrightness;
  late final enableSlideFS = Pref.enableSlideFS;
  late final enableDragSubtitle = Pref.enableDragSubtitle;
  late final fastForBackwardDuration = Duration(
    seconds: Pref.fastForBackwardDuration,
  );

  late final horizontalSeasonPanel = Pref.horizontalSeasonPanel;
  late final preInitPlayer = Pref.preInitPlayer;
  late final showRelatedVideo = Pref.showRelatedVideo;
  late final showVideoReply = Pref.showVideoReply;
  late final showBangumiReply = Pref.showBangumiReply;
  late final reverseFromFirst = Pref.reverseFromFirst;
  late final horizontalPreview = Pref.horizontalPreview;
  late final showDmChart = Pref.showDmChart;
  late final showViewPoints = Pref.showViewPoints;
  late final showFsScreenshotBtn = Pref.showFsScreenshotBtn;
  late final showFsLockBtn = Pref.showFsLockBtn;
  late final keyboardControl = Pref.keyboardControl;

  late final bool autoExitFullscreen = Pref.autoExitFullscreen;
  late final bool autoPlayEnable = Pref.autoPlayEnable;
  late final bool enableVerticalExpand = Pref.enableVerticalExpand;
  late final bool pipNoDanmaku = Pref.pipNoDanmaku;

  late final bool tempPlayerConf = Pref.tempPlayerConf;

  late int? cacheVideoQa = Utils.isMobile ? null : Pref.defaultVideoQa;
  late int cacheAudioQa = Pref.defaultAudioQa;
  bool enableHeart = true;

  late final bool enableHA = Pref.enableHA;
  late final String hwdec = Pref.hardwareDecoding;

  late final progressType =
      BtmProgressBehavior.values[Pref.btmProgressBehavior];
  late final enableQuickDouble = Pref.enableQuickDouble;
  late final fullScreenGestureReverse = Pref.fullScreenGestureReverse;

  late final isRelative = Pref.useRelativeSlide;
  late final offset = isRelative
      ? Pref.sliderDuration / 100
      : Pref.sliderDuration * 1000;

  num get sliderScale =>
      isRelative ? duration.value.inMilliseconds * offset : offset;

  // 播放顺序相关
  late PlayRepeat playRepeat = PlayRepeat.values[Pref.playRepeat];

  TextStyle get subTitleStyle => TextStyle(
    height: 1.5,
    fontSize:
        16 * (isFullScreen.value ? subtitleFontScaleFS : subtitleFontScale),
    letterSpacing: 0.1,
    wordSpacing: 0.1,
    color: Colors.white,
    fontWeight: FontWeight.values[subtitleFontWeight],
    backgroundColor: subtitleBgOpaticy == 0
        ? null
        : Colors.black.withValues(alpha: subtitleBgOpaticy),
  );

  late final Rx<SubtitleViewConfiguration> subtitleConfig = _getSubConfig.obs;

  SubtitleViewConfiguration get _getSubConfig {
    final subTitleStyle = this.subTitleStyle;
    return SubtitleViewConfiguration(
      style: subTitleStyle,
      strokeStyle: subtitleBgOpaticy == 0
          ? subTitleStyle.copyWith(
              color: null,
              background: null,
              backgroundColor: null,
              foreground: Paint()
                ..color = Colors.black
                ..style = PaintingStyle.stroke
                ..strokeWidth = subtitleStrokeWidth,
            )
          : null,
      padding: EdgeInsets.only(
        left: subtitlePaddingH.toDouble(),
        right: subtitlePaddingH.toDouble(),
        bottom: subtitlePaddingB.toDouble(),
      ),
      textScaleFactor: 1,
    );
  }

  void updateSubtitleStyle() {
    subtitleConfig.value = _getSubConfig;
  }

  void onUpdatePadding(EdgeInsets padding) {
    subtitlePaddingB = padding.bottom.round().clamp(0, 200);
    putSubtitleSettings();
  }

  void updateSliderPositionSecond() {
    int newSecond = sliderPosition.value.inSeconds;
    if (sliderPositionSeconds.value != newSecond) {
      sliderPositionSeconds.value = newSecond;
    }
  }

  void updatePositionSecond() {
    int newSecond = position.value.inSeconds;
    if (positionSeconds.value != newSecond) {
      positionSeconds.value = newSecond;
    }
  }

  void updateDurationSecond() {
    if (durationSeconds.value != duration.value) {
      durationSeconds.value = duration.value;
    }
  }

  void updateBufferedSecond() {
    int newSecond = buffered.value.inSeconds;
    if (bufferedSeconds.value != newSecond) {
      bufferedSeconds.value = newSecond;
    }
  }

  static PlPlayerController? get instance => _instance;

  static bool instanceExists() {
    return _instance != null;
  }

  static void setPlayCallBack(Function? playCallBack) {
    _playCallBack = playCallBack;
  }

  static Function? _playCallBack;

  static void playIfExists({bool repeat = false, bool hideControls = true}) {
    // await _instance?.play(repeat: repeat, hideControls: hideControls);
    _playCallBack?.call();
  }

  // try to get PlayerStatus
  static PlayerStatus? getPlayerStatusIfExists() {
    return _instance?.playerStatus.value;
  }

  static Future<void> pauseIfExists({
    bool notify = true,
    bool isInterrupt = false,
  }) async {
    if (_instance?.playerStatus.value == PlayerStatus.playing) {
      await _instance?.pause(notify: notify, isInterrupt: isInterrupt);
    }
  }

  static Future<void> seekToIfExists(
    Duration position, {
    bool isSeek = true,
  }) async {
    await _instance?.seekTo(position, isSeek: isSeek);
  }

  static double? getVolumeIfExists() {
    return _instance?.volume.value;
  }

  static Future<void> setVolumeIfExists(double volumeNew) async {
    await _instance?.setVolume(volumeNew);
  }

  Box video = GStorage.video;

  // 添加一个私有构造函数
  PlPlayerController._() {
    if (!Accounts.heartbeat.isLogin || Pref.historyPause) {
      enableHeart = false;
    }

    if (Platform.isAndroid && autoPiP) {
      Utils.sdkInt.then((sdkInt) {
        if (sdkInt < 31) {
          Utils.channel.setMethodCallHandler((call) async {
            if (call.method == 'onUserLeaveHint') {
              if (playerStatus.playing && _isCurrVideoPage) {
                enterPip();
              }
            }
          });
        } else {
          _shouldSetPip = true;
        }
      });
    }
  }

  // 获取实例 传参
  static PlPlayerController getInstance({bool isLive = false}) {
    // 如果实例尚未创建,则创建一个新实例
    _instance ??= PlPlayerController._();
    _instance!
      ..isLive = isLive
      .._playerCount += 1;
    return _instance!;
  }

File: lib/plugin/pl_player/view.dart (L77-116)

class PLVideoPlayer extends StatefulWidget {
  const PLVideoPlayer({
    required this.maxWidth,
    required this.maxHeight,
    required this.plPlayerController,
    this.videoDetailController,
    this.introController,
    required this.headerControl,
    this.bottomControl,
    this.danmuWidget,
    this.showEpisodes,
    this.showViewPoints,
    this.fill = Colors.black,
    this.alignment = Alignment.center,
    super.key,
  });

  final double maxWidth;
  final double maxHeight;
  final PlPlayerController plPlayerController;
  final VideoDetailController? videoDetailController;
  final CommonIntroController? introController;
  final Widget headerControl;
  final Widget? bottomControl;
  final Widget? danmuWidget;
  final void Function([
    int?,
    UgcSeason?,
    List<ugc.BaseEpisodeItem>?,
    String?,
    int?,
    int?,
  ])?
  showEpisodes;
  final VoidCallback? showViewPoints;
  final Color fill;
  final Alignment alignment;

  @override
  State<PLVideoPlayer> createState() => _PLVideoPlayerState();

File: lib/plugin/pl_player/view.dart (L322-912)

  // 动态构建底部控制条
  Widget buildBottomControl(
    VideoDetailController videoDetailController,
    bool isLandscape,
  ) {
    final videoDetail = introController.videoDetail.value;
    final isSeason = videoDetail.ugcSeason != null;
    final isPart = videoDetail.pages != null && videoDetail.pages!.length > 1;
    final isPgc = !videoDetailController.isUgc;
    final isPlayAll = videoDetailController.isPlayAll;
    final anySeason = isSeason || isPart || isPgc || isPlayAll;
    final isFullScreen = this.isFullScreen;
    final double widgetWidth = isLandscape && isFullScreen ? 42 : 35;

    Widget progressWidget(
      BottomControlType bottomControl,
    ) => switch (bottomControl) {
      /// 播放暂停
      BottomControlType.playOrPause => PlayOrPauseButton(
        plPlayerController: plPlayerController,
      ),

      /// 上一集
      BottomControlType.pre => ComBtn(
        width: widgetWidth,
        height: 30,
        tooltip: '上一集',
        icon: const Icon(
          Icons.skip_previous,
          size: 22,
          color: Colors.white,
        ),
        onTap: () {
          if (!introController.prevPlay()) {
            SmartDialog.showToast('已经是第一集了');
          }
        },
      ),

      /// 下一集
      BottomControlType.next => ComBtn(
        width: widgetWidth,
        height: 30,
        tooltip: '下一集',
        icon: const Icon(
          Icons.skip_next,
          size: 22,
          color: Colors.white,
        ),
        onTap: () {
          if (!introController.nextPlay()) {
            SmartDialog.showToast('已经是最后一集了');
          }
        },
      ),

      /// 时间进度
      BottomControlType.time => Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          // 播放时间
          Obx(
            () => Text(
              DurationUtils.formatDuration(
                plPlayerController.positionSeconds.value,
              ),
              style: const TextStyle(
                color: Colors.white,
                fontSize: 10,
                height: 1.4,
                fontFeatures: [FontFeature.tabularFigures()],
              ),
            ),
          ),
          Obx(
            () => Text(
              DurationUtils.formatDuration(
                plPlayerController.durationSeconds.value.inSeconds,
              ),
              style: const TextStyle(
                color: Color(0xFFD0D0D0),
                fontSize: 10,
                height: 1.4,
                fontFeatures: [FontFeature.tabularFigures()],
              ),
            ),
          ),
        ],
      ),

      /// 高能进度条
      BottomControlType.dmChart => Obx(
        () {
          final list = videoDetailController.dmTrend.value?.dataOrNull;
          if (list != null && list.isNotEmpty) {
            return ComBtn(
              width: widgetWidth,
              height: 30,
              tooltip: '高能进度条',
              icon: videoDetailController.showDmTreandChart.value
                  ? const Icon(
                      Icons.show_chart,
                      size: 22,
                      color: Colors.white,
                    )
                  : const Stack(
                      clipBehavior: Clip.none,
                      alignment: Alignment.center,
                      children: [
                        Icon(
                          Icons.show_chart,
                          size: 22,
                          color: Colors.white,
                        ),
                        Icon(
                          Icons.hide_source,
                          size: 22,
                          color: Colors.white,
                        ),
                      ],
                    ),
              onTap: () => videoDetailController.showDmTreandChart.value =
                  !videoDetailController.showDmTreandChart.value,
            );
          }
          return const SizedBox.shrink();
        },
      ),

      /// 超分辨率
      BottomControlType.superResolution => Obx(
        () => PopupMenuButton<SuperResolutionType>(
          tooltip: '超分辨率',
          requestFocus: false,
          initialValue: plPlayerController.superResolutionType.value,
          color: Colors.black.withValues(alpha: 0.8),
          itemBuilder: (context) {
            return SuperResolutionType.values
                .map(
                  (type) => PopupMenuItem<SuperResolutionType>(
                    height: 35,
                    padding: const EdgeInsets.only(left: 30),
                    value: type,
                    onTap: () => plPlayerController.setShader(type),
                    child: Text(
                      type.title,
                      style: const TextStyle(color: Colors.white, fontSize: 13),
                    ),
                  ),
                )
                .toList();
          },
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Text(
              plPlayerController.superResolutionType.value.title,
              style: const TextStyle(color: Colors.white, fontSize: 13),
            ),
          ),
        ),
      ),

      /// 分段信息
      BottomControlType.viewPoints => Obx(
        () => videoDetailController.viewPointList.isEmpty
            ? const SizedBox.shrink()
            : ComBtn(
                width: widgetWidth,
                height: 30,
                tooltip: '分段信息',
                icon: Transform.rotate(
                  angle: math.pi / 2,
                  child: const Icon(
                    MdiIcons.viewHeadline,
                    size: 22,
                    color: Colors.white,
                  ),
                ),
                onTap: widget.showViewPoints,
                onLongPress: () {
                  Feedback.forLongPress(context);
                  videoDetailController.showVP.value =
                      !videoDetailController.showVP.value;
                },
                onSecondaryTap: Utils.isMobile
                    ? null
                    : () => videoDetailController.showVP.value =
                          !videoDetailController.showVP.value,
              ),
      ),

      /// 选集
      BottomControlType.episode => ComBtn(
        width: widgetWidth,
        height: 30,
        tooltip: '选集',
        icon: const Icon(
          Icons.list,
          size: 22,
          color: Colors.white,
        ),
        onTap: () {
          if (videoDetailController.isFileSource) {
            // TODO
            return;
          }
          // part -> playAll -> season(pgc)
          if (isPlayAll && !isPart) {
            widget.showEpisodes?.call();
            return;
          }
          int? index;
          int currentCid = plPlayerController.cid!;
          String bvid = plPlayerController.bvid;
          List<ugc.BaseEpisodeItem> episodes = [];
          if (isSeason) {
            final List<SectionItem> sections = videoDetail.ugcSeason!.sections!;
            for (int i = 0; i < sections.length; i++) {
              final List<EpisodeItem> episodesList = sections[i].episodes!;
              for (int j = 0; j < episodesList.length; j++) {
                if (episodesList[j].cid == plPlayerController.cid) {
                  index = i;
                  episodes = episodesList;
                  break;
                }
              }
            }
          } else if (isPart) {
            episodes = videoDetail.pages!;
          } else if (isPgc) {
            episodes =
                (introController as PgcIntroController).pgcItem.episodes!;
          }
          widget.showEpisodes?.call(
            index,
            isSeason ? videoDetail.ugcSeason! : null,
            isSeason ? null : episodes,
            bvid,
            IdUtils.bv2av(bvid),
            isSeason && isPart
                ? videoDetailController.seasonCid ?? currentCid
                : currentCid,
          );
        },
      ),

      /// 画面比例
      BottomControlType.fit => Obx(
        () => PopupMenuButton<VideoFitType>(
          tooltip: '画面比例',
          requestFocus: false,
          initialValue: plPlayerController.videoFit.value,
          color: Colors.black.withValues(alpha: 0.8),
          itemBuilder: (context) {
            return VideoFitType.values
                .map(
                  (boxFit) => PopupMenuItem<VideoFitType>(
                    height: 35,
                    padding: const EdgeInsets.only(left: 30),
                    value: boxFit,
                    onTap: () => plPlayerController.toggleVideoFit(boxFit),
                    child: Text(
                      boxFit.desc,
                      style: const TextStyle(color: Colors.white, fontSize: 13),
                    ),
                  ),
                )
                .toList();
          },
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Text(
              plPlayerController.videoFit.value.desc,
              style: const TextStyle(color: Colors.white, fontSize: 13),
            ),
          ),
        ),
      ),

      BottomControlType.aiTranslate => Obx(
        () {
          final list = videoDetailController.languages.value;
          if (list != null && list.isNotEmpty) {
            return PopupMenuButton<String>(
              tooltip: '翻译',
              requestFocus: false,
              initialValue: videoDetailController.currLang.value,
              color: Colors.black.withValues(alpha: 0.8),
              itemBuilder: (context) {
                return [
                  PopupMenuItem<String>(
                    height: 35,
                    value: '',
                    onTap: () => videoDetailController.setLanguage(''),
                    child: const Text(
                      "关闭翻译",
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 13,
                      ),
                    ),
                  ),
                  ...list.map((e) {
                    return PopupMenuItem<String>(
                      height: 35,
                      value: e.lang,
                      onTap: () => videoDetailController.setLanguage(e.lang!),
                      child: Text(
                        e.title!,
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 13,
                        ),
                      ),
                    );
                  }),
                ];
              },
              child: SizedBox(
                width: widgetWidth,
                height: 30,
                child: const Icon(
                  Icons.translate,
                  size: 18,
                  color: Colors.white,
                ),
              ),
            );
          }
          return const SizedBox.shrink();
        },
      ),

      /// 字幕
      BottomControlType.subtitle => Obx(
        () => videoDetailController.subtitles.isEmpty == true
            ? const SizedBox.shrink()
            : PopupMenuButton<int>(
                tooltip: '字幕',
                requestFocus: false,
                initialValue: videoDetailController.vttSubtitlesIndex.value
                    .clamp(
                      0,
                      videoDetailController.subtitles.length,
                    ),
                color: Colors.black.withValues(alpha: 0.8),
                itemBuilder: (context) {
                  return [
                    PopupMenuItem<int>(
                      value: 0,
                      height: 35,
                      onTap: () => videoDetailController.setSubtitle(0),
                      child: const Text(
                        "关闭字幕",
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 13,
                        ),
                      ),
                    ),
                    ...videoDetailController.subtitles.indexed.map((e) {
                      return PopupMenuItem<int>(
                        value: e.$1 + 1,
                        height: 35,
                        onTap: () =>
                            videoDetailController.setSubtitle(e.$1 + 1),
                        child: Text(
                          "${e.$2.lanDoc}",
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 13,
                          ),
                        ),
                      );
                    }),
                  ];
                },
                child: SizedBox(
                  width: widgetWidth,
                  height: 30,
                  child: videoDetailController.vttSubtitlesIndex.value == 0
                      ? const Icon(
                          Icons.closed_caption_off_outlined,
                          size: 22,
                          color: Colors.white,
                        )
                      : const Icon(
                          Icons.closed_caption_off_rounded,
                          size: 22,
                          color: Colors.white,
                        ),
                ),
              ),
      ),

      /// 播放速度
      BottomControlType.speed => Obx(
        () => PopupMenuButton<double>(
          tooltip: '倍速',
          requestFocus: false,
          initialValue: plPlayerController.playbackSpeed,
          color: Colors.black.withValues(alpha: 0.8),
          itemBuilder: (context) {
            return plPlayerController.speedList
                .map(
                  (double speed) => PopupMenuItem<double>(
                    height: 35,
                    padding: const EdgeInsets.only(left: 30),
                    value: speed,
                    onTap: () => plPlayerController.setPlaybackSpeed(speed),
                    child: Text(
                      "${speed}X",
                      style: const TextStyle(color: Colors.white, fontSize: 13),
                      semanticsLabel: "$speed倍速",
                    ),
                  ),
                )
                .toList();
          },
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Text(
              "${plPlayerController.playbackSpeed}X",
              style: const TextStyle(color: Colors.white, fontSize: 13),
              semanticsLabel: "${plPlayerController.playbackSpeed}倍速",
            ),
          ),
        ),
      ),

      BottomControlType.qa => Obx(
        () {
          final VideoQuality? currentVideoQa =
              videoDetailController.currentVideoQa.value;
          if (currentVideoQa == null) {
            return const SizedBox.shrink();
          }
          final PlayUrlModel videoInfo = videoDetailController.data;
          if (videoInfo.dash == null) {
            return const SizedBox.shrink();
          }
          final List<FormatItem> videoFormat = videoInfo.supportFormats!;
          final int totalQaSam = videoFormat.length;
          int userfulQaSam = 0;
          final List<VideoItem> video = videoInfo.dash!.video!;
          final Set<int> idSet = {};
          for (final VideoItem item in video) {
            final int id = item.id!;
            if (!idSet.contains(id)) {
              idSet.add(id);
              userfulQaSam++;
            }
          }
          return PopupMenuButton<int>(
            tooltip: '画质',
            requestFocus: false,
            initialValue: currentVideoQa.code,
            color: Colors.black.withValues(alpha: 0.8),
            itemBuilder: (context) {
              return List.generate(
                totalQaSam,
                (index) {
                  final item = videoFormat[index];
                  final enabled = index >= totalQaSam - userfulQaSam;
                  return PopupMenuItem<int>(
                    enabled: enabled,
                    height: 35,
                    padding: const EdgeInsets.only(left: 15, right: 10),
                    value: item.quality,
                    onTap: () async {
                      if (currentVideoQa.code == item.quality) {
                        return;
                      }
                      final int quality = item.quality!;
                      final newQa = VideoQuality.fromCode(quality);
                      videoDetailController
                        ..plPlayerController.cacheVideoQa = newQa.code
                        ..currentVideoQa.value = newQa
                        ..updatePlayer();

                      SmartDialog.showToast("画质已变为:${newQa.desc}");

                      // update
                      if (!plPlayerController.tempPlayerConf) {
                        GStorage.setting.put(
                          await Utils.isWiFi
                              ? SettingBoxKey.defaultVideoQa
                              : SettingBoxKey.defaultVideoQaCellular,
                          quality,
                        );
                      }
                    },
                    child: Text(
                      item.newDesc ?? '',
                      style: enabled
                          ? const TextStyle(color: Colors.white, fontSize: 13)
                          : const TextStyle(
                              color: Color(0x62FFFFFF),
                              fontSize: 13,
                            ),
                    ),
                  );
                },
              );
            },
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 8),
              child: Text(
                currentVideoQa.shortDesc,
                style: const TextStyle(color: Colors.white, fontSize: 13),
              ),
            ),
          );
        },
      ),

      /// 全屏
      BottomControlType.fullscreen => ComBtn(
        width: widgetWidth,
        height: 30,
        tooltip: isFullScreen ? '退出全屏' : '全屏',
        icon: isFullScreen
            ? const Icon(
                Icons.fullscreen_exit,
                size: 24,
                color: Colors.white,
              )
            : const Icon(
                Icons.fullscreen,
                size: 24,
                color: Colors.white,
              ),
        onTap: () =>
            plPlayerController.triggerFullScreen(status: !isFullScreen),
        onSecondaryTap: () => plPlayerController.triggerFullScreen(
          status: !isFullScreen,
          inAppFullScreen: true,
        ),
      ),
    };

    final isNotFileSource = !plPlayerController.isFileSource;

    List<BottomControlType> userSpecifyItemLeft = [
      BottomControlType.playOrPause,
      BottomControlType.time,
      if (!isNotFileSource || anySeason) ...[
        BottomControlType.pre,
        BottomControlType.next,
      ],
    ];

    final flag =
        isFullScreen || plPlayerController.isDesktopPip || maxWidth >= 500;
    List<BottomControlType> userSpecifyItemRight = [
      if (isNotFileSource && plPlayerController.showDmChart)
        BottomControlType.dmChart,
      if (plPlayerController.isAnim) BottomControlType.superResolution,
      if (isNotFileSource && plPlayerController.showViewPoints)
        BottomControlType.viewPoints,
      if (isNotFileSource && anySeason) BottomControlType.episode,
      if (flag) BottomControlType.fit,
      if (isNotFileSource) BottomControlType.aiTranslate,
      BottomControlType.subtitle,
      BottomControlType.speed,
      if (isNotFileSource && flag) BottomControlType.qa,
      if (!plPlayerController.isDesktopPip) BottomControlType.fullscreen,
    ];

    return Row(
      children: [
        ...userSpecifyItemLeft.map(progressWidget),
        Expanded(
          child: LayoutBuilder(
            builder: (context, constraints) => FittedBox(
              child: ConstrainedBox(
                constraints: BoxConstraints(minWidth: constraints.maxWidth),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: userSpecifyItemRight.map(progressWidget).toList(),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }

File: lib/utils/utils.dart (L21-26)

  @pragma("vm:platform-const")
  static final bool isMobile = Platform.isAndroid || Platform.isIOS;

  @pragma("vm:platform-const")
  static final bool isDesktop =
      Platform.isWindows || Platform.isMacOS || Platform.isLinux;

File: android/app/src/main/AndroidManifest.xml (L1-213)

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.piliplus">
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="http" />
        </intent>
        <!-- If your app opens https URLs -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="https" />
        </intent>

    </queries>
    <queries>
        <intent>
            <action android:name=
                "android.support.customtabs.action.CustomTabsService" />
        </intent>
    </queries>

    <queries>
        <!-- If your app checks for http support -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="http" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="https" />
        </intent>
    </queries>

    <application
        android:label="@string/app_name"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        xmlns:tools="http://schemas.android.com/tools"
        android:enableOnBackInvokedCallback="false"
        android:allowBackup="false"
        android:fullBackupContent="false"
        tools:replace="android:allowBackup">
        <meta-data
            android:name="io.flutter.embedding.android.EnableImpeller"
            android:value="false" />
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTask"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize"
            android:supportsPictureInPicture="true"
            android:resizeableActivity="true"
            >

            <meta-data android:name="flutter_deeplinking_enabled" android:value="false" />

            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
            <intent-filter android:label="PiliPlus">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="http"/>
                <data android:scheme="https"/>
                <data android:host="*.bilibili.com"/>
                <data android:host="*.bilibili.cn"/>
                <data android:host="*.bilibili.tv"/>
                <data android:host="bilibili.com"/>
                <data android:host="bilibili.cn"/>
                <data android:host="bilibili.tv"/>
                <data android:host="b23.tv" />
                <!--<data android:host="live.bilibili.com"/>-->
                <!--<data android:host="www.bilibili.com"/>-->
                <!--<data android:host="www.bilibili.tv"/>-->
                <!--<data android:host="www.bilibili.cn"/>-->
                <!--<data android:host="m.bilibili.cn"/>-->
                <!--<data android:host="m.bilibili.com"/>-->
                <!--<data android:host="bilibili.cn"/>-->
                <!--<data android:host="bilibili.com"/>-->
                <!--<data android:host="bangumi.bilibili.com"/>-->
                <!--<data android:host="space.bilibili.com"/>-->
            </intent-filter>
            <intent-filter android:label="PiliPlus">
                <action android:name="android.intent.action.VIEW" />
                <action android:name="android.intent.action.SEARCH" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="bilibili"/>
                <data android:host="forward" />
                <data android:host="comment"
                    android:pathPattern="/detail/.*/.*/.*" />
                <data android:host="uper" />
                <data android:host="article"
                    android:pathPattern="/readlist" />
                <data android:host="opus" />
                <data android:host="advertise" android:path="/home" />
                <data android:host="clip" />
                <data android:host="search" android:pathPattern=".*" />
                <data android:host="stardust-search" />
                <data android:host="music" />
                <data android:host="cheese" />
                <data android:host="bangumi"
                    android:pathPattern="/season.*" />
                <data android:host="bangumi" android:pathPattern="/.*" />
                <data android:host="pictureshow"
                    android:pathPrefix="/creative_center" />
                <data android:host="cliparea" />
                <data android:host="im" />
                <data android:host="im" android:path="/notifications" />
                <data android:host="following" />
                <data android:host="following"
                    android:pathPattern="/detail/.*" />
                <data android:host="following"
                    android:path="/publishInfo/" />
                <data android:host="laser" android:pathPattern="/.*" />
                <data android:host="livearea" />
                <data android:host="live" />
                <data android:host="catalog" />
                <data android:host="browser" />
                <data android:host="user_center" />
                <data android:host="login" />
                <data android:host="space" />
                <data android:host="author" />
                <data android:host="tag" />
                <data android:host="rank" />
                <data android:host="external" />
                <data android:host="blank" />
                <data android:host="home" />
                <data android:host="root" />
                <data android:host="video" />
                <data android:host="story" />
                <data android:host="podcast" />
                <data android:host="main" android:path="/favorite" />
                <data android:host="pgc" android:path="/theater/match" />
                <data android:host="pgc" android:path="/theater/square" />
                <data android:host="m.bilibili.com"
                    android:path="/topic-detail" />
                <data android:host="article" />
                <data android:host="pegasus"
                    android:pathPattern="/channel/v2/.*" />
                <data android:host="feed" android:pathPattern="/channel" />
                <data android:host="vip" />
                <data android:host="user_center" android:path="/vip" />
                <data android:host="history" />
                <data android:host="charge" android:path="/rank" />
                <data android:host="assistant" />
                <data android:host="feedback" />
                <data android:host="auth" android:path="/launch" />
            </intent-filter>
        </activity>
        <service 
            android:name="com.ryanheise.audioservice.AudioService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="true" 
            tools:ignore="Instantiatable">
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>

        <activity
            android:name="com.yalantis.ucrop.UCropActivity"
            android:theme="@style/Ucrop.CropTheme"/>

        <receiver 
            android:name="com.ryanheise.audioservice.MediaButtonReceiver"
            android:exported="true" 
            tools:ignore="Instantiatable">
            <intent-filter>
                <action android:name="android.intent.action.MEDIA_BUTTON" />
            </intent-filter>
        </receiver> 
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
    <uses-permission android:name="android.permission.WAKE_LOCK"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <!--
      Media access permissions.
      Android 13 or higher.
      https://developer.android.com/about/versions/13/behavior-changes-13#granular-media-permissions
      -->
    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
    <uses-permission android:name="android.permission.WRITE_SETTINGS"/>

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions