v88 旅程查看器全屏化:视频内联全屏播放、播放中直接滑动切换、只留底部计数
This commit is contained in:
@@ -290,22 +290,28 @@ export struct FbTripDetailPage {
|
||||
@Local delNodeId: number = 0;
|
||||
@Local deleting: boolean = false;
|
||||
|
||||
/* ---- v76:照片查看器 / 视频播放器 ----
|
||||
/* ---- v76:照片查看器 ----
|
||||
🔴 用户 2026-10-22:「里面的照片要可以点击查看,视频要能够播放。」
|
||||
⚠️ 两个都是**全屏覆盖层**,所以用"非空即开着"的语义
|
||||
(`viewerPhotos` 空 / `videoKey` 空 = 关着),不用额外的布尔开关 ——
|
||||
少一个开关就少一处"关了浮层忘了复位"的漏洞。 */
|
||||
⚠️ 全屏覆盖层,所以用"非空即开着"的语义(`viewerPhotos` 空 = 关着),
|
||||
不用额外的布尔开关 —— 少一个开关就少一处"关了浮层忘了复位"的漏洞。 */
|
||||
/** 查看器里的媒体列表(v87:**图片+视频混排**,空 = 关着)。
|
||||
* 🔴 用户 2026-10-24:「图片和视频是不是可以都支持滑动…滑动到视频的时候,
|
||||
* 点击就直接播放。」⇒ 列表不再是纯键数组,要带 `mime` 才分得清图片/视频。 */
|
||||
@Local viewerPhotos: FbTripNodePhoto[] = [];
|
||||
@Local viewerIndex: number = 0;
|
||||
/** v87:Swiper 控制器 —— 底部「上一张/下一张」走 `showPrevious()/showNext()`,
|
||||
* 与手势翻页同一条动画通道(直接改 index 是瞬跳,又要回到"生硬"的老毛病)。 */
|
||||
/** v87:Swiper 控制器 —— 开局定位走 `changeIndex()`。 */
|
||||
private viewerCtl: SwiperController = new SwiperController();
|
||||
/** 正在播放的视频键(空 = 关着) */
|
||||
@Local videoKey: string = '';
|
||||
@Local videoName: string = '';
|
||||
/* v88(用户 2026-10-24):
|
||||
「预览能不能直接全屏?把关闭按钮、上一张、下一张都去掉。
|
||||
一共三张、第几张那个可以留着。视频播放之后马上就是全屏播放;
|
||||
播放的时候不许还得关闭一下才能切下一个,能不能直接切换?」
|
||||
⇒ ① 查看器 Swiper 撑满全屏,控制区只剩底部 "N / M" 计数;
|
||||
② 关闭 = 点一下图片 / 系统返回手势(按钮摘了,得留别的关法);
|
||||
③ **独立的全屏播放器浮层整个退场**:视频页点海报 → 就地变成
|
||||
`Video` 全屏开播(autoPlay),计数也藏起来 = 真全屏;
|
||||
④ 切换不用关:播放中直接左右滑,`onChange` 落新页时停掉旧的。
|
||||
⚠️ `playingKey` = 正在内联播放的视频 key(空 = 该页是海报态)。 */
|
||||
@Local playingKey: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
/* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载,
|
||||
@@ -326,16 +332,11 @@ export struct FbTripDetailPage {
|
||||
* 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。
|
||||
*/
|
||||
private backOnce(): boolean {
|
||||
/* 🔴 v76:查看器/播放器是**最上面那一层**,必须第一个关,
|
||||
否则按返回会直接退出详情页,而用户以为只是"关掉大图"。 */
|
||||
if (this.videoKey !== '') {
|
||||
this.videoKey = '';
|
||||
this.videoName = '';
|
||||
return true;
|
||||
}
|
||||
/* 🔴 v76:查看器是**最上面那一层**,必须第一个关,
|
||||
否则按返回会直接退出详情页,而用户以为只是"关掉大图"。
|
||||
v88:独立播放器退场了,查看器(含内联播放)就是唯一一层。 */
|
||||
if (this.viewerPhotos.length > 0) {
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
this.closeViewer();
|
||||
return true;
|
||||
}
|
||||
if (this.delNodeId > 0) {
|
||||
@@ -401,18 +402,13 @@ export struct FbTripDetailPage {
|
||||
fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0;
|
||||
}
|
||||
|
||||
/* v76:全屏查看器 / 播放器也要让底部悬浮栏让位(否则大图底下漏出一条悬浮栏)。
|
||||
⚠️ 同样是"一个 @Monitor 盯一个变量",两个分开写 —— 它们互斥,直接赋值即可。 */
|
||||
/* v76:全屏查看器也要让底部悬浮栏让位(否则大图底下漏出一条悬浮栏)。
|
||||
v88:独立播放器退场,只剩这一个 Monitor。 */
|
||||
@Monitor('viewerPhotos')
|
||||
onViewerChanged(): void {
|
||||
fbAppState().sheetCount = this.viewerPhotos.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
@Monitor('videoKey')
|
||||
onVideoChanged(): void {
|
||||
fbAppState().sheetCount = this.videoKey !== '' ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某一段行程的大图查看器(从第 `at` 个开始)。
|
||||
*
|
||||
@@ -428,10 +424,11 @@ export struct FbTripDetailPage {
|
||||
this.viewerIndex = at < 0 ? 0 : (at >= photos.length ? photos.length - 1 : at);
|
||||
}
|
||||
|
||||
/** 打开一个视频(`key` 是裸 key,播放地址现拼,不带缓存) */
|
||||
private openVideo(a: FbTripNodePhoto): void {
|
||||
this.videoKey = a.key;
|
||||
this.videoName = a.name !== '' ? a.name : '视频';
|
||||
/** 关掉查看器(v88:三样都要复位 —— 漏了 `playingKey` 会"关了还在播声音") */
|
||||
private closeViewer(): void {
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
this.playingKey = '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,15 +649,13 @@ export struct FbTripDetailPage {
|
||||
this.delNodeConfirm()
|
||||
}
|
||||
|
||||
/* 🔴 v76:照片查看器 / 视频播放器放在**最后一个位置**。
|
||||
/* 🔴 v76:照片查看器放在**最后一个位置**。
|
||||
在 `Stack` 里"后写的盖在上面",所以它压住所有浮层 —— 用户点了照片
|
||||
就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。 */
|
||||
就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。
|
||||
v88:独立播放器浮层退场(视频内联全屏播,见 `photoViewer`)。 */
|
||||
if (this.viewerPhotos.length > 0) {
|
||||
this.photoViewer()
|
||||
}
|
||||
if (this.videoKey !== '') {
|
||||
this.videoPlayer()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@@ -1279,7 +1274,12 @@ export struct FbTripDetailPage {
|
||||
.clip(true)
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.onClick((): void => {
|
||||
this.openVideo(vd);
|
||||
/* v88(用户:「视频也应该播放之后马上就是全屏播放」):
|
||||
不再开独立播放器 —— 直接开查看器、定位到这条视频、
|
||||
并把 `playingKey` 置上 ⇒ 视频页一挂载就是全屏 `Video` 开播。 */
|
||||
const all: FbTripNodePhoto[] = this.mediaOf(n);
|
||||
this.openViewer(all, all.indexOf(vd));
|
||||
this.playingKey = vd.key;
|
||||
})
|
||||
}, (vd: FbTripNodePhoto): string => 'vd_' + vd.id.toString())
|
||||
}
|
||||
@@ -1355,44 +1355,51 @@ export struct FbTripDetailPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片查看器(全屏大图 + 左右滑翻页)。
|
||||
* 照片/视频查看器(v88:**全屏** + 只留底部计数)。
|
||||
*
|
||||
* 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看。」
|
||||
* ⚠️ 照 `FbMovieDetailSheet` 那套查看器写(同一套手势、同一套关闭方式),
|
||||
* 不要另起一种"点一下放大一点、再点一下缩回去"的交互 ——
|
||||
* 两个页面同一功能长得不一样,用户会以为其中一个是坏的。
|
||||
* ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张",裁边缘就失去意义
|
||||
* (只有缩略图才用 `Cover`)。
|
||||
* 🔴 左右滑用 **`PanGesture`** 不用 `SwipeGesture`:后者要求"快速划动"才识别,
|
||||
* 在复杂布局里慢划经常不触发(日程页实测过)。
|
||||
* 🔴 v88(用户 2026-10-24):
|
||||
* 「照片和视频预览的时候,能不能直接全屏啊?就是把那些关闭按钮、
|
||||
* 上一张、下一张都去掉。那个下边一共三张,然后第几张那个可以留着。
|
||||
* 尤其是视频,也应该就是播放之后马上就是全屏播放。
|
||||
* 视频播放的时候,还不许关闭一下才能切换到下一个视频,能不能直接切换?」
|
||||
* ⇒ ① Swiper 从 76% 高撑到**全屏**;顶栏 ✕、底部 ‹› 翻页条全部摘掉;
|
||||
* ② 底部只留 "N / M" 计数(用户点名要留);播放时连计数也藏 = 真全屏;
|
||||
* ③ 关闭 = **点一下图片** 或 系统返回手势(按钮摘了,得留别的关法);
|
||||
* ④ 视频页:海报态(▶ + 「点一下播放」)→ 点一下就地变成全屏 `Video`
|
||||
* 开播(autoPlay);**播放中直接左右滑就切下一个**(onChange 落新页时
|
||||
* 停掉旧的),不用先关闭。
|
||||
* ⚠️ v87 的教训不变:Swiper 跟手滑动 + 缓动动画;开局 `changeIndex(,false)`。
|
||||
*/
|
||||
@Builder
|
||||
photoViewer() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
// ① 背景(点它关闭)
|
||||
// ① 底衬(被全屏 Swiper 盖住,留着防漏色)
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#E6000000')
|
||||
.onClick((): void => {
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
})
|
||||
|
||||
/* ② 媒体翻页(v87:Swiper)
|
||||
🔴 用户 2026-10-24:「图片和视频是不是可以都支持滑动…滑动到视频的时候,
|
||||
点击就直接播放。另外滑动的时候有没有个动画呀?现在整个一下就变化,特别生硬。」
|
||||
⚠️ 原来是单张 `Image` + `PanGesture`,松手直接换 index ⇒ "整个一下就变化"。
|
||||
`Swiper` 的滑动**跟手**,松手(或 `changeIndex`)自带缓动动画,
|
||||
不用自己写位移插值;`.duration()` 调换页动画时长。
|
||||
⚠️ 视频也是翻页项(`viewerPhotos` 本来就是混排):滑到视频那页,点一下
|
||||
走 `openVideo()`,原 `videoPlayer` 全屏播放器(系统控件)原样叠上来。
|
||||
⚠️ `loop(true)`:首尾相接,与旧版"取模循环翻页"行为一致(Swiper 自己处理回绕)。 */
|
||||
/* ② 媒体翻页(v87:Swiper 混排;v88:全屏 + 视频内联播放)
|
||||
⚠️ 视频页两态:`playingKey` 对上 = 全屏 `Video` 开播;否则海报。
|
||||
切走(onChange 落到别的页)就复位 `playingKey` —— 直接滑动切换,
|
||||
不用先关闭(用户 2026-10-24 原话)。 */
|
||||
Swiper(this.viewerCtl) {
|
||||
ForEach(this.viewerPhotos, (m: FbTripNodePhoto) => {
|
||||
if (fbPhotoKind(m) === 'video') {
|
||||
/* 视频页:没有服务端封面 ⇒ 深色底 + ▶ + 文件名 + 一句"点一下播放",
|
||||
比放一张坏图诚实。点它才播(用户:滑动到视频的时候,点击就直接播放)。 */
|
||||
if (this.playingKey === m.key) {
|
||||
Video({ src: fbImageSrc(m.key, FB_TOKEN_STORE.get()) })
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.controls(true)
|
||||
.autoPlay(true)
|
||||
.loop(false)
|
||||
.objectFit(ImageFit.Contain)
|
||||
.backgroundColor('#000000')
|
||||
.onError((): void => {
|
||||
// 放不出来就退回海报态,不能留一块黑屏让人以为在缓冲
|
||||
this.playingKey = '';
|
||||
})
|
||||
} else {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Column()
|
||||
.width('100%')
|
||||
@@ -1417,16 +1424,19 @@ export struct FbTripDetailPage {
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.borderRadius(10)
|
||||
.clip(true)
|
||||
.onClick((): void => {
|
||||
this.openVideo(m);
|
||||
this.playingKey = m.key;
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Image(fbImageSrc(m.key, FB_TOKEN_STORE.get()))
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.objectFit(ImageFit.Contain)
|
||||
.onClick((): void => {
|
||||
// 关闭按钮按用户要求摘了 ⇒ 点一下图片本身就是"关"
|
||||
this.closeViewer();
|
||||
})
|
||||
}
|
||||
}, (m: FbTripNodePhoto): string => 'vw_' + m.id.toString())
|
||||
}
|
||||
@@ -1434,149 +1444,49 @@ export struct FbTripDetailPage {
|
||||
.duration(350)
|
||||
.cachedCount(1)
|
||||
.width('100%')
|
||||
.height('76%')
|
||||
.height('100%')
|
||||
.onChange((i: number): void => {
|
||||
/* 手势与按钮翻页统一在这里落 index(动画结束才回调 ⇒ 底部计数不闪)。 */
|
||||
/* 手势翻页统一在这里落 index(动画结束才回调 ⇒ 计数不闪)。
|
||||
v88:落到别的页就停播 —— `changeIndex(,false)` 开局定位也会触发
|
||||
onChange,所以要先比 key:落在"正在播的那条"上不能误停。 */
|
||||
this.viewerIndex = i;
|
||||
if (i < this.viewerPhotos.length && this.viewerPhotos[i].key !== this.playingKey) {
|
||||
this.playingKey = '';
|
||||
}
|
||||
})
|
||||
.onAppear((): void => {
|
||||
/* ⚠️ Swiper 挂载时只能从第 0 页开始 ⇒ 打开瞬间跳到用户点的那张。
|
||||
`useAnimation=false`:**开查看器**这一下不该放翻页动画(否则看起来
|
||||
像"从第一张滑过去")。之后的手势/按钮翻页都走 Swiper 自带动画。 */
|
||||
`useAnimation=false`:**开查看器**这一下不该放翻页动画。 */
|
||||
this.viewerCtl.changeIndex(this.viewerIndex, false);
|
||||
})
|
||||
|
||||
/* ③ 顶部关闭 + 底部翻页条
|
||||
🔴🔴 v76 真机实测踩到:原来这两块各自写 `.align(Alignment.Bottom)` /
|
||||
`.align(Alignment.TopEnd)`,以为能贴到 Stack 的角上 —— 实测**完全无效**,
|
||||
两块都掉到了屏幕正中(关闭按钮跑到 x=518..569,正中间)。
|
||||
⇒ 别再靠 `.align()` 定位;用**铺满的 Column + SpaceBetween** 顶到底:
|
||||
第一行放关闭(右对齐),中间 layoutWeight(1) 撑开,
|
||||
最后一行放翻页条。这样位置只由布局决定,与对齐属性无关。
|
||||
⚠️ `hitTestBehavior(Transparent)` 必须有:这一层是**透明的**,
|
||||
它只负责摆按钮的位置;中间大片空白要让点击穿下去给 ① 的遮罩
|
||||
(否则"点背景关闭"失效)。Transparent = 自身与子节点响应、不阻挡兄弟。
|
||||
⚠️ v87:‹ › 按钮走 `viewerCtl.showPrevious()/showNext()` —— 与手势同一条
|
||||
动画通道;直接改 `viewerIndex` 是瞬跳(Swiper 也不认外部改 index)。
|
||||
*/
|
||||
/* ③ 底部计数(用户点名要留的那行 "N / M")。
|
||||
⚠️ 沿 v76 的教训:不用 `.align()` 定位(Stack 里实测无效),
|
||||
用铺满 Column + `layoutWeight(1)` 压到底。
|
||||
⚠️ `hitTestBehavior(Transparent)` 必须有:这层是透明的摆位层,
|
||||
点击要穿下去给媒体页(点图关闭 / 点海报播放)。
|
||||
⚠️ v88:播放时整个藏掉(用户:"马上就是全屏播放")。 */
|
||||
Column() {
|
||||
Row() {
|
||||
Column().layoutWeight(1)
|
||||
Column({ space: 2 }) {
|
||||
Text('✕')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontColor('#FFFFFFCC')
|
||||
Text('关闭')
|
||||
.fontSize(9)
|
||||
.fontColor('#FFFFFF88')
|
||||
}
|
||||
.padding({ left: 18, right: 6, top: 8, bottom: 8 })
|
||||
.alignItems(HorizontalAlign.End)
|
||||
.onClick((): void => {
|
||||
this.viewerPhotos = [];
|
||||
this.viewerIndex = 0;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column().layoutWeight(1)
|
||||
|
||||
Row({ space: 18 }) {
|
||||
Text('‹ 上一张')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor('#FFFFFFCC')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.onClick((): void => {
|
||||
this.viewerCtl.showPrevious();
|
||||
})
|
||||
if (this.playingKey === '') {
|
||||
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerPhotos.length.toString())
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor('#FFFFFF99')
|
||||
Text('下一张 ›')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor('#FFFFFFCC')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.onClick((): void => {
|
||||
this.viewerCtl.showNext();
|
||||
})
|
||||
.padding({ bottom: 34 })
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: 8, bottom: 48 })
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放器(全屏居中 + 系统控件)。
|
||||
*
|
||||
* 🔴 v76(用户 2026-10-22):「视频要能够播放。」
|
||||
* ⚠️ 走 `media.php?k=…&token=…` 这个代理拿字节:它**支持 Range(206)**
|
||||
* (`media.php` 第 6 段),所以拖进度条、边下边播都正常。
|
||||
* 直接把 OSS 地址塞给播放器是不行的 —— 桶是私有的,而且没有令牌。
|
||||
* ⚠️ `controls(true)`:进度条/暂停/全屏都用系统的,不要自己画一套
|
||||
* (自己画的那套在真机上连"拖进度条"都做不到)。
|
||||
*/
|
||||
@Builder
|
||||
videoPlayer() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F2000000')
|
||||
.onClick((): void => {
|
||||
this.videoKey = '';
|
||||
this.videoName = '';
|
||||
})
|
||||
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.videoName)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor('#FFFFFFCC')
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text('✕')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontColor('#FFFFFFCC')
|
||||
.padding({ left: 14, right: 2, top: 4, bottom: 4 })
|
||||
.onClick((): void => {
|
||||
this.videoKey = '';
|
||||
this.videoName = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Video({
|
||||
src: fbImageSrc(this.videoKey, FB_TOKEN_STORE.get())
|
||||
})
|
||||
.width('100%')
|
||||
.aspectRatio(16 / 9)
|
||||
.controls(true)
|
||||
.autoPlay(true)
|
||||
.loop(false)
|
||||
.objectFit(ImageFit.Contain)
|
||||
.backgroundColor('#000000')
|
||||
.borderRadius(8)
|
||||
.onError((): void => {
|
||||
// 播放不了要**说实话**,不能留一块黑屏让人以为在缓冲
|
||||
this.videoName = '这个视频放不出来(格式或网络问题)';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
/* v88:独立的全屏播放器浮层(原 `videoPlayer()`)整个退场 ——
|
||||
视频改在查看器 Swiper 里**内联全屏**播(用户 2026-10-24:
|
||||
「播放之后马上就是全屏播放」「不许关闭一下才能切换到下一个」)。
|
||||
播放地址仍走 `media.php?k=…&token=…` 代理(支持 Range,拖进度条正常)。 */
|
||||
|
||||
/** 删一段行程的确认框(自绘,避免动系统弹窗层级) */
|
||||
@Builder
|
||||
|
||||
Reference in New Issue
Block a user