From 05b86262ffa69ba080aec74a4f95a43c9ed3e437 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 6 Nov 2026 10:52:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E7=85=A7=E7=89=87=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=A7=86=E9=A2=91+=E8=BD=AC=E7=A0=81=EF=BC=9B?= =?UTF-8?q?=E5=90=8C=E7=9C=8B=E7=9A=84=E4=BA=BA=E6=94=B9=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=20(#84,=20#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:① 同看的人,按照头像加名字组成标签的形式进行展示。 ② 确认观影照片是否允许上传视频,不允许则需要改,要求能上传视频, 视频需要转码,参考旅程中的行程做法。 ① 同看的人改头像胶囊(#83 追加) - 原来一行纯文字「已选 2 人:杨永全、红围脖」,人多了被 maxLines(1) 截断; - 改成每人一颗胶囊:头像(FbAvatar,有图显示图/没图回落色块字) + 名字 + 小叉, Flex(wrap) 流式换行(Row 不换行会横向溢出,本项目 v76 同款事故); - ⚠️ FbAvatar 必须传 imageUrl,否则有头像的人也退化成色块字 (首页观影卡 2026-10-19 踩过同一个坑)。 ② 观影照片支持视频(#84) 事实核查:原来不允许。POST /movies//photos 走 Upload::image(), 只收 JPG/PNG/GIF/WebP,传视频直接拒。旅程走 Attachment::save(), 内部对 video/* 调 Video::toH264() —— 观影要支持就得接上这条路。 服务端: - Schema VERSION 35→36,event_photos 加 kind/thumb_key/mime 三列; kind 默认 image(老数据全是照片,默认值即正确,不需要回填 UPDATE); 建表语句同步改,新库直接是新结构。 - 上传按 MIME 分流:视频走 toH264 + 新增的 Video::poster() 抽首帧, 图片仍走 Upload::image()(裁剪压缩行为完全不变)。 - Video::poster() 取第 1 秒(手机视频开头常是黑场),失败不阻断上传。 - deletePhoto / destroy 补删 thumb_key,否则删视频留孤儿封面。 - addPhotos 兼容裸键数组与结构化数组,只有一条 INSERT 路径。 端上: - 选择器 IMAGE_TYPE → IMAGE_VIDEO_TYPE(与旅程 FbTripNodeSheet 对齐); 海报选择器保持 IMAGE_TYPE(海报必须是图)。 - FbMovieDetail.photos 从 string[] 升到 FbMovieMedia[](带 kind/thumb/mime), 靠类型检查确保所有读取点都改到。 - 网格里视频显示首帧+播放角标(不解码,一屏 9 个视频各挂解码器必卡死); 全屏查看器用 Video 播放。⚠️ VideoOptions 只有 src/currentProgressRate/ previewUri/controller,controls/autoPlay 是属性方法不是构造参数。 验证:造 H.265 视频实测 needsTranscode=true、toH264 成功转成 h264/yuv420p、 poster 抽出 13KB 封面;完整上传后接口返回 5 条(4 image + 1 video 带 thumb); 端上网格出现 ▶ 角标、点击进全屏查看器且视频在播。 迁移已跑(35→36),4 个 PHP 文件 md5 逐个核对一致。测试数据已清理。 hvigorw assembleHap BUILD SUCCESSFUL。 --- .../ets/components/FbMovieDetailSheet.ets | 148 +++++++++++-- .../main/ets/components/FbMovieNewSheet.ets | 195 ++++++++++++++---- entry/src/main/ets/data/FbMovieRepo.ets | 57 ++++- 3 files changed, 336 insertions(+), 64 deletions(-) diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index 88ff8b4..5354d04 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -17,7 +17,7 @@ import { FbColors, FbMetric, fbAlpha, FB_MASK } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; -import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; +import { FbMovieAttendeeRow, FbMovieDetail, FbMovieMedia } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbAppState } from '../common/Store'; import { FbAvatar, FbRating, FbSectionTitle } from './Widgets'; @@ -98,7 +98,9 @@ export struct FbMovieDetailSheet { /** 查看器当前显示第几张 */ @Local viewerIndex: number = 0; /** 查看器里的图片列表(打开时快照,避免与详情刷新打架) */ - @Local viewerList: string[] = []; + @Local viewerList: FbMovieMedia[] = []; + /** 查看器里视频是否播放失败(换图/换视频时重置) */ + @Local viewerVideoError: boolean = false; /** * 详情里所有可看的图:**票根在前 + 现场照片**。 @@ -107,28 +109,60 @@ export struct FbMovieDetailSheet { * "合成一块展示、同一套灯箱",注释里写明了不再分类)。 * 端上也别分类 —— 用户关心的是"这次观影的照片",不是它的来源。 */ - private allPhotos(d: FbMovieDetail): string[] { - const out: string[] = []; + private allPhotos(d: FbMovieDetail): FbMovieMedia[] { + const out: FbMovieMedia[] = []; + /* 票根:历史数据里它就是一张图(`ticket_key` 单独存在 movie_details), + 这里归并进同一个列表 —— 与网页端"合成一块展示"同口径。 + ⚠️ 票根永远是图片,不会是视频。 */ if (d.ticketKey !== '') { - out.push(d.ticketKey); + out.push({ key: d.ticketKey, kind: 'image', thumb: '', mime: '' }); } - for (const k of d.photos) { - out.push(k); + for (const m of d.photos) { + out.push(m); } return out; } + /** + * 查看器当前这一项的键(越界/空列表时返回空串)。 + * + * ⚠️ 存在的理由:模板里直接写 `viewerList[viewerIndex].key` 在 + * `viewerIndex` 越界(列表刚变成空、或删了最后一张)时会读到 undefined, + * ArkTS 下会直接抛。统一从这里取,越界就是空串 → `Image('')` 不渲染,不崩。 + */ + private viewerCurKey(): string { + if (this.viewerIndex < 0 || this.viewerIndex >= this.viewerList.length) { + return ''; + } + return this.viewerList[this.viewerIndex].key; + } + + /** 查看器当前这一项是不是视频 */ + private viewerCurIsVideo(): boolean { + if (this.viewerIndex < 0 || this.viewerIndex >= this.viewerList.length) { + return false; + } + return this.viewerList[this.viewerIndex].kind === 'video'; + } + /** 打开全屏查看器(点缩略图时) */ private openViewer(key: string): void { - const list: string[] = this.allPhotos(this.detail !== null + const list: FbMovieMedia[] = this.allPhotos(this.detail !== null ? this.detail : this.emptyForViewer()); - const idx: number = list.indexOf(key); + let idx: number = -1; + for (let i = 0; i < list.length; i++) { + if (list[i].key === key) { + idx = i; + break; + } + } if (idx < 0) { return; } this.viewerList = list; this.viewerIndex = idx; this.viewerOpen = true; + this.viewerVideoError = false; } /** 查看器用的空详情(列表为空时 openViewer 不会走到,仅类型需要) */ @@ -149,6 +183,8 @@ export struct FbMovieDetailSheet { } // ⚠️ 必须取模归一到 [0,n):直接加会让 index 越界 / 变成负数 this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n; + /* 换了一项 ⇒ 上一次视频的播放失败提示不该留着(新的一项可能是图片) */ + this.viewerVideoError = false; } /** @@ -375,10 +411,44 @@ export struct FbMovieDetailSheet { // ⇒ 原来只有底部的「上一张/下一张」文字按钮,**没有滑动手势**。 // 现在整张大图可以左右滑(见下面 `parallelGesture`)。 Stack({ alignContent: Alignment.Center }) { - Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) - .width('100%') - .height('76%') - .objectFit(ImageFit.Contain) + /* 🔴 2026-11-06:按类型分别渲染 —— 视频用 `Video` 组件直接播, + 图片仍是 `Image`。 + ⚠️ 不能用 `if (kind === 'video')` 直接读 `viewerList[i].kind`: + 并列表达式里的数组成员访问可以,但要先取到局部变量, + 否则 `viewerIndex` 越界时会在表达式里就崩。 + `viewerCur()` 负责做越界保护。 */ + Stack({ alignContent: Alignment.Center }) { + if (this.viewerCurIsVideo()) { + /* ⚠️ `VideoOptions` 只有 `src` / `currentProgressRate` / `previewUri` / `controller` + (已对照本机 SDK 的 `video.d.ts` 确认)—— + **没有 `controls` / `autoPlay`**,那两个是组件属性方法,必须链式调用。 + 传进去会编译失败:`Argument of type {...} is not assignable to VideoOptions`。 */ + Video({ src: fbImageSrc(this.viewerCurKey(), FB_TOKEN_STORE.get()) }) + .width('100%') + .height('76%') + .objectFit(ImageFit.Contain) + .controls(true) + .autoPlay(true) + .onError((): void => { + this.viewerVideoError = true; + }) + if (this.viewerVideoError) { + Text('这个视频播不了(可能是原格式不被支持),可以到网页端下载后看。') + .fontSize(FbMetric.fontSmall) + .fontColor('#FFFFFFCC') + .textAlign(TextAlign.Center) + .width('80%') + .padding({ top: 12 }) + } + } else { + Image(fbImageSrc(this.viewerCurKey(), FB_TOKEN_STORE.get())) + .width('100%') + .height('76%') + .objectFit(ImageFit.Contain) + } + } + .width('100%') + .height('76%') /* 🔴🔴 左右滑翻页。 ⚠️ 用 **`gesture(PanGesture)`** 而不是 `SwipeGesture`: 本轮在日程页实测过 —— `SwipeGesture` 要求"**快速**划动"才识别, @@ -647,21 +717,55 @@ export struct FbMovieDetailSheet { .alignItems(VerticalAlign.Center) Grid() { - ForEach(this.allPhotos(d), (k: string) => { + ForEach(this.allPhotos(d), (m: FbMovieMedia) => { GridItem() { // 🔴 缩略图必须按**照片真实比例**做格子。 // 实测线上照片是 **270×377(竖版,比例 ≈0.716)**, // 而第一版用的是**正方形格子**(GridItem 没有固定高度, // 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半, // "看起来格式不对"。用户 2026-10-12 反馈的就是这个。 - Column() { - Image(fbImageSrc(k, FB_TOKEN_STORE.get())) - .width('100%') - .height('100%') - .objectFit(ImageFit.Cover) - .alt($r('app.media.fb_ic_movies')) + Stack({ alignContent: Alignment.Center }) { + /* 🔴 2026-11-06:视频**不在这里解码** —— 显示服务端抽好的首帧。 + 一屏 9 个视频若各自挂解码器必然卡死,这是必守的取舍。 + ⚠️ 没有封面(抽帧失败)时回落通用影片图标,仍可点开播。 */ + if (m.kind === 'video') { + if (m.thumb !== '') { + Image(fbImageSrc(m.thumb, FB_TOKEN_STORE.get())) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .alt($r('app.media.fb_ic_movies')) + } else { + Column() + .width('100%') + .height('100%') + .backgroundColor('#33000000') + Image($r('app.media.fb_ic_movies')) + .width(28) + .height(28) + } + /* 播放角标:白色三角 + 半透明圆底,一眼看出"这个能播" */ + Column() { + Text('▶') + .fontSize(14) + .fontColor(Color.White) + } + .width(30) + .height(30) + .borderRadius(15) + .backgroundColor('#99000000') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } else { + Image(fbImageSrc(m.key, FB_TOKEN_STORE.get())) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .alt($r('app.media.fb_ic_movies')) + } } .width('100%') + .height('100%') // ⚠️ 用 `aspectRatio` 指定比例,而不是靠父容器推: // 在 Grid 里 `height('100%')` 拿不到确定的高度 // (行高是内容决定的),必须显式给比例。 @@ -669,10 +773,10 @@ export struct FbMovieDetailSheet { .clip(true) .borderRadius(FbMetric.radiusS) .onClick((): void => { - this.openViewer(k); + this.openViewer(m.key); }) } - }, (k: string): string => 'ph_' + k) + }, (m: FbMovieMedia): string => 'ph_' + m.key) } .columnsTemplate('1fr 1fr 1fr') .columnsGap(6) diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets index 5a4baff..4296870 100644 --- a/entry/src/main/ets/components/FbMovieNewSheet.ets +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -43,11 +43,13 @@ import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieAttendeeRow, FbMovieDetail, FbMovieDraft, - FbMovieRepo } from '../data/FbMovieRepo'; + FbMovieMedia, FbMovieRepo } from '../data/FbMovieRepo'; import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbApiBase, fbImageUrl } from '../data/FbHttp'; import { fbImageSrc } from '../data/FbAttachQueue'; +/* 🔴 2026-11-06:同看的人改成「头像 + 名字」胶囊,需要 FbAvatar */ +import { FbAvatar } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose, FbSheetTitleBar } from './FbSheetClose'; import { FbCinemaPickSheet } from './FbCinemaPickSheet'; @@ -141,7 +143,7 @@ export struct FbMovieNewSheet { * ⚠️ 与 `photoUris` 分开存:那个是"待上传的本地 uri",语义不同, * 混在一起会让"保存时传哪些"变得说不清。 */ - @Local existingPhotos: string[] = []; + @Local existingPhotos: FbMovieMedia[] = []; /** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */ @Local doubanMeta: string = ''; @@ -498,7 +500,12 @@ export struct FbMovieNewSheet { private async pickPhotos(): Promise { try { const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); - opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + /* 🔴 2026-11-06:从 `IMAGE_TYPE` 改成 `IMAGE_VIDEO_TYPE` —— + 用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改, + 要求能够上传视频,视频需要转码,参考旅程中的行程做法。」 + 旅程那边就是 `IMAGE_VIDEO_TYPE`(见 `FbTripNodeSheet.pickPhotos`), + 这里对齐。转码在服务端做(`Video::toH264`),端上只负责把文件传上去。 */ + opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE; opts.maxSelectNumber = 9; const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); const sel: picker.PhotoSelectResult = await p.select(opts); @@ -1022,38 +1029,115 @@ export struct FbMovieNewSheet { .fontColor(this.accentColor()) .width('100%') - /* 第二排:已选人 + 「选择」 */ - Row({ space: 8 }) { - Text(this.pickedLabel()) - .fontSize(FbMetric.fontSmall) - .fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - .flexShrink(1) - Text('选择') - .fontSize(FbMetric.fontTiny) - .fontColor(this.accentColor()) + /* 第二排:已选的人 —— **头像 + 名字的胶囊标签**,流式换行 */ + Column({ space: 8 }) { + if (this.picked.length === 0) { + /* 没人选:还是一行可点的空框(点它去选) */ + Row({ space: 8 }) { + Text('还没有选人') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .layoutWeight(1) + Text('选择') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .width('100%') + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.openPersonPick(); + }) + } else { + /* 🔴 用户 2026-11-06:「同看的人,按照头像加名字组成标签的形式进行展示。」 + ⇒ 每人一颗胶囊:头像(有图显示图、没图回落色块字)+ 名字 + 小叉(删掉)。 + 用 `Flex(wrap)` 而不是 `Row`:人多时 `Row` 不换行会横向溢出(本项目 v76 的坑)。 */ + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.pickedPeople(), (p: FbRosterPerson) => { + Row({ space: 6 }) { + FbAvatar({ + name: p.shown !== '' ? p.shown : p.name, + color: p.avatarColor !== '' ? p.avatarColor : this.accentColor(), + dia: 24, + /* ⚠️ 必须传 imageUrl:不传的话有头像的人也退化成色块字 + (首页观影卡踩过同一个坑,见 FbMovieDetailSheet 的注释) */ + imageUrl: p.avatar !== '' ? fbImageSrc(p.avatar, this.token()) : '' + }) + Text(p.shown !== '' ? p.shown : p.name) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .maxLines(1) + /* 小叉:从已选里移除(不影响名册,只是这次不放他) */ + Text('✕') + .fontSize(10) + .fontColor(FbColors.faint) + .padding({ left: 2, right: 2 }) + .onClick((): void => { + this.picked = this.picked.filter((x: number): boolean => x !== p.id); + }) + } + .padding({ left: 4, right: 8, top: 3, bottom: 3 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .margin({ right: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + }, (p: FbRosterPerson): string => 'pk_' + p.id.toString()) + } + .width('100%') + + /* 底部「+ 选择」:加人 */ + Row({ space: 4 }) { + Text('+ 选择') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .padding({ left: 12, right: 12, top: 5, bottom: 5 }) + .borderRadius(FbMetric.radiusPill) + .border({ width: 1, color: fbAlpha(this.accentColor(), 0.4) }) + .onClick((): void => { + this.openPersonPick(); + }) + } } .width('100%') - .height(42) - .padding({ left: 12, right: 12 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .alignItems(VerticalAlign.Center) - .onClick((): void => { - if (this.roster.length === 0) { - this.bad = true; - this.note2 = '名册还没同步,同看人稍后可以在网页端补'; - return; - } - this.personPickOpen = true; - }) + .alignItems(HorizontalAlign.Start) } .width('100%') .alignItems(HorizontalAlign.Start) } + /** + * 已选的同看人(按 `picked` 的顺序映射成完整人物对象)。 + * + * ⚠️ 返回数组而不是在模板里内联 find:模板里做复杂查询会让 + * `ForEach` 的依赖追踪变模糊(本项目反复踩过),这里一次算清。 + * ⚠️ 名单里找不到的 id(人被删了)直接跳过,不占位。 + */ + private pickedPeople(): FbRosterPerson[] { + const out: FbRosterPerson[] = []; + for (const id of this.picked) { + const p: FbRosterPerson | undefined = + this.roster.find((x: FbRosterPerson): boolean => x.id === id); + if (p !== undefined) { + out.push(p); + } + } + return out; + } + + /** 打开「选同看人」弹框(名册没同步时给提示,不硬开一个空列表) */ + private openPersonPick(): void { + if (this.roster.length === 0) { + this.bad = true; + this.note2 = '名册还没同步,同看人稍后可以在网页端补'; + return; + } + this.personPickOpen = true; + } + /** * 海报(1 张;可从豆瓣抓、也可自己选)。 * @@ -1259,15 +1343,52 @@ export struct FbMovieNewSheet { .width('100%') Flex({ wrap: FlexWrap.Wrap }) { - ForEach(this.existingPhotos, (k: string) => { - Image(fbImageSrc(k, this.token())) - .width(60) - .height(60) - .borderRadius(4) - .objectFit(ImageFit.Cover) - .alt($r('app.media.fb_ic_movies')) - .margin({ right: 8, bottom: 8 }) - }, (k: string): string => 'exph_' + k) + ForEach(this.existingPhotos, (m: FbMovieMedia) => { + /* 🔴 2026-11-06:已有的视频显示**首帧 + 播放角标**(不解码), + 与详情页同一套呈现口径。 */ + Stack({ alignContent: Alignment.Center }) { + if (m.kind === 'video') { + if (m.thumb !== '') { + Image(fbImageSrc(m.thumb, this.token())) + .width(60) + .height(60) + .borderRadius(4) + .objectFit(ImageFit.Cover) + .alt($r('app.media.fb_ic_movies')) + } else { + Column() + .width(60) + .height(60) + .borderRadius(4) + .backgroundColor(FbColors.inputBg) + Image($r('app.media.fb_ic_movies')) + .width(22) + .height(22) + } + Column() { + Text('▶') + .fontSize(11) + .fontColor(Color.White) + } + .width(22) + .height(22) + .borderRadius(11) + .backgroundColor('#99000000') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } else { + Image(fbImageSrc(m.key, this.token())) + .width(60) + .height(60) + .borderRadius(4) + .objectFit(ImageFit.Cover) + .alt($r('app.media.fb_ic_movies')) + } + } + .width(60) + .height(60) + .margin({ right: 8, bottom: 8 }) + }, (m: FbMovieMedia): string => 'exph_' + m.key) } .width('100%') } diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index d7be894..7abee7b 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -307,8 +307,15 @@ export interface FbMovieDetail { reviewAvg: number; /** 同看的人 */ attendees: FbMovieAttendeeRow[]; - /** 照片(相对键) */ - photos: string[]; + /** + * 观影照片 / 视频。 + * + * 🔴 2026-11-06:从 `string[]`(纯键)升级成 `FbMovieMedia[]` —— + * 支持视频后必须带类型与封面,否则端上不知道哪个要画播放按钮。 + * ⚠️ 所有读取 `.photos` 的地方都要改用 `.key`(有类型检查兜着, + * 漏改会直接编译失败,不会静默出错)。 + */ + photos: FbMovieMedia[]; } // ============================================================ @@ -1541,6 +1548,37 @@ interface FbApiMovieDetail { interface FbApiPhoto { id?: number; key?: string; + /** + * `'image'` / `'video'`。 + * + * 🔴 2026-11-06:观影照片支持视频后新增。 + * 端上靠它决定"显示图片"还是"显示首帧 + 播放按钮"。 + * ⚠️ 不要靠扩展名猜 —— `.mp4` 也可能是被误命名的图片,MIME 才靠谱, + * 而服务端存的就是判定好的 `kind`。 + */ + kind?: string; + /** 视频首帧封面(相对键;图片为空)。列表用它显示,不解码。 */ + thumb?: string; + /** MIME(视频转码后是 video/mp4) */ + mime?: string; +} + +/** + * 观影照片/视频(端上统一形状)。 + * + * 🔴 2026-11-06:原来 `photos` 只是 `string[]`(纯键),支持视频后 + * 必须带上类型与封面 —— 否则端上无从知道哪个要画播放按钮、 + * 也没法在不解码的前提下把视频画进网格。 + */ +export interface FbMovieMedia { + /** 相对键 */ + key: string; + /** 图片还是视频 */ + kind: string; + /** 视频封面键(空 = 没有封面,端上用通用图标兜底) */ + thumb: string; + /** MIME */ + mime: string; } /** 空的详情(出错时用,避免调用方判 null) */ @@ -1576,13 +1614,22 @@ function parseDetail(id: number, json: string): FbMovieDetail { } // 照片:接口给的是 `[{id, key}]`,这里只留相对键(界面按 key 拼图片地址) - const photos: string[] = []; + const photos: FbMovieMedia[] = []; if (o.photos !== undefined && Array.isArray(o.photos)) { for (const ph of o.photos) { const k: string = strOf(ph.key); - if (k !== '') { - photos.push(k); + if (k === '') { + continue; } + const kind: string = strOf(ph.kind); + photos.push({ + key: k, + /* ⚠️ 默认按图片处理:老接口(或网页端 PUT 回来的裸键)没有 kind, + 那些数据全是照片 —— 与服务端列的默认值 'image' 一致。 */ + kind: kind === 'video' ? 'video' : 'image', + thumb: strOf(ph.thumb), + mime: strOf(ph.mime) + }); } }