diff --git a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets index b011fb7..33644ff 100644 --- a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets @@ -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,78 +1355,88 @@ 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') { - /* 视频页:没有服务端封面 ⇒ 深色底 + ▶ + 文件名 + 一句"点一下播放", - 比放一张坏图诚实。点它才播(用户:滑动到视频的时候,点击就直接播放)。 */ - Stack({ alignContent: Alignment.Center }) { - Column() + if (this.playingKey === m.key) { + Video({ src: fbImageSrc(m.key, FB_TOKEN_STORE.get()) }) .width('100%') .height('100%') - .backgroundColor('#2A2320') - Column({ space: 10 }) { - Text('▶') - .fontSize(44) - .fontColor('#FFFFFFCC') - Text(m.name !== '' ? m.name : '视频') - .fontSize(FbMetric.fontSmall) - .fontColor('#FFFFFF99') - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - Text('点一下播放') - .fontSize(FbMetric.fontTiny) - .fontColor('#FFFFFF66') + .controls(true) + .autoPlay(true) + .loop(false) + .objectFit(ImageFit.Contain) + .backgroundColor('#000000') + .onError((): void => { + // 放不出来就退回海报态,不能留一块黑屏让人以为在缓冲 + this.playingKey = ''; + }) + } else { + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor('#2A2320') + Column({ space: 10 }) { + Text('▶') + .fontSize(44) + .fontColor('#FFFFFFCC') + Text(m.name !== '' ? m.name : '视频') + .fontSize(FbMetric.fontSmall) + .fontColor('#FFFFFF99') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + Text('点一下播放') + .fontSize(FbMetric.fontTiny) + .fontColor('#FFFFFF66') + } + .padding(20) + .alignItems(HorizontalAlign.Center) } - .padding(20) - .alignItems(HorizontalAlign.Center) + .width('100%') + .height('100%') + .onClick((): void => { + this.playingKey = m.key; + }) } - .width('100%') - .height('100%') - .borderRadius(10) - .clip(true) - .onClick((): void => { - this.openVideo(m); - }) } 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