用户:① 同看的人,按照头像加名字组成标签的形式进行展示。
② 确认观影照片是否允许上传视频,不允许则需要改,要求能上传视频,
视频需要转码,参考旅程中的行程做法。
① 同看的人改头像胶囊(#83 追加)
- 原来一行纯文字「已选 2 人:杨永全、红围脖」,人多了被 maxLines(1) 截断;
- 改成每人一颗胶囊:头像(FbAvatar,有图显示图/没图回落色块字) + 名字 + 小叉,
Flex(wrap) 流式换行(Row 不换行会横向溢出,本项目 v76 同款事故);
- ⚠️ FbAvatar 必须传 imageUrl,否则有头像的人也退化成色块字
(首页观影卡 2026-10-19 踩过同一个坑)。
② 观影照片支持视频(#84)
事实核查:原来不允许。POST /movies/<id>/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。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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%')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user