观影照片支持视频+转码;同看的人改头像标签 (#84, #83)

用户:① 同看的人,按照头像加名字组成标签的形式进行展示。
      ② 确认观影照片是否允许上传视频,不允许则需要改,要求能上传视频,
         视频需要转码,参考旅程中的行程做法。

① 同看的人改头像胶囊(#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:
2026-11-06 10:52:42 +08:00
parent ac666dd7f4
commit 05b86262ff
3 changed files with 336 additions and 64 deletions
@@ -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()))
/* 🔴 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 反馈的就是这个。
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() {
Image(fbImageSrc(k, FB_TOKEN_STORE.get()))
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)
+137 -16
View File
@@ -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,15 +1029,15 @@ export struct FbMovieNewSheet {
.fontColor(this.accentColor())
.width('100%')
/* 第二排:已选人 + 「选择」 */
/* 第二排:已选的人 —— **头像 + 名字的胶囊标签**,流式换行 */
Column({ space: 8 }) {
if (this.picked.length === 0) {
/* 没人选:还是一行可点的空框(点它去选) */
Row({ space: 8 }) {
Text(this.pickedLabel())
Text('还没有选人')
.fontSize(FbMetric.fontSmall)
.fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(FbColors.muted)
.layoutWeight(1)
.flexShrink(1)
Text('选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
@@ -1042,16 +1049,93 @@ export struct FbMovieNewSheet {
.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%')
.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;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
@@ -1259,15 +1343,52 @@ export struct FbMovieNewSheet {
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.existingPhotos, (k: string) => {
Image(fbImageSrc(k, this.token()))
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 })
}, (k: string): string => 'exph_' + k)
}, (m: FbMovieMedia): string => 'exph_' + m.key)
}
.width('100%')
}
+52 -5
View File
@@ -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)
});
}
}