做视频类界面时,最容易被忽略但又最能拉开体验差距的,往往是播放器控制栏在不同显示状态下怎么切换。最近我在用 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( src="https://example.com/sample.mp4", controls_mode=VideoControlsMode.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_fullscreen
Flet 的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( src="https://example.com/sample.mp4", controls_mode=VideoControlsMode.PLAY_PAUSE, on_enter_fullscreen=lambda e: _enter_fullscreen(video), on_exit_fullscreen=lambda e: _exit_fullscreen(video), ) page.add( ft.Row( [video], expand=True, ) ) 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( src="https://example.com/sample.mp4", controls_mode=VideoControlsMode.PLAY_PAUSE, aspect_ratio=16 / 9, fit=ft.VideoFit.CONTAIN, ) page_fullscreen = ft.Container( content=video, width=720, height=405, bgcolor="#000000", alignment=ft.alignment.center, ) page_fullscreen.data = {"full": False} page.add( ft.Row( [ ft.Column( [ page_fullscreen, ft.ElevatedButton( "进入全屏 / 退出全屏", on_click=toggle_fullscreen, ), ] ) ], expand=True, ) ) 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本身很简单,但和状态联动一起用,就能解决很多真实场景下的播放器界面问题。