Flet VideoControlsMode:播放器控制栏随全屏状态动态切换
做视频类界面时最容易被忽略但又最能拉开体验差距的往往是播放器控制栏在不同显示状态下怎么切换。最近我在用 Flet 做视频看板默认的Video控件一铺开就是完整控制面板进度条、音量、全屏按钮全堆在画面下方嵌入到看板里显得特别拥挤。后来把VideoControlsMode这个枚举吃透之后问题就迎刃而解了普通状态下用极简控件保持版面干净全屏状态下再切换回完整控件保证操作顺手。这篇文章就围绕这个需求把VideoControlsMode的四种模式、状态感知思路、两种实现路线和实测踩坑全部理清楚适合正在用 Flet 做视频播放器、视频看板或多媒体课件的开发者参考。1. VideoControlsMode 四种模式先把家底盘清楚1.1 从 show_controls 到 controls_mode播放器控制逻辑的一次升级在较早版本的 Flet 里Video控件控制界面的是一个简单的布尔值show_controls。true 就显示一套完整控件false 就什么都不显示虽然简单直接但问题也很明显你没法在“仅显示播放按钮”和“显示播放/暂停按钮”之间做选择更不可能针对不同显示状态切换控制粒度。后来的版本引入了VideoControlsMode枚举用一套可扩展的枚举值替代了原来的开关逻辑。这个变动表面上只是 API 调整实际上是把“显示/隐藏”升级成了“按需配置界面模式”。它是 Flet 视频控件默认行为的统一入口直接决定了控件工作在哪一层交互粒度。1.2 几种模式分别长什么样VideoControlsMode定义在flet.video模块中我在 0.23 版本环境下使用导入方式如下import flet as ft from flet.video import VideoControlsMode枚举值一共有四个模式控件显示情况典型使用场景VideoControlsMode.NONE显示完整控制面板包括播放/暂停、进度条、时间、音量、全屏等全屏观影、需要完整交互的视频详情页VideoControlsMode.DISABLED不显示任何操作控件也无法点击播放背景视频、开机引导、片头动画VideoControlsMode.PLAY只显示一个播放按钮列表封面、视频预览卡片、极简场景VideoControlsMode.PLAY_PAUSE显示播放和暂停两个按钮嵌入看板、仪表盘、需要基础启停的界面实际设置方式很简单video ft.Video( srchttps://example.com/sample.mp4, controls_modeVideoControlsMode.PLAY_PAUSE, )我把四种模式分别跑了一遍才意识到每个模式对应的交互差异其实很大尤其是NONE这个命名很容易误导人。1.3 最大的坑NONE 不是“没有控件”很多人第一次看到VideoControlsMode.NONE会以为它是“关闭控件”实测恰恰相反NONE 是显示全部控件。它是 Flet 的默认值对应的是 Flutter 端VideoPlayer提供的完整控制面板。反直觉的地方就在这里DISABLED才是真正关闭控件PLAY和PLAY_PAUSE则是对控件做减法。所以如果你想在某个状态下隐藏所有控件要选DISABLED而不是NONE。第一次踩这个坑是在做看板嵌入想着“NONE 就是没有控件”结果运行时整个完整控制面板都出现了。后来翻文档才发现命名沿用了 Flutter 视频播放器控件的枚举习惯NONE 在这里代表“无附加限制”也就是把默认完整控件全部暴露出来。这一点先记住后面所有方案都依赖它。2. 普通状态与全屏状态为什么要两套控件2.1 场景还原同一个视频两种完全不同的交互诉求看板场景里视频通常是嵌入在一个小尺寸网格或侧边栏里的用户对它的诉求是“快速预览、可暂停不打断当前阅读节奏”。这时候如果摊出进度条、音量、全屏按钮视觉负担很重还容易误触。而切换到全屏之后用户的目的变成了“专心观看”交互诉求也随之变化需要进度拖拽、需要音量调节、需要倍速、需要随时退出全屏。这个场景下如果只给一个播放按钮反而会让人觉得播放器“缺胳膊少腿”。所以结论很直接同一个视频控件要能根据所在容器的显示状态动态调整自己的控制模式。2.2 推荐配置普通模式做减法全屏模式做加法经过两轮迭代我这边的最终配置方案是这样显示状态controls_mode 建议理由普通嵌入态PLAY_PAUSE版面干净能启停就够避免误触进度条全屏状态NONE完整控制面板提供进度、音量、倍速、退出全屏片头/背景动画DISABLED用户不参与操作只看效果这个配置的核心理念是“按需暴露功能”功能不是越多越好而是在正确的位置给到正确的功能。2.3 一个容易被忽略的前提普通状态也要有全屏入口这里有个联动问题需要先讲清楚如果你想在普通状态下用PLAY_PAUSE那全屏按钮就不会出现因为全屏按钮挂在完整控制面板里。也就是说你得自己在普通状态下提供“进入全屏”的入口否则就永远进不了全屏后面切换模式也就没有意义。我见过一种做法是在PLAY_PAUSE模式下借助外层布局额外放一个“全屏”按钮或者监听视频区域的点击事件来触发全屏。这样既能保持控制面板简洁又保留了进入全屏的通道。3. 动手实现先走原生全屏事件路线3.1 注册 on_enter_fullscreen 与 on_exit_fullscreenFlet 的Video控件提供了两个和全屏相关的回调on_enter_fullscreen与on_exit_fullscreen。思路很清晰监听这两个事件在回调里修改controls_mode就能实现普通状态与全屏状态下的控件切换。先看一个最基础的原生全屏事件示例import flet as ft from flet.video import Video, VideoControlsMode def main(page: ft.Page): page.title VideoControlsMode 原生全屏切换 video Video( srchttps://example.com/sample.mp4, controls_modeVideoControlsMode.PLAY_PAUSE, on_enter_fullscreenlambda e: _enter_fullscreen(video), on_exit_fullscreenlambda e: _exit_fullscreen(video), ) page.add( ft.Row( [video], expandTrue, ) ) def _enter_fullscreen(video: Video): video.controls_mode VideoControlsMode.NONE video.update() def _exit_fullscreen(video: Video): video.controls_mode VideoControlsMode.PLAY_PAUSE video.update() ft.app(main)这段代码的运行逻辑是初始状态为PLAY_PAUSE视频下方只有播放/暂停按钮用户点击完整控制面板中的全屏按钮进入全屏后on_enter_fullscreen被触发模式立即切换为NONE进度条、音量等控件全部出现退出全屏时再切回PLAY_PAUSE。3.2 关键细节为什么每次都要调用 update在上面的代码里每次修改controls_mode之后我都调用了video.update()。不要小看这一步Flet 的属性赋值是同步到 Python 侧的控件描述对象必须通过update()把变更推送到 Flutter 渲染层界面才会刷新。我在第一版代码里偷懒没写update()结果就是全屏进入之后控件模式没变进度条始终不出现。加回update()之后切换就正常了。这个问题的原因很简单controls_mode的修改需要触发重建不刷新就不会生效。3.3 原生路线的问题入口受限、平台表现不一致原生全屏事件路线虽然代码最少但有一个明显的短板普通状态下没有全屏按钮原生全屏入口基本绑定了NONE模式的完整控制面板。你的普通模式只能选择NONE或者DISABLED没按钮可点很难用PLAY_PAUSE保持简洁的同时还能点进全屏。另一个问题是原生全屏在很多平台上的表现依赖 Flutter 端的播放器实现不同桌面环境下的全屏动画、退出手势都有差异测试量要跟上去。如果你做的是一个多端项目原生路线可能不够省心。4. 更可控的自定义全屏方案容器切换 模式联动4.1 思路说明用布局层切换而非原生全屏我实际在项目中最终选择的是更可控的自定义全屏方案核心思路是不用原生全屏而是自己控制容器布局。具体来说把Video控件放进一个容器里普通状态下容器的尺寸是预定看板大小点击自定义全屏按钮后让容器占据整个页面覆盖在最高层同时把视频的controls_mode切换为NONE退出全屏时再恢复容器尺寸和PLAY_PAUSE模式。这样做的好处在于普通状态下可以自由选择极简控件、甚至DISABLED还不影响全屏入口全屏切换完全由你自己的代码控制跨平台行为一致。4.2 完整示例代码import flet as ft from flet.video import Video, VideoControlsMode def main(page: ft.Page): page.title 自定义全屏 模式联动 def toggle_fullscreen(e): if not page_fullscreen.data.get(full, False): # 记录普通状态容器的尺寸进入全屏 page_fullscreen.data[full] True video.controls_mode VideoControlsMode.NONE page_fullscreen.width page.width page_fullscreen.height page.height else: # 恢复普通状态容器尺寸切换极简控件 page_fullscreen.data[full] False video.controls_mode VideoControlsMode.PLAY_PAUSE page_fullscreen.width 720 page_fullscreen.height 405 page.update() video Video( srchttps://example.com/sample.mp4, controls_modeVideoControlsMode.PLAY_PAUSE, aspect_ratio16 / 9, fitft.VideoFit.CONTAIN, ) page_fullscreen ft.Container( contentvideo, width720, height405, bgcolor#000000, alignmentft.alignment.center, ) page_fullscreen.data {full: False} page.add( ft.Row( [ ft.Column( [ page_fullscreen, ft.ElevatedButton( 进入全屏 / 退出全屏, on_clicktoggle_fullscreen, ), ] ) ], expandTrue, ) ) ft.app(main)这段代码里ft.Container充当了视频的可变尺寸容器。进入全屏时把它扩展为page.width和page.height退出时恢复720x405。VideoControlsMode则在NONE与PLAY_PAUSE之间联动。实际运行下来切换流畅布局也不会出现原生全屏那种弹跳感。4.3 这个方案更适合生产环境的三点理由第一入口自由设计。普通模式下你不需要依赖控制面板里的全屏按钮可以通过自己的按钮、点击事件等方式进入全屏。第二布局完全可控。你可以把视频容器自由嵌套进Column、Row、Stack中全屏时还能叠加自定义的返回按钮、水印、字幕等元素。第三测试稳定。没有平台相关的原生差异一套逻辑在桌面端和 Web 端表现一致。在这个自定义方案里page_fullscreen.data这种附加字段的方法是我比较推荐的它是 Flet 控件预留的data属性适合临时存状态比维护一个全局变量更干净。5. 踩坑记录切换不生效、布局闪跳、回调时机5.1 改了 controls_mode 但界面没反应先检查 update 链路这是出现频率最高的问题。修改controls_mode之后没有调video.update()或者page.update()控件模式就是旧的。Flet 的渲染更新是显式触发的赋值只是改内存数据你必须把它显式地同步给渲染端。如果update()也调了还是没生效就要检查你是不是在某个事件回调里修改了模式但事件本身没有被正确绑定。比如把on_enter_fullscreen写成了字符串而不是回调对象或者事件绑定时用的 lambda 里引用了一个尚未初始化的变量这类问题会让事件静默失效。5.2 全屏回调里立刻改布局会闪跳问题常出在回调时机在原生全屏事件里立即修改容器尺寸或显示状态有时会出现一次明显的闪烁这是因为on_enter_fullscreen触发时全屏状态可能已经在 Flutter 端生效而 Flet 侧布局对象还没完全重建。此时再同步修改多个属性就会有一个“旧布局→新布局→再重绘”的间隙。我处理这个问题比较朴素通过回调延迟到下一帧再改例如用page.run_task把状态更新放到异步任务里效果会平稳很多。自定义全屏方案我反而没有遇到闪跳因为进入全屏、修改容器、切换模式都在同一个点击回调里完成Flutter 端一次性重绘没有时序缝隙。5.3 fit 设置不对全屏后画面不是“放大”而是“裁剪”全屏切换还有一个视觉层面的坑Video控件的fit属性。默认是VideoFit.CONTAIN保持原始画面比例完整显示画面可能有黑边但有些开发者为了沉浸感会设成VideoFit.COVER结果全屏时画面边缘被裁剪掉人物说话时字幕或头部出画面。我的建议是普通嵌入态用CONTAIN保证缩略图完整全屏状态如果要铺满屏幕优先考虑调整容器底色为黑色而不是依赖COVER裁剪这样观感最自然。代码里的bgcolor#000000就是干这个用的。5.4 模式切换不阻断播放状态这里给一个容易忽略的结论切换controls_mode不会重置视频的播放进度和音量。也就是说你可以在播放过程中随时切换模式视频不会重头开始也不会自动暂停。这一点非常关键不然全屏模式一切换视频就跳回开头那体验基本全毁了。如果你在实测中遇到“一切换就从头播放”的情况先检查是否在回调里误触了seek或者重新设置了src而不是怀疑controls_mode本身。6. 扩展思路把模式切换做成可配置状态机6.1 用枚举管理多种界面状态不要再用散落的函数改属性当你的视频界面状态增多时普通、全屏、后台播放、画中画直接在回调函数里散落地写controls_mode xxx会越来越难维护。我推荐用一个状态机或至少一个配置映射来管理。class VideoUiState(ft.Enum): EMBED embed FULLSCREEN fullscreen BACKGROUND background CONTROLS_MODE_MAP { VideoUiState.EMBED: VideoControlsMode.PLAY_PAUSE, VideoUiState.FULLSCREEN: VideoControlsMode.NONE, VideoUiState.BACKGROUND: VideoControlsMode.DISABLED, } def apply_ui_state(video: Video, state: VideoUiState): video.controls_mode CONTROLS_MODE_MAP[state] video.update()apply_ui_state封装了模式切换的所有细节后续如果某个状态想调整控件模式只需要改映射表不需要动事件回调代码。这在页面逻辑复杂之后能省下很多排查时间。6.2 结合播放状态事件做更细粒度的控制除了全屏状态你还可以监听视频的播放状态事件把控件模式和应用流程进一步绑定。比如视频未加载完成时用DISABLED防止误操作加载完成后切到PLAY_PAUSE视频播放结束之后再切到PLAY让用户重新开始。这种组合逻辑比单纯的全屏切换更接近真实产品需求。监听事件时注意Video的事件名比如播放结束、加载完成等在不同 Flet 版本里可能有差异建议绑定事件前后都先打印日志确认是否被触发避免回调函数写了但不执行。6.3 与自定义底部控制栏的搭配思路VideoControlsMode并不是唯一的选择。如果你的交互需求远超预置模式比如要做自定义倍率、弹幕、画质切换可以把controls_mode设为DISABLED然后在Video上方叠加自己的控制栏。这个做法在 Flet 里完全可行因为Video是普通控件可以放入Stack中。我自己在做某个通用视频播放器组件时就是底层放Video顶层放自定义操作栏再用Stack控制层级。此时controls_mode只负责“预置控件的显隐”真正交互完全交由自定义控件处理。需要注意的是顶层容器要设置transparent背景并且控制它的事件穿透避免挡住视频点击。做了几轮优化之后我最深的体会是不要一上来就追求“功能最多”的控制面板先把视频控件所处的状态列清楚然后给每个状态分配一套合适的控件模式再通过一个统一的状态管理函数去切换这样才能保持代码清爽、交互顺手。VideoControlsMode本身很简单但和状态联动一起用就能解决很多真实场景下的播放器界面问题。