<template>
	<view class="reel-root" :style="rootStyle">
		<view v-if="clips.length === 0 && phase === 'loading'" class="reel-loading" :style="rootStyle">
			<view class="reel-loading-inner">
				<view class="reel-loading-spinner" />
				<text class="reel-loading-txt">{{ copy.loading }}</text>
			</view>
		</view>
		<view v-else-if="clips.length === 0 && phase === 'empty'" class="reel-empty" :style="rootStyle">
			<view class="reel-empty-inner">
				<text class="reel-empty-icon">🎬</text>
				<text class="reel-empty-txt">{{ copy.empty }}</text>
			</view>
		</view>

		<swiper
			v-if="clips.length > 0"
			:key="'reel-sw-' + swiperEpoch"
			class="reel-swiper"
			vertical="true"
			:acceleration="true"
			:circular="swiperCircular"
			:current="activeIndex"
			:disable-touch="goodsTouchLock"
			:style="rootStyle"
			@change="onSlideChange"
		>
			<swiper-item v-for="(clip, index) in clips" :key="clipKey(clip, index)">
				<view v-if="shouldMount(index)" class="reel-slide" :style="rootStyle">
					<!-- 合辑付费锁定：封面 + 解锁按钮 -->
					<view
						v-if="isClipLocked(clip)"
						class="paid-video-overlay"
						:style="paidOverlayStyle"
					>
						<image
							class="paid-video-cover"
							:style="paidOverlayStyle"
							:src="mediaCover(clip)"
							mode="aspectFill"
						/>
						<view class="paid-video-mask" />
						<view class="paid-video-btn-wrap">
							<view class="paid-video-btn" @click.stop="handlePayForVideo(clip)">
								<image class="paid-video-btn-icon" src="/static/imgs/icon/ic_d.png" mode="aspectFit" />
								<text
									v-if="resolvePaidAmount(clip)"
									class="paid-video-btn-amount"
								>{{ resolvePaidAmount(clip) }}</text>
								<text class="paid-video-btn-text">{{ copy.payToWatch }}</text>
							</view>
						</view>
					</view>
					<feed-media-cell
						v-else
						:key="mediaCellKey(clip, index)"
						:player-id="mediaPlayerId(clip, index)"
						:src="playbackSrc(clip)"
						:remote-src="mediaSrc(clip)"
						:cover="mediaCover(clip)"
						:cover-enabled="showCover"
						:playing="isClipPlaying(clip)"
						:frame-w="frameW"
						:frame-h="frameH"
						:fit-mode="(clip.video && clip.video.fit) || 'cover'"
						@fallback="onPlaybackFallback(index, $event)"
					/>

					<!-- 主点击区：占满视频上方，底部 240px+safe 让位给用户卡 -->
					<view
						v-if="isActiveSlide(index) && !isClipLocked(clip)"
						class="reel-tap-layer"
						:style="tapLayerStyle"
						@click.stop="togglePause(index)"
					/>

					<!-- 暂停态：全屏居中图标 + 点击任意视频区域继续播放 -->
					<view
						v-if="isActiveSlide(index) && !isClipLocked(clip) && !isClipPlaying(clip)"
						class="reel-pause-overlay"
						:style="pauseOverlayStyle"
						@click.stop="togglePause(index)"
					>
						<view class="reel-pause-hint">
							<image class="reel-pause-icon-img" src="/static/icon/play.png" mode="aspectFit" />
						</view>
					</view>

					<!--
						底部用户卡 —— 直接内联在 vertical-reel.vue 模板里（不再做独立 vue 子组件）。
						原因：uni-app 把 .vue 子作为 webview subwindow 嵌入 nvue 父；如果 user-card 单独
						再做一层 .vue 孙组件，会变成嵌套 subwindow，不能跟同级 video 叠加，点击穿透到 native video。
						HKLive 工程 components/find/play.vue 的按钮也是这样直接内联到父 vue 模板里。
					-->
					<view
						class="reel-bottom-overlay"
						:class="{ 'reel-bottom-overlay--expanded': isCaptionExpanded(clip) }"
						:style="bottomOverlayStyle"
						@click.stop
					>
						<view class="reel-bottom-scrim reel-bottom-scrim-deep" />
						<view class="reel-bottom-scrim reel-bottom-scrim-top" />
						<view class="fuc">
							<!-- 左：头像 + 关注 -->
							<view class="fuc-left">
								<view class="fuc-avatar-wrap" @click.stop="onAvatarTap(index)">
									<image
										v-if="userAvatar(clip)"
										class="fuc-avatar"
										:src="userAvatar(clip)"
										mode="aspectFill"
									/>
									<view v-else class="fuc-avatar fuc-avatar-fallback">
										<text class="fuc-avatar-initial">{{ avatarInitial(clip) }}</text>
									</view>
								</view>
								<view
									:class="!!clip.isFollow ? 'fuc-follow fuc-followed' : 'fuc-follow'"
									@click.stop="onFollowTap(index)"
								>
									<text
										:class="!!clip.isFollow ? 'fuc-followed-text' : 'fuc-follow-text'"
									>{{ clip.isFollow ? copy.followed : copy.followPlus }}</text>
								</view>
							</view>

							<!-- 右：信息 + 操作按钮 -->
							<view class="fuc-body">
								<view class="fuc-top">
									<view class="fuc-nickname-wrap" @click.stop="onAvatarTap(index)">
										<text class="fuc-nickname" :lines="1">@{{ (clip.user && clip.user.nickname) || copy.guest }}</text>
									</view>
									<view class="fuc-actions">
										<view class="fuc-action" @click.stop="onLikeTap(index)">
											<text
												:class="isLiked(clip) ? 'fuc-action-icon fuc-action-liked' : 'fuc-action-icon'"
											>{{ isLiked(clip) ? '♥' : '♡' }}</text>
										<!-- 点赞 -->
											<text
												v-if="showBottomBarLikeCount && formatCount(clip.likes)"
												class="fuc-action-num"
											>{{ formatCount(clip.likes) }}</text>
										</view>
										<!-- <view class="fuc-action fuc-action-spaced" @click.stop="onPraiseTap(index)">
											<text class="fuc-action-icon">👍</text>
										</view> -->
										<view class="fuc-action fuc-action-spaced" @click.stop="onCommentTap(index)">
											<text class="fuc-action-icon">💬</text>
											<text v-if="formatCount(clip.comments)" class="fuc-action-num">{{ formatCount(clip.comments) }}</text>
										</view>
										<view
											v-if="!isSelfClip(clip)"
											class="fuc-action fuc-action-spaced"
											@click.stop="onReportTap(index)"
										>
											<text class="fuc-action-icon">🚩</text>
										</view>
									</view>
								</view>
								<view v-if="plainCaption(clip)" class="fuc-caption-wrap">
									<view v-if="!isCaptionExpanded(clip)" class="fuc-caption-collapsed">
										<text class="fuc-caption fuc-caption--clip" :lines="1">{{ collapsedCaption(clip) }}</text>
										<text
											v-if="showCaptionToggle(clip)"
											class="fuc-caption-toggle"
											@click.stop="toggleCaption(index)"
										>{{ copy.captionExpand }}</text>
									</view>
									<view v-else class="fuc-caption-expanded">
										<text class="fuc-caption">{{ plainCaption(clip) }}</text>
										<text
											v-if="showCaptionToggle(clip)"
											class="fuc-caption-toggle"
											@click.stop="toggleCaption(index)"
										>{{ copy.captionCollapse }}</text>
									</view>
								</view>
								<!-- 合辑入口放回 fuc-body：iOS vue-in-nvue 对 absolute 容器内 flex 行后的兄弟节点易丢布局 -->
								<view
									v-if="isActiveSlide(index) && seriesListOf(clip).length > 0"
									class="series-entry"
									@click.stop="openSeriesSheet(index)"
								>
									<text class="series-entry-txt">{{ copy.viewMoreSeries }} ({{ seriesListOf(clip).length }})</text>
								</view>
							</view>
						</view>
						<!-- 挂商品：自定义横滑（iOS 竖滑 swiper 内 scroll-view 常被抢走手势） -->
						<view
							v-if="isActiveSlide(index) && goodsListOf(clip).length > 0"
							class="reel-goods"
							@click.stop
							@touchstart="onGoodsPanStart"
							@touchmove="onGoodsPanMove"
							@touchend="onGoodsPanEnd"
							@touchcancel="onGoodsPanEnd"
						>
							<view class="reel-goods-viewport" :style="{ width: frameW + 'px' }">
								<view class="reel-goods-track" :style="goodsTrackStyle(clip)">
									<view
										v-for="(goods, gIndex) in goodsListOf(clip)"
										:key="goods.id || gIndex"
										class="reel-goods-item"
										:style="{ width: goodsCardWidth + 'px' }"
										@tap="onGoodsItemTap(goods)"
									>
										<image
											class="reel-goods-img"
											:src="goodsCover(goods)"
											mode="aspectFill"
										/>
										<view class="reel-goods-body">
											<text class="reel-goods-title" :lines="2">{{ goods.title || '' }}</text>
											<view class="reel-goods-foot">
												<text class="reel-goods-price">HKD{{ goods.price }}</text>
												<view class="reel-goods-buy">
													<text class="reel-goods-buy-txt">{{ copy.buy }}</text>
												</view>
											</view>
										</view>
									</view>
									<view class="reel-goods-tail" />
								</view>
							</view>
						</view>
					</view>
				</view>
			</swiper-item>
		</swiper>

		<!--
			评论弹层
			⚠️ 关键：mask 只覆盖 sheet 顶部以上的区域（屏幕高度 - sheet 高度）
			    避免 mask 和 sheet/inputbar 在屏幕底部重叠导致的 hit-test 误判，
			    这是之前"点 input 就把弹层收起"的根因 ——
			    部分 webview 在 native input 上 hit-test 会优先把事件给同区域的非 input 兄弟节点。
		-->
		<view
			v-if="commentVisible"
			class="reel-comment-mask"
			:style="commentMaskStyle"
			@click.stop="closeComment"
		/>
		<view
			v-if="commentVisible"
			class="reel-comment-sheet"
			:style="commentSheetStyle"
		>
			<view class="reel-comment-header">
				<text class="reel-comment-title">评论</text>
				<view class="reel-comment-close" @click.stop="closeComment">
					<text class="reel-comment-close-icon">×</text>
				</view>
			</view>
			<comment-thread
				:find-id="commentClipId"
				:list="commentList"
				:count="(clips[commentClipIndex] && clips[commentClipIndex].comments) || 0"
				:list-height="commentListHeight"
				:safe-bottom-inset="safeBottom"
				:tabbar-height="0"
				@complete="onCommentComplete"
			/>
		</view>

		<!-- 合辑视频列表弹层 -->
		<view
			v-if="seriesSheetVisible"
			class="reel-comment-mask"
			:style="seriesMaskStyle"
			@click.stop="closeSeriesSheet"
		/>
		<view
			v-if="seriesSheetVisible"
			class="series-sheet"
			:style="seriesSheetStyle"
		>
			<view class="series-sheet-header">
				<text class="series-sheet-title">{{ copy.seriesVideos }}</text>
				<view class="series-sheet-close" @click.stop="closeSeriesSheet">
					<text class="series-sheet-close-icon">×</text>
				</view>
			</view>
			<scroll-view scroll-y="true" class="series-sheet-scroll" :style="{ height: seriesListHeight + 'px' }">
				<view
					v-for="(seriesItem, sIndex) in currentSeriesList"
					:key="seriesItem.id || sIndex"
					class="series-row"
					@click.stop="onSeriesItemTap(seriesItem)"
				>
					<image
						class="series-row-cover"
						:src="seriesCover(seriesItem)"
						mode="aspectFill"
					/>
					<view class="series-row-body">
						<view class="series-row-title-line">
							<text class="series-row-title" :lines="1">{{ seriesItem.title || copy.noTitle }}</text>
							<view class="series-row-actions">
								<!-- is_paid=0 免费；is_paid=1 且未解锁显示锁；已解锁显示文案 -->
								<view class="series-row-badge">
									<image
										v-if="isSeriesLocked(seriesItem)"
										class="series-row-lock"
										src="/static/imgs/icon/ic_lock.png"
										mode="aspectFit"
									/>
									<text v-else-if="isSeriesUnlocked(seriesItem)" class="series-row-unlocked">{{ copy.unlocked }}</text>
									<text v-else class="series-row-free">{{ copy.free }}</text>
								</view>
								<view class="series-row-play">
									<text class="series-row-play-icon">▶</text>
								</view>
							</view>
						</view>
						<text
							v-if="isSeriesLocked(seriesItem) && seriesPaidAmount(seriesItem)"
							class="series-row-price"
						>{{ seriesPaidAmount(seriesItem) }} {{ copy.diamonds }}</text>
						<text class="series-row-time">{{ seriesItem.create_time_text || '' }}</text>
					</view>
				</view>
			</scroll-view>
		</view>

		<!-- 钻石余额不足 -->
		<live-room-insufficient-balance-modal
			:show="showInsufficientBalanceModal"
			:title="copy.insufficientBalance"
			:subtitle="copy.rechargeToContinue"
			:buttonText="copy.goToRecharge"
			@close="closeInsufficientBalanceModal"
			@confirm="goToRecharge"
		/>

		<!-- 支付确认 -->
		<live-video-paypop
			:show="showPayPopup"
			icon="/static/imgs/icon/ic_pay_diam.png"
			:title="payPopupTitle"
			:subtitle="payPopupSubtitle"
			:confirmText="copy.confirm"
			:closeText="copy.cancel"
			@close="closePayPopup"
			@confirm="handlePayPopupConfirm"
		/>
	</view>
</template>

<script>
import FeedMediaCell from '@/components/feed-reel/media-cell.vue'
import CommentThread from '@/components/feed-comment/comment-thread.vue'
import liveRoomInsufficientBalanceModal from '@/components/live-room/insufficient-balance-modal.vue'
import liveVideoPaypop from '@/components/uni-popup/live-video-paypop.vue'
import {
	fetchVideoFeedPage,
	fetchSeriesVideoList,
	toggleClipLike,
	toggleClipFollow,
	fetchClipComments,
	fetchDiamondBalance,
	unlockFeedVideo
} from '@/features/feed/feed-gateway'
import { readFeedPage, writeFeedPage } from '@/features/feed/feed-list-cache'
import {
	resolvePlaybackUrl,
	prefetchNeighbors,
	readCachedPlaybackUrl,
	dropCachedPlaybackUrl
} from '@/features/feed/video-asset-cache'
import { stripHtml } from '@/features/feed/clip-normalize'
import { absolutizeMedia } from '@/features/feed/media-resolve'
import { buildOssUrl } from '@/core/media/oss-url'
import appSettings from '@/core/config/app-settings'
import { STORAGE } from '@/core/constants/storage-keys'
import { stashSeriesHandoff, normalizeSeriesClip } from '@/features/feed/series-handoff'
import {
	markSeriesItemUnlocked,
	applyUnlockCacheToItem,
	applyUnlockCacheToSeriesList
} from '@/features/feed/series-unlock-cache'

const PREFETCH_TAIL = 2

/** nvue 内嵌子 webview 常无 vue-i18n，文案走本地表兜底 */
const REEL_COPY = {
	'zh-cn': {
		loading: '加载中…',
		empty: '暂无视频',
		needLogin: '请先登录',
		captionExpand: '展开',
		captionCollapse: '收起',
		followPlus: '+ 关注',
		followed: '已关注',
		guest: '游客',
		payToWatch: '付费观看',
		insufficientBalance: '钻石余额不足',
		rechargeToContinue: '储值后才可继续购买',
		goToRecharge: '去储值',
		confirmUnlockContent: '确认支付 {amount} 钻石解锁观看？',
		confirm: '确认',
		cancel: '关闭',
		unlocking: '解锁中...',
		unlockSuccess: '解锁成功',
		unlockFailed: '解锁失败',
		payDiamonds: '支付{amount}钻石',
		videoInfoIncomplete: '视频信息缺失',
		viewMoreSeries: '查看更多合辑视频',
		seriesVideos: '合辑视频',
		free: '免费',
		unlocked: '已解锁',
		noTitle: '无标题',
		diamonds: '钻石',
		buy: '购买'
	},
	'zh-Hant': {
		loading: '加載中…',
		empty: '暫無視頻',
		needLogin: '請先登錄',
		captionExpand: '展開',
		captionCollapse: '收起',
		followPlus: '+ 關注',
		followed: '已關注',
		guest: '遊客',
		payToWatch: '付費觀看',
		insufficientBalance: '鑽石餘額不足',
		rechargeToContinue: '儲值後才可繼續購買',
		goToRecharge: '去儲值',
		confirmUnlockContent: '確認支付 {amount} 鑽石解鎖觀看？',
		confirm: '確認',
		cancel: '關閉',
		unlocking: '解鎖中...',
		unlockSuccess: '解鎖成功',
		unlockFailed: '解鎖失敗',
		payDiamonds: '支付{amount}鑽石',
		videoInfoIncomplete: '視頻信息缺失',
		viewMoreSeries: '查看更多合輯視頻',
		seriesVideos: '合輯視頻',
		free: '免費',
		unlocked: '已解鎖',
		noTitle: '無標題',
		diamonds: '鑽石',
		buy: '購買'
	},
	en: {
		loading: 'Loading…',
		empty: 'No videos',
		needLogin: 'Please sign in first',
		captionExpand: 'Expand',
		captionCollapse: 'Collapse',
		followPlus: '+ Follow',
		followed: 'Following',
		guest: 'Guest',
		payToWatch: 'Pay to Watch',
		insufficientBalance: 'Insufficient Diamond Balance',
		rechargeToContinue: 'Please top up to continue',
		goToRecharge: 'Top Up',
		confirmUnlockContent: 'Pay {amount} diamonds to unlock?',
		confirm: 'Confirm',
		cancel: 'Close',
		unlocking: 'Unlocking...',
		unlockSuccess: 'Unlocked',
		unlockFailed: 'Unlock failed',
		payDiamonds: 'Pay {amount} Diamonds',
		videoInfoIncomplete: 'Video info incomplete',
		viewMoreSeries: 'More series videos',
		seriesVideos: 'Series',
		free: 'Free',
		unlocked: 'Unlocked',
		noTitle: 'Untitled',
		diamonds: 'diamonds',
		buy: 'Buy'
	}
}

function resolveReelLang() {
	let code = ''
	try {
		code = uni.getStorageSync(STORAGE.language) || uni.getLocale() || ''
	} catch (_) {
		code = ''
	}
	if (!code || code === 'auto') {
		try {
			code = uni.getLocale() || 'zh-cn'
		} catch (_) {
			code = 'zh-cn'
		}
	}
	if (REEL_COPY[code]) return code
	if (code === 'zh-HK' || code === 'zh-TW' || String(code).indexOf('Hant') >= 0) return 'zh-Hant'
	if (String(code).indexOf('zh') === 0) return 'zh-cn'
	if (String(code).indexOf('en') === 0) return 'en'
	return 'zh-cn'
}

export default {
	name: 'FeedVerticalReel',
	components: {
		FeedMediaCell,
		CommentThread,
		liveRoomInsufficientBalanceModal,
		liveVideoPaypop
	},
	props: {
		frameW: { type: Number, default: 375 },
		frameH: { type: Number, default: 667 },
		safeBottom: { type: Number, default: 0 },
		/**
		 * 底部额外抬高（仅发现 Tab / index.nvue 传入自定义 tabbar 高度）。
		 * reel.nvue / series-reel 不传，默认 0，避免把合辑入口顶出可视区。
		 */
		bottomInset: { type: Number, default: 0 },
		tabActive: { type: Boolean, default: true },
		showCover: { type: Boolean, default: true },
		autoBootstrap: { type: Boolean, default: true },
		entryFindId: { type: [String, Number], default: '' },
		entryLabelId: { type: [String, Number], default: '' },
		/** 个人主页入口：works | likes | myfind */
		entryListType: { type: [String, Number], default: '' },
		entryUserNo: { type: [String, Number], default: '' },
		entryPage: { type: [String, Number], default: 1 },
		entryCompId: { type: [String, Number], default: '' },
		entryOrderType: { type: [String, Number], default: '' },
		entryIsHotDian: { type: [String, Number], default: '' },
		/** 搜索入口关键词 */
		entrySearch: { type: [String, Number], default: '' },
		/** 列表已全部加载后，滑到末尾可循环回第一条（用于 reel 详情页等有限列表） */
		loopAtEnd: { type: Boolean, default: false }
	},
	data() {
		return {
			entryBootstrapped: false,
			clips: [],
			activeIndex: 0,
			pageNo: 1,
			pageEnd: 1,
			listFilters: {},
			phase: 'idle',
			/** 入口打开后再解锁 circular，避免 swiper circular 导致前几条 video 黑屏 */
			circularUnlocked: false,
			/** 强制重建 swiper / 当前 video，规避 iOS 首条黑屏 */
			swiperEpoch: 0,
			mediaEpoch: 0,
			/** 触摸商品条时锁定竖滑，避免 iOS 抢走横向滑动 */
			goodsTouchLock: false,
			/** 商品条横向偏移（自定义横滑） */
			goodsOffsetX: 0,
			// ─── 评论弹层 ───
			commentVisible: false,
			commentLoading: false,
			commentList: [],
			commentClipId: 0,
			commentClipIndex: -1,
			// ─── 合辑列表弹层 ───
			seriesSheetVisible: false,
			currentSeriesList: [],
			seriesAnchorClip: null,
			/** 合辑本地模式：用传入 series_list 播放，不请求列表接口 */
			seriesLocalMode: false,
			/** 进入合辑页时的父级 find.id，解锁后用 getList 补播放地址（不走 videoseriesitem/list） */
			seriesParentFindId: '',
			// ─── 合辑付费解锁 ───
			showInsufficientBalanceModal: false,
			showPayPopup: false,
			currentPayVideoItem: null,
			unlockBusy: false
		}
	},
	computed: {
		copy() {
			// nvue 内嵌子 webview 常无 vue-i18n，禁止触碰 this.$t / $t.bind
			return REEL_COPY[resolveReelLang()] || REEL_COPY['zh-cn']
		},
		showBottomBarLikeCount() {
			return !!appSettings.showBottomBarLikeCount
		},
		/** 合辑入口才展示付费解锁遮罩（依赖 compID） */
		hasCompId() {
			const fromEntry = this.entryCompId
			const fromFilters = this.listFilters && this.listFilters.compID
			const id = fromEntry != null && fromEntry !== '' ? fromEntry : fromFilters
			return id != null && id !== ''
		},
		paidOverlayStyle() {
			return {
				width: `${this.frameW}px`,
				height: `${this.frameH}px`
			}
		},
		payPopupTitle() {
			const item = this.currentPayVideoItem || this.clips[this.activeIndex]
			const paidAmount = this.resolvePaidAmount(item)
			if (this.copy.payDiamonds) {
				return this.copy.payDiamonds.replace('{amount}', paidAmount)
			}
			return `支付${paidAmount}钻石`
		},
		payPopupSubtitle() {
			const item = this.currentPayVideoItem || this.clips[this.activeIndex]
			const paidAmount = this.resolvePaidAmount(item)
			if (this.copy.confirmUnlockContent) {
				return this.copy.confirmUnlockContent.replace('{amount}', paidAmount)
			}
			return '解锁观看完整视频'
		},
		seriesSheetDesiredHeight() {
			return Math.round(this.frameH * 0.6)
		},
		seriesSheetStyle() {
			const h = this.seriesSheetDesiredHeight
			const inset = this.bottomInset || 0
			return {
				width: `${this.frameW}px`,
				height: `${h}px`,
				bottom: `${inset}px`
			}
		},
		seriesMaskStyle() {
			const sheetH = this.seriesSheetDesiredHeight
			const inset = this.bottomInset || 0
			return {
				width: `${this.frameW}px`,
				top: '0px',
				bottom: `${sheetH + inset}px`,
				zIndex: 60
			}
		},
		seriesListHeight() {
			const headerPx = uni.upx2px(88)
			return Math.max(160, this.seriesSheetDesiredHeight - headerPx)
		},
		rootStyle() {
			return {
				width: `${this.frameW}px`,
				height: `${this.frameH}px`
			}
		},
		tapLayerStyle() {
			const row = this.clips[this.activeIndex]
			const paused = row && !this.isClipPlaying(row)
			if (paused) {
				return {
					width: `${this.frameW}px`,
					height: `${this.frameH}px`
				}
			}
			let bottomReserve = 240 + (this.safeBottom || 0) + (this.bottomInset || 0)
			if (this.goodsListOf(row).length) bottomReserve += uni.upx2px(174)
			if (this.seriesListOf(row).length) bottomReserve += uni.upx2px(70)
			const h = Math.max(this.frameH - bottomReserve, this.frameH * 0.5)
			return {
				width: `${this.frameW}px`,
				height: `${h}px`
			}
		},
		pauseOverlayStyle() {
			return {
				width: `${this.frameW}px`,
				height: `${this.frameH}px`
			}
		},
		bottomOverlayStyle() {
			// reel 详情：bottomInset=0，只留 safeBottom（home indicator）
			// 发现 Tab：index 传入 bottomInset=tabbar 高度，用 padding 抬高信息区，
			// 避免改 bottom 与 CSS bottom:0 打架，也避免误伤 reel 布局
			const pad = (Number(this.safeBottom) || 0) + (Number(this.bottomInset) || 0) + 16
			return {
				width: `${this.frameW}px`,
				bottom: '0px',
				paddingBottom: `${pad}px`
			}
		},
		signedIn() {
			return !!this.$store.state.user.isLogin
		},
		/** 弹层固定高度：屏幕 70%（键盘弹起时不改 sheet 高度，只让 inputbar 自己跟随键盘） */
		commentSheetDesiredHeight() {
			return Math.round(this.frameH * 0.7)
		},
		commentSheetStyle() {
			const h = this.commentSheetDesiredHeight
			const inset = this.bottomInset || 0
			return {
				width: `${this.frameW}px`,
				height: `${h}px`,
				bottom: `${inset}px`
			}
		},
		/**
		 * 遮罩与 sheet 底部贴合：top=0 + bottom=sheet高度。
		 * 不能用 height=frameH-sheetH —— hideTabBar 后视口变高，frameH 仍是旧值会留出 ~tabbar 空隙。
		 */
		commentMaskStyle() {
			const sheetH = this.commentSheetDesiredHeight
			const inset = this.bottomInset || 0
			return {
				width: `${this.frameW}px`,
				top: '0px',
				bottom: `${sheetH + inset}px`
			}
		},
		/** 评论列表区高度 = sheet - header - inputbar（HKLive 用固定 px，不用 flex） */
		commentListHeight() {
			const headerPx = uni.upx2px(88)
			const inputPx = uni.upx2px(70 + 16 * 2 + 12)
			return Math.max(160, this.commentSheetDesiredHeight - headerPx - inputPx)
		},
		/** 无更多分页且至少 2 条时开启 swiper 首尾衔接（iOS + 入口首屏关闭，避免首条黑屏） */
		swiperCircular() {
			if (this.isIosApp) return false
			return (
				this.loopAtEnd &&
				this.circularUnlocked &&
				this.clips.length >= 2 &&
				this.pageNo >= this.pageEnd &&
				this.phase !== 'loading'
			)
		},
		isIosApp() {
			try {
				const sys = uni.getSystemInfoSync()
				return sys.platform === 'ios'
			} catch (_) {
				return false
			}
		},
		goodsCardWidth() {
			return Math.round((Number(this.frameW) || 0) * 0.8)
		}
	},
	watch: {
		activeIndex(next, prev) {
			if (this.clips.length - next - 1 <= PREFETCH_TAIL) {
				this.appendPage()
			}
			if (prev !== next) {
				if (typeof prev === 'number') this.collapseCaption(prev)
				this.warmAround(next)
				this.commitPlayState(next)
			}
		},
		tabActive(visible) {
			if (visible) this.commitPlayState(this.activeIndex)
			else this.haltAll()
		}
	},
	mounted() {
		if (this.autoBootstrap) {
			this.bootstrap()
			return
		}
		this.tryEntryBootstrap()
	},
	methods: {
		hasEntryQuery() {
			const findId = this.entryFindId
			const labelId = this.entryLabelId
			const listType = this.entryListType
			const userNo = this.entryUserNo
			const compId = this.entryCompId
			const search = this.entrySearch
			if (compId != null && compId !== '') return true
			if (findId != null && findId !== '') return true
			if (labelId != null && labelId !== '') return true
			if (listType != null && listType !== '' && userNo != null && userNo !== '') return true
			if (search != null && String(search) !== '') return true
			return false
		},
		tryEntryBootstrap(force = false) {
			if (this.autoBootstrap) return
			if (!force && this.entryBootstrapped) return
			if (!this.hasEntryQuery()) return
			this.entryBootstrapped = true
			this.openFromEntry({
				findId: this.entryFindId,
				labelId: this.entryLabelId,
				listType: this.entryListType,
				userNo: this.entryUserNo,
				page: Number(this.entryPage) || 1,
				compId: this.entryCompId,
				orderType: this.entryOrderType,
				isHotDian: this.entryIsHotDian,
				search: this.entrySearch
			})
		},
		clipKey(clip, index) {
			return clip.id || clip.video_id || `clip-${index}`
		},
		/** H5 swiper 的 current 常为字符串，严格 === 会导致合辑入口等 UI 不渲染 */
		slideIndex(value) {
			const n = Number(value)
			return Number.isFinite(n) ? n : 0
		},
		isActiveSlide(index) {
			return this.slideIndex(index) === this.slideIndex(this.activeIndex)
		},
		mediaPlayerId(clip, index) {
			return `feed-vid-${this.clipKey(clip, index)}`
		},
		mediaCellKey(clip, index) {
			const base = this.clipKey(clip, index)
			if (this.isActiveSlide(index)) return `${base}-on-${this.mediaEpoch}`
			return `${base}-off`
		},
		scheduleEntryPlayback(idx) {
			this.warmAround(idx)
			this.commitPlayState(idx)
			// iOS 首条 video 挂载更慢；且勿在此处开启 circular（会重建 swiper 导致首条黑屏）
			const retryMs = this.isIosApp ? 450 : 220
			setTimeout(() => {
				if (this.slideIndex(this.activeIndex) !== this.slideIndex(idx)) return
				this.mediaEpoch += 1
				this.$nextTick(() => {
					this.warmAround(idx)
					this.commitPlayState(idx)
				})
			}, retryMs)
		},
		plainCaption(clip) {
			let text = stripHtml(clip.content)
			if (text.includes('\\n')) {
				text = text.replace(/\\n/g, '\n')
			}
			return text
		},
		collapsedCaption(clip) {
			const text = this.plainCaption(clip)
			if (!text) return ''
			return text
				.replace(/\\n/g, ' ')
				.replace(/\r?\n+/g, ' ')
				.replace(/\s+/g, ' ')
				.trim()
		},
		isCaptionExpanded(clip) {
			return !!(clip && clip._captionExpanded)
		},
		captionLikelyOverflow(clip) {
			const text = this.plainCaption(clip)
			if (!text) return false
			if (/\r?\n/.test(text) || /\\n/.test(text)) return true
			const flat = this.collapsedCaption(clip)
			const bodyPx = Math.max(120, this.frameW - uni.upx2px(132 + 48 + 24 + 72))
			const charWidth = uni.upx2px(26) * 0.52
			const charsPerLine = Math.max(8, Math.floor(bodyPx / charWidth))
			return flat.length > charsPerLine
		},
		showCaptionToggle(clip) {
			return this.captionLikelyOverflow(clip)
		},
		toggleCaption(index) {
			const clip = this.clips[index]
			if (!clip) return
			this.$set(clip, '_captionExpanded', !clip._captionExpanded)
		},
		collapseCaption(index) {
			const clip = this.clips[index]
			if (clip && clip._captionExpanded) {
				this.$set(clip, '_captionExpanded', false)
			}
		},
		mediaSrc(clip) {
			return absolutizeMedia(clip.video && clip.video.url)
		},
		playbackSrc(clip) {
			if (clip._localSrc) return clip._localSrc
			const remote = this.mediaSrc(clip)
			const cached = readCachedPlaybackUrl(remote)
			return cached || remote
		},
		mediaCover(clip) {
			return absolutizeMedia(clip.video && clip.video.cover_url)
		},
		avatarSrc(path) {
			return buildOssUrl(path, 80, 80, 2, 'avatar')
		},
		userAvatar(clip) {
			const path = clip && clip.user && clip.user.avatar
			return path ? this.avatarSrc(path) : ''
		},
		// 头像 fallback 首字母
		avatarInitial(clip) {
			const s = String((clip && clip.user && clip.user.nickname) || '').trim()
			return s ? s.slice(0, 1) : '?'
		},
		// 数字格式化（10000+ → w，1000+ → k）
		formatCount(n) {
			const v = Number(n) || 0
			if (v <= 0) return ''
			if (v >= 10000) return `${(v / 10000).toFixed(1)}w`
			if (v >= 1000) return `${(v / 1000).toFixed(1)}k`
			return String(v)
		},
		/**
		 * 是否已点赞
		 * 后端 find/find/getList 接口的 isLike 字段：0 未点赞 / 1 已点赞
		 */
		isLiked(clip) {
			return clip && (clip.isLike === 1 || clip.isLike === '1')
		},
		// 从 clip 上抽取 user_no（关注主播 / 跳主页都要用）
		userNoOf(clip) {
			if (!clip) return ''
			if (clip.user_no) return clip.user_no
			if (clip.user && clip.user.user_no) return clip.user.user_no
			if (clip.shop && clip.shop.user_no) return clip.shop.user_no
			return ''
		},
		isSelfClip(clip) {
			if (!this.signedIn) return false
			const u = this.$store && this.$store.state && this.$store.state.user
			if (!u) return false
			const mine = String(u.user_no || u.id || '').trim()
			const theirs = String(this.userNoOf(clip) || '').trim()
			return !!(mine && theirs && mine === theirs)
		},
		requireSignIn() {
			console.log('[feed.reel] requireSignIn signedIn=', this.signedIn)
			if (!this.signedIn) {
				console.warn('[feed.reel] action blocked: user not signed in')
				uni.showModal({
					title: '请先登录',
					content: '登录后才能关注、点赞、评论',
					confirmText: '去登录',
					cancelText: '取消',
					success: ({ confirm }) => {
						if (confirm) uni.navigateTo({ url: '/pages/passport/lobby' })
					}
				})
				return false
			}
			return true
		},
		/**
		 * 关注 / 取消关注主播 —— 参考 HKLive\components\find\play.vue#handleFollow
		 * 与 onLikeTap 同策略：乐观翻转 + 防抖 + 异常回滚
		 */
		async onFollowTap(targetIndex) {
			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
			const row = this.clips[idx]
			const userNo = this.userNoOf(row)
			console.log('[feed.reel] onFollowTap idx=', idx, 'userNo=', userNo)
			if (!row || !userNo) {
				uni.showToast({ title: '主播信息缺失 (user_no 为空)', icon: 'none' })
				return
			}
			if (!this.requireSignIn()) return
			if (row._followBusy) {
				console.log('[feed.reel] onFollowTap throttled (busy)')
				return
			}
			row._followBusy = true

			const wasFollowed = !!row.isFollow
			const nextFollowed = !wasFollowed
			this.$set(row, 'isFollow', nextFollowed ? 1 : 0)

			try {
				const flag = await toggleClipFollow(userNo)
				console.log('[feed.reel] toggleClipFollow ←', flag, 'predicted=', nextFollowed)
				// flag === 1 → 已关注，flag === 0 → 已取消，其它（null/undefined）→ 信任前端预测
				let finalFollowed = nextFollowed
				if (flag === 1 || flag === 0) {
					const serverFollowed = flag === 1
					if (serverFollowed !== nextFollowed) {
						console.warn('[feed.reel] onFollowTap server mismatch, reconciling')
						this.$set(row, 'isFollow', serverFollowed ? 1 : 0)
					}
					finalFollowed = serverFollowed
				}
				uni.showToast({
					title: finalFollowed ? '已关注' : '已取消关注',
					icon: 'none'
				})
			} catch (err) {
				console.warn('[feed.reel] toggleClipFollow failed, rolling back', err)
				this.$set(row, 'isFollow', wasFollowed ? 1 : 0)
				uni.showToast({ title: (err && err.message) || '关注失败', icon: 'none' })
			} finally {
				row._followBusy = false
			}
		},
		onAvatarTap(targetIndex) {
			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
			const userNo = this.userNoOf(this.clips[idx])
			if (!userNo) {
				uni.showToast({ title: '主播信息缺失', icon: 'none' })
				return
			}
			uni.navigateTo({
				url: `/pages/profile/show?id=${encodeURIComponent(String(userNo))}`
			})
		},
		onPraiseTap(targetIndex) {
			console.log('[feed.reel] onPraiseTap idx=', targetIndex)
			uni.showToast({ title: '敬请期待', icon: 'none' })
		},
		shouldMount(index) {
			return Math.abs(this.slideIndex(this.activeIndex) - this.slideIndex(index)) <= 1
		},
		isClipPlaying(clip) {
			return !!(clip && clip.play && this.tabActive)
		},
		/**
		 * 合辑播放锁：
		 * - is_paid=0 → 免费，可播
		 * - is_paid=1 且 is_can_play=0 → 未解锁，遮罩
		 * - is_paid=1 且 is_can_play=1 → 已解锁，可播
		 */
		isClipLocked(clip) {
			if (!this.hasCompId || !clip) return false
			return this.isSeriesLocked(clip)
		},
		seriesListOf(clip) {
			if (!clip) return []
			const list = clip.series_list
			let rows = []
			if (Array.isArray(list)) rows = list
			else if (list && typeof list === 'object') {
				// 部分端把数组序列化成 {0:..,1:..}
				rows = Object.keys(list)
					.filter((k) => /^\d+$/.test(k))
					.sort((a, b) => Number(a) - Number(b))
					.map((k) => list[k])
					.filter(Boolean)
			}
			// 合并本地解锁缓存，避免弹层仍显示锁 / 再次要求付费
			return applyUnlockCacheToSeriesList(rows)
		},
		/** 挂商品列表 */
		goodsListOf(clip) {
			if (!clip) return []
			const list = clip.goods
			if (Array.isArray(list)) return list.filter(Boolean)
			if (list && typeof list === 'object') {
				return Object.keys(list)
					.filter((k) => /^\d+$/.test(k))
					.sort((a, b) => Number(a) - Number(b))
					.map((k) => list[k])
					.filter(Boolean)
			}
			return []
		},
		goodsCover(goods) {
			const path = goods && (goods.image || goods.image_url || goods.cover)
			return path ? buildOssUrl(path, 120, 120) : ''
		},
		goodsTrackWidth(clip) {
			const n = this.goodsListOf(clip).length
			if (!n) return 0
			const gap = uni.upx2px(14)
			const tail = uni.upx2px(24)
			return n * (this.goodsCardWidth + gap) + tail
		},
		goodsMinOffset(clip) {
			return Math.min(0, (Number(this.frameW) || 0) - this.goodsTrackWidth(clip))
		},
		goodsTrackStyle(clip) {
			const x = this.goodsOffsetX
			return {
				width: this.goodsTrackWidth(clip) + 'px',
				transform: `translateX(${x}px)`,
				webkitTransform: `translateX(${x}px)`
			}
		},
		onGoodsPanStart(e) {
			const t = e && e.touches && e.touches[0]
			this._gStartX = t ? t.pageX : 0
			this._gStartY = t ? t.pageY : 0
			this._gBase = this.goodsOffsetX
			this._gAxis = ''
			this._gMoved = false
		},
		onGoodsPanMove(e) {
			const t = e && e.touches && e.touches[0]
			if (!t) return
			const dx = t.pageX - this._gStartX
			const dy = t.pageY - this._gStartY
			if (!this._gAxis) {
				if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
				this._gAxis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'
			}
			if (this._gAxis !== 'x') {
				this.goodsTouchLock = false
				return
			}
			this.goodsTouchLock = true
			this._gMoved = true
			if (e && e.cancelable && typeof e.preventDefault === 'function') {
				e.preventDefault()
			}
			const clip = this.clips[this.slideIndex(this.activeIndex)]
			const min = this.goodsMinOffset(clip)
			let next = this._gBase + dx
			if (next > 0) next *= 0.35
			else if (next < min) next = min + (next - min) * 0.35
			this.goodsOffsetX = next
		},
		onGoodsPanEnd() {
			const clip = this.clips[this.slideIndex(this.activeIndex)]
			const min = this.goodsMinOffset(clip)
			if (this.goodsOffsetX > 0) this.goodsOffsetX = 0
			else if (this.goodsOffsetX < min) this.goodsOffsetX = min
			this.goodsTouchLock = false
			// 延后清除，避免 touchend 后立刻触发 tap
			setTimeout(() => {
				this._gMoved = false
				this._gAxis = ''
			}, 50)
		},
		onGoodsItemTap(goods) {
			if (this._gMoved) return
			this.onGoodsTap(goods)
		},
		onGoodsTap(goods) {
			const id = goods && goods.id
			if (id == null || id === '') return
			uni.navigateTo({
				url: `/pages/product/goods?id=${id}`
			})
		},
		resolvePaidAmount(item) {
			if (!item) return 0
			const top = Number(item.paid_money)
			if (!Number.isNaN(top) && top > 0) return top
			const nested = Number(item.video && item.video.paid_money)
			if (!Number.isNaN(nested) && nested > 0) return nested
			return 0
		},
		/** is_paid: 0 免费 / 1 付费（金额看 paid_money） */
		isSeriesPaid(item) {
			if (!item) return false
			if (item.is_paid === 1 || item.is_paid === '1') return true
			// 兼容旧数据：未带 is_paid 但金额 > 0
			if (item.is_paid == null && this.resolvePaidAmount(item) > 0) return true
			return false
		},
		isSeriesCanPlay(item) {
			if (!item) return false
			return !(item.is_can_play === 0 || item.is_can_play === '0')
		},
		/** 付费且未解锁 → 弹层显示锁、播放页遮罩 */
		isSeriesLocked(item) {
			return this.isSeriesPaid(item) && !this.isSeriesCanPlay(item)
		},
		/** 付费且已解锁 → 弹层显示「已解锁」 */
		isSeriesUnlocked(item) {
			return this.isSeriesPaid(item) && this.isSeriesCanPlay(item)
		},
		/** @deprecated 保留别名，避免旧调用；语义改为「付费内容」（含已解锁） */
		isPaidVideo(item) {
			return this.isSeriesPaid(item)
		},
		seriesCover(seriesItem) {
			return absolutizeMedia(seriesItem && seriesItem.video && seriesItem.video.cover_url)
		},
		seriesPaidAmount(seriesItem) {
			return this.resolvePaidAmount(seriesItem)
		},
		openSeriesSheet(index) {
			const clip = this.clips[index]
			const list = this.seriesListOf(clip)
			if (!list.length) return
			this.seriesAnchorClip = clip
			this.currentSeriesList = list
			this.seriesSheetVisible = true
			if (this.commentVisible) this.closeComment()
			// 发现 Tab：合辑弹层打开时隐藏自定义 tabbar，避免挡住列表
			this.$emit('tabbar-change', false)
		},
		closeSeriesSheet() {
			this.seriesSheetVisible = false
			this.currentSeriesList = []
			this.seriesAnchorClip = null
			if (!this.commentVisible) {
				this.$emit('tabbar-change', true)
			}
		},
		onSeriesItemTap(seriesItem) {
			if (!seriesItem || seriesItem.id == null || seriesItem.id === '') {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}
			const anchor = this.seriesAnchorClip || this.clips[this.activeIndex] || {}
			const findId = seriesItem.id
			// 带上已解锁缓存后的列表，避免进合辑页又变回未解锁
			const seriesList = applyUnlockCacheToSeriesList(
				this.currentSeriesList.length ? this.currentSeriesList : this.seriesListOf(anchor)
			)
			const compID =
				anchor.series_id ||
				anchor.seriesId ||
				seriesItem.pid ||
				this.entryCompId ||
				(this.listFilters && this.listFilters.compID) ||
				''

			// 已在合辑本地页：只切换当前集，不再跳转
			if (this.seriesLocalMode) {
				const idx = this.clips.findIndex(
					(row) =>
						(row && row.id != null && String(row.id) === String(findId)) ||
						(row && row.series_item_id != null && String(row.series_item_id) === String(findId))
				)
				this.closeSeriesSheet()
				if (idx < 0) {
					uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
					return
				}
				this.activeIndex = idx
				this.mediaEpoch += 1
				this.$nextTick(() => {
					this.scheduleEntryPlayback(idx)
				})
				return
			}

			if (!seriesList.length) {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}
			if (compID === '' || compID == null) {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}

			stashSeriesHandoff({
				seriesId: compID,
				startId: findId,
				seriesList,
				user: anchor.user || null,
				isFollow: anchor.isFollow || 0,
				anchor,
				parentFindId: anchor.id || ''
			})
			this.closeSeriesSheet()
			uni.navigateTo({
				url:
					`/pages/feed/series-reel?id=${encodeURIComponent(String(findId))}` +
					`&compID=${encodeURIComponent(String(compID))}`
			})
		},
		/**
		 * 合辑专用入口：用上游 series_list 填充列表，不请求接口
		 */
		openFromSeriesList({
			seriesList = [],
			seriesId = '',
			startId = '',
			user = null,
			isFollow = 0,
			anchor = null,
			parentFindId = ''
		} = {}) {
			const list = applyUnlockCacheToSeriesList(Array.isArray(seriesList) ? seriesList : [])
			const sid = seriesId || (list[0] && list[0].pid) || ''
			const extras = {
				seriesId: sid,
				seriesList: list,
				user: user || (anchor && anchor.user) || null,
				isFollow: isFollow != null ? isFollow : (anchor && anchor.isFollow) || 0
			}
			const rows = list.map((item) =>
				applyUnlockCacheToItem(normalizeSeriesClip(item, extras))
			)

			this.seriesLocalMode = true
			this.seriesParentFindId = this.resolveSeriesParentFindId(
				list,
				parentFindId || (anchor && anchor.id) || ''
			)
			this.entryBootstrapped = true
			this.circularUnlocked = false
			this.swiperEpoch += 1
			this.mediaEpoch += 1
			this.listFilters = sid !== '' && sid != null ? { compID: sid } : { compID: 'local' }
			this.clips = rows
			this.phase = rows.length ? 'ready' : 'empty'
			this.pageNo = 1
			this.pageEnd = 1

			let idx = 0
			if (startId != null && startId !== '') {
				const found = rows.findIndex(
					(row) =>
						(row && row.id != null && String(row.id) === String(startId)) ||
						(row && row.series_item_id != null && String(row.series_item_id) === String(startId))
				)
				if (found >= 0) idx = found
			}
			this.activeIndex = idx
			this.$nextTick(() => {
				this.scheduleEntryPlayback(idx)
			})
		},
		handlePayForVideo(item) {
			if (!item || !item.id) {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}
			if (this.$appKit && typeof this.$appKit.requireLogin === 'function') {
				if (!this.$appKit.requireLogin()) return
			} else if (!this.requireSignIn()) {
				return
			}
			this.currentPayVideoItem = item
			this.showPayPopup = true
		},
		closePayPopup() {
			this.showPayPopup = false
		},
		handlePayPopupConfirm() {
			this.closePayPopup()
			this.checkBalanceAndPay()
		},
		async checkBalanceAndPay() {
			try {
				const balance = await fetchDiamondBalance()
				const currentItem = this.currentPayVideoItem || this.clips[this.activeIndex]
				const paidAmount = this.resolvePaidAmount(currentItem)
				if (balance < paidAmount) {
					this.showInsufficientBalanceModal = true
					return
				}
				await this.unlockVideo()
			} catch (error) {
				console.error('[feed.reel] checkBalanceAndPay failed', error)
				this.showInsufficientBalanceModal = true
			}
		},
		closeInsufficientBalanceModal() {
			this.showInsufficientBalanceModal = false
		},
		goToRecharge() {
			this.closeInsufficientBalanceModal()
			uni.navigateTo({
				url: '/pages/user/money/diamond-top-up'
			})
		},
		async unlockVideo() {
			if (this.unlockBusy) return
			const currentItem = this.currentPayVideoItem || this.clips[this.activeIndex]
			if (!currentItem || !currentItem.id) {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}
			// 合辑接口项：优先 series_item_id，否则用自身 id（series_list 项 id 即 series_item_id）
			const seriesItemId =
				currentItem.series_item_id != null && currentItem.series_item_id !== ''
					? currentItem.series_item_id
					: currentItem.id
			if (seriesItemId == null || seriesItemId === '') {
				uni.showToast({ title: this.copy.videoInfoIncomplete, icon: 'none' })
				return
			}
			this.unlockBusy = true
			uni.showLoading({ title: this.copy.unlocking || '解锁中...' })
			try {
				await unlockFeedVideo(seriesItemId)
				uni.hideLoading()
				uni.showToast({
					title: this.copy.unlockSuccess || '解锁成功',
					icon: 'none',
					duration: 1200
				})
				this.$set(currentItem, 'is_can_play', 1)
				// 解锁后拉 seriesvideolist，用最新列表（含 url）刷新并播放
				const mergedUrl = await this.refreshPlayUrlAfterUnlock(seriesItemId, currentItem)
				if (!mergedUrl) {
					console.warn('[feed.series] still no play url after unlock', seriesItemId)
				}
				markSeriesItemUnlocked(seriesItemId, {
					url: mergedUrl,
					videoId: currentItem.video && currentItem.video.id
				})
				await this.playUnlockedClip(seriesItemId, mergedUrl)
			} catch (error) {
				uni.hideLoading()
				console.error('[feed.reel] unlockVideo failed', error)
				uni.showToast({
					title: (error && error.message) || this.copy.unlockFailed || '解锁失败',
					icon: 'none'
				})
			} finally {
				this.unlockBusy = false
			}
		},
		/** 从 series_list 里推断父级 find.id（如 find_id:885） */
		resolveSeriesParentFindId(list, explicit) {
			if (explicit != null && explicit !== '' && Number(explicit) > 0) return explicit
			if (this.seriesParentFindId && Number(this.seriesParentFindId) > 0) {
				return this.seriesParentFindId
			}
			const rows = Array.isArray(list) ? list : []
			for (let i = 0; i < rows.length; i++) {
				const fid = rows[i] && rows[i].find_id
				if (fid != null && fid !== '' && Number(fid) > 0) return fid
			}
			return ''
		},
		/** 从已有 series_list 取分集播放地址（不请求接口） */
		findSeriesListUrl(seriesItemId) {
			const lists = []
			const clips = this.clips || []
			for (let c = 0; c < clips.length; c++) {
				const clip = clips[c]
				if (!clip) continue
				if (
					(clip.id != null && String(clip.id) === String(seriesItemId)) ||
					(clip.series_item_id != null && String(clip.series_item_id) === String(seriesItemId))
				) {
					if (clip.video && clip.video.url) return clip.video.url
				}
				if (Array.isArray(clip.series_list)) lists.push(clip.series_list)
			}
			if (Array.isArray(this.currentSeriesList)) lists.push(this.currentSeriesList)
			for (let i = 0; i < lists.length; i++) {
				const hit = lists[i].find(
					(row) =>
						row &&
						((row.id != null && String(row.id) === String(seriesItemId)) ||
							(row.series_item_id != null && String(row.series_item_id) === String(seriesItemId)))
				)
				if (hit && hit.video && hit.video.url) return hit.video.url
			}
			return ''
		},
		/**
		 * 解锁后：GET videoseriesitem/seriesvideolist
		 * params: page, series_id, video_id（lang 由拦截器自动带）
		 * 用返回列表刷新本地 clips，并返回当前集 url
		 */
		async refreshPlayUrlAfterUnlock(seriesItemId, currentItem) {
			const seriesId =
				(this.listFilters && this.listFilters.compID) ||
				(currentItem && currentItem.pid) ||
				(currentItem && currentItem.series_id) ||
				''
			const videoId =
				(currentItem && currentItem.video_id) ||
				(currentItem && currentItem.video && currentItem.video.video_id) ||
				''
			if (!seriesId || seriesId === 'local' || !videoId) {
				console.warn('[feed.series] seriesvideolist skip: missing seriesId/videoId', {
					seriesId,
					videoId
				})
				return (currentItem && currentItem.video && currentItem.video.url) || ''
			}
			try {
				await new Promise((r) => setTimeout(r, 150))
				const pack = await fetchSeriesVideoList({
					seriesId,
					videoId,
					page: 1
				})
				const list = applyUnlockCacheToSeriesList((pack && pack.rows) || [])
				if (!list.length) {
					console.warn('[feed.series] seriesvideolist empty')
					return ''
				}

				const user = currentItem && currentItem.user
				const isFollow = currentItem && currentItem.isFollow
				const extras = {
					seriesId,
					seriesList: list,
					user,
					isFollow
				}
				const rows = list.map((item) =>
					applyUnlockCacheToItem(
						normalizeSeriesClip(
							Object.assign({}, item, { is_can_play: item.is_can_play == null ? 1 : item.is_can_play }),
							extras
						)
					)
				)

				// 合辑本地页：整表替换为服务端最新列表
				if (this.seriesLocalMode) {
					const keepId = seriesItemId
					let idx = rows.findIndex(
						(row) =>
							(row && row.id != null && String(row.id) === String(keepId)) ||
							(row && row.series_item_id != null && String(row.series_item_id) === String(keepId)) ||
							(row && row.video_id && String(row.video_id) === String(videoId)) ||
							(row && row.video && String(row.video.video_id) === String(videoId))
					)
					if (idx < 0) idx = 0
					this.clips = rows
					this.phase = rows.length ? 'ready' : 'empty'
					this.activeIndex = idx
					this.currentSeriesList = list
					const hit = rows[idx]
					const url = (hit && hit.video && hit.video.url) || ''
					if (hit) {
						markSeriesItemUnlocked(hit.series_item_id || hit.id, { url, videoId: hit.video && hit.video.id })
					}
					console.log('[feed.series] seriesvideolist applied, url=', !!url, 'idx=', idx)
					return url
				}

				// 非本地模式：只合并当前集
				const hit = list.find(
					(row) =>
						row &&
						((row.id != null && String(row.id) === String(seriesItemId)) ||
							(row.video_id && String(row.video_id) === String(videoId)) ||
							(row.video && String(row.video.video_id) === String(videoId)))
				)
				const url = (hit && hit.video && hit.video.url) || ''
				if (hit) {
					this.patchUnlockedAcrossSeries(seriesItemId, { url })
					const idx = this.clips.findIndex(
						(row) =>
							(row && row.id != null && String(row.id) === String(seriesItemId)) ||
							(row && row.series_item_id != null && String(row.series_item_id) === String(seriesItemId))
					)
					if (idx >= 0) {
						const fresh = normalizeSeriesClip(hit, {
							seriesId,
							seriesList: list,
							user: this.clips[idx].user,
							isFollow: this.clips[idx].isFollow
						})
						this.$set(
							this.clips,
							idx,
							Object.assign({}, this.clips[idx], fresh, {
								is_can_play: 1,
								play: false,
								userPaused: false
							})
						)
					}
				}
				console.log('[feed.series] seriesvideolist url=', !!url)
				return url
			} catch (err) {
				console.warn('[feed.series] seriesvideolist failed', err)
				return ''
			}
		},
		/** 解锁后强制挂载播放器并起播 */
		async playUnlockedClip(seriesItemId, playUrl) {
			const idx = this.clips.findIndex(
				(row) =>
					(row && row.id != null && String(row.id) === String(seriesItemId)) ||
					(row && row.series_item_id != null && String(row.series_item_id) === String(seriesItemId)) ||
					(row && playUrl && row.video && row.video.url === playUrl)
			)
			const i = idx >= 0 ? idx : this.slideIndex(this.activeIndex)
			let clip = this.clips[i]
			if (!clip) return
			this.$set(clip, 'is_can_play', 1)
			this.$set(clip, 'userPaused', false)
			if (playUrl) {
				this.$set(clip, 'video', Object.assign({}, clip.video || {}, { url: playUrl }))
			}
			clip = this.clips[i]
			const finalUrl = (clip && clip.video && clip.video.url) || playUrl || ''
			if (finalUrl && clip && (!clip.video || clip.video.url !== finalUrl)) {
				this.$set(clip, 'video', Object.assign({}, (clip && clip.video) || {}, { url: finalUrl }))
			}
			this.activeIndex = i
			this.swiperEpoch += 1
			this.mediaEpoch += 1
			await this.$nextTick()
			this.warmAround(i)
			await new Promise((r) => setTimeout(r, 320))
			const row = this.clips[i]
			if (!row) return
			if (!this.mediaSrc(row)) {
				console.warn('[feed.series] unlock play skipped: empty mediaSrc', row && row.video)
				uni.showToast({ title: '暂无播放地址', icon: 'none' })
				return
			}
			this.haltAll()
			this.$set(row, 'userPaused', false)
			this.$set(row, 'play', true)
		},
		/** 同步：当前 clip + 各 clip.series_list + 弹层列表 */
		patchUnlockedAcrossSeries(anchorId, { url = '' } = {}) {
			if (anchorId == null || anchorId === '') return
			const match = (row) =>
				!!(
					row &&
					((row.id != null && String(row.id) === String(anchorId)) ||
						(row.series_item_id != null && String(row.series_item_id) === String(anchorId)))
				)
			const patchRow = (row) => {
				if (!match(row)) return row
				const next = Object.assign({}, row, { is_can_play: 1 })
				if (url) {
					next.video = Object.assign({}, row.video || {}, { url })
				}
				return next
			}

			;(this.clips || []).forEach((clip, i) => {
				if (!clip) return
				if (match(clip)) {
					this.$set(clip, 'is_can_play', 1)
					if (url) {
						this.$set(clip, 'video', Object.assign({}, clip.video || {}, { url }))
					}
				}
				const nested = Array.isArray(clip.series_list) ? clip.series_list : null
				if (nested && nested.length) {
					this.$set(clip, 'series_list', nested.map(patchRow))
				}
				this.$set(this.clips, i, clip)
			})

			if (Array.isArray(this.currentSeriesList) && this.currentSeriesList.length) {
				this.currentSeriesList = this.currentSeriesList.map(patchRow)
			}
		},
		pickUnlockVideoUrl(unlockRes) {
			const dig = (node, depth = 0) => {
				if (!node || depth > 4) return ''
				if (typeof node === 'string') {
					const s = node.trim()
					if (!s) return ''
					if (s.indexOf('/uploads/') >= 0 || s.indexOf('.mp4') >= 0 || /^https?:\/\//i.test(s)) {
						return s
					}
					return ''
				}
				if (typeof node !== 'object') return ''
				if (node.url) return dig(node.url, depth + 1) || String(node.url)
				if (node.video) {
					const fromVideo = dig(node.video, depth + 1)
					if (fromVideo) return fromVideo
				}
				if (node.data != null) {
					const fromData = dig(node.data, depth + 1)
					if (fromData) return fromData
				}
				return ''
			}
			const fromData = dig(unlockRes && unlockRes.data)
			if (fromData) return fromData
			const raw = unlockRes && unlockRes.raw
			const envelope = (raw && (raw.res || raw.data)) || null
			return dig(envelope)
		},
		async reloadAfterUnlock(anchorId, fallbackIndex) {
			// 合辑本地模式禁止 videoseriesitem/list
			if (this.seriesLocalMode) return
			const requestFilters = { ...(this.listFilters || {}) }
			delete requestFilters.compID
			if (anchorId != null && anchorId !== '') requestFilters.find_id = anchorId
			const pack = await fetchVideoFeedPage(this.pageNo || 1, requestFilters)
			this.applyPagePack(pack)
			this.phase = this.clips.length ? 'ready' : 'empty'
			let idx = -1
			if (anchorId != null && anchorId !== '') {
				idx = this.clips.findIndex(
					(row) =>
						(row && row.id != null && String(row.id) === String(anchorId)) ||
						(row && row.series_item_id != null && String(row.series_item_id) === String(anchorId))
				)
			}
			if (idx < 0 && fallbackIndex >= 0 && fallbackIndex < this.clips.length) {
				idx = fallbackIndex
			}
			if (idx < 0) idx = 0
			this.activeIndex = idx
			if (this.clips[idx]) {
				this.$set(this.clips[idx], 'is_can_play', 1)
				markSeriesItemUnlocked(
					this.clips[idx].series_item_id || this.clips[idx].id,
					{ url: this.clips[idx].video && this.clips[idx].video.url }
				)
				this.patchUnlockedAcrossSeries(anchorId || this.clips[idx].id, {
					url: this.clips[idx].video && this.clips[idx].video.url
				})
			}
			this.mediaEpoch += 1
			this.$nextTick(() => {
				this.warmAround(idx)
				setTimeout(() => {
					this.commitPlayState(idx)
				}, 300)
			})
		},
		async bootstrap(filters = {}) {
			this.phase = 'loading'
			this.pageNo = 1
			this.clips = []
			this.listFilters = { ...(filters || {}) }
			delete this.listFilters.page

			const useCache = !this.listFilters || !Object.keys(this.listFilters).length
			if (useCache) {
				const cached = readFeedPage(1)
				if (cached && cached.rows && cached.rows.length) {
					this.applyPagePack(cached, { fromCache: true })
					this.phase = 'ready'
					this.$nextTick(() => {
						this.warmAround(0)
						this.commitPlayState(0)
					})
				}
			}

			try {
				const pack = await fetchVideoFeedPage(1, this.listFilters)
				if (useCache) writeFeedPage(1, pack)
				this.applyPagePack(pack)
				this.phase = this.clips.length ? 'ready' : 'empty'
				this.$nextTick(() => {
					this.warmAround(this.activeIndex)
					this.commitPlayState(this.activeIndex)
				})
			} catch (err) {
				if (!this.clips.length) {
					this.phase = 'empty'
					uni.showToast({ title: err.message || '加载失败', icon: 'none' })
				}
			}
		},
		/**
		 * 从外部入口打开指定视频（find_id / label_id / type+user_no / page）
		 */
		async openFromEntry({
			findId = '',
			labelId = '',
			listType = '',
			userNo = '',
			page = 1,
			compId = '',
			orderType = '',
			isHotDian = '',
			search = ''
		} = {}) {
			const filters = {}
			const hasComp = compId != null && String(compId) !== ''
			// 合辑模式：只带 compID，不要混入 label_id / 个人主页 type（否则列表常为空）
			if (hasComp) {
				filters.compID = compId
			} else if (listType != null && listType !== '') {
				filters.type = listType
				if (userNo != null && userNo !== '') filters.user_no = userNo
			} else if (labelId != null && labelId !== '') {
				filters.label_id = labelId
			}
			if (!hasComp && orderType != null && orderType !== '') filters.order_type = orderType
			if (!hasComp && isHotDian != null && isHotDian !== '') filters.is_hot_dian = isHotDian
			if (!hasComp && search != null && String(search) !== '') filters.search = String(search)
			const startPage = Number(page) || 1

			// find_id 仅用于首屏定位，不写入 listFilters，避免分页一直带 find_id
			const requestFilters = { ...filters }
			if (findId != null && findId !== '') requestFilters.find_id = findId

			console.log('[feed.reel] openFromEntry', { findId, compId, filters: requestFilters })

			this.circularUnlocked = false
			this.swiperEpoch += 1
			this.mediaEpoch += 1
			this.activeIndex = 0
			this.phase = 'loading'
			this.clips = []
			this.listFilters = { ...filters }

			try {
				const pack = await fetchVideoFeedPage(startPage, requestFilters)
				this.applyPagePack(pack)
				this.phase = this.clips.length ? 'ready' : 'empty'

				// 优先按点击的 findId 定位；不要让 first_play 抢先（本列表 first_play 可能不在第一条）
				let idx = -1
				if (findId != null && findId !== '') {
					idx = this.clips.findIndex(
						(row) =>
							(row && row.id != null && String(row.id) === String(findId)) ||
							(row && row.series_item_id != null && String(row.series_item_id) === String(findId))
					)
				}
				if (idx < 0) {
					idx = this.clips.findIndex(
						(row) => row && row.first_play == 1 && this.mediaSrc(row)
					)
				}
				if (idx < 0) {
					idx = this.clips.findIndex((row) => row && row.first_play == 1)
				}
				if (idx < 0) idx = 0
				this.activeIndex = idx

				this.$nextTick(() => {
					this.scheduleEntryPlayback(idx)
				})
			} catch (err) {
				this.phase = 'empty'
				uni.showToast({ title: err.message || '加载失败', icon: 'none' })
			}
		},
		async openFromLabel(labelId) {
			if (!labelId) return
			this.activeIndex = 0
			this.listFilters = { label_id: labelId }
			await this.bootstrap({ label_id: labelId })
			const idx = this.clips.findIndex((row) =>
				String(row.id) === String(labelId) || String(row.label_id) === String(labelId)
			)
			if (idx > 0) {
				this.activeIndex = idx
				this.$nextTick(() => {
					this.warmAround(idx)
					this.commitPlayState(idx)
				})
			}
		},
		applyPagePack(pack, opts) {
			const append = opts && opts.append
			this.pageNo = pack.currentPage
			this.pageEnd = pack.lastPage
			const rows = (pack.rows || []).map((row) => {
				const next = Object.assign({}, row, {
					_localSrc: '',
					userPaused: false,
					_captionExpanded: false
				})
				if (Array.isArray(next.series_list) && next.series_list.length) {
					next.series_list = applyUnlockCacheToSeriesList(next.series_list)
				}
				return next
			})
			this.clips = append ? this.clips.concat(rows) : rows
		},
		warmAround(index) {
			prefetchNeighbors(this.clips, index, (clip) => this.mediaSrc(clip))
			const center = this.clips[index]
			const next = this.clips[index + 1]
			const jobs = [center, next].filter(Boolean)
			jobs.forEach((clip) => {
				const remote = this.mediaSrc(clip)
				if (!remote) return
				const cached = readCachedPlaybackUrl(remote)
				if (cached) {
					clip._localSrc = cached
					return
				}
				resolvePlaybackUrl(remote).then((local) => {
					if (local && local !== remote) clip._localSrc = local
				})
			})
		},
		async appendPage() {
			if (this.seriesLocalMode) return
			if (this.phase === 'loading' || this.pageNo >= this.pageEnd) return
			this.phase = 'loading'
			const next = this.pageNo + 1
			try {
				let pack = readFeedPage(next)
				if (!pack) {
					pack = await fetchVideoFeedPage(next, this.listFilters)
					if (!Object.keys(this.listFilters).length) writeFeedPage(next, pack)
				}
				this.applyPagePack(pack, { append: true })
				this.warmAround(this.activeIndex)
				this.phase = 'ready'
			} catch (_) {
				this.phase = 'ready'
			}
		},
		onSlideChange(e) {
			const next = this.slideIndex(e && e.detail && e.detail.current)
			if (next === this.slideIndex(this.activeIndex)) return
			this.goodsTouchLock = false
			this.goodsOffsetX = 0
			this.circularUnlocked = true
			this.haltAll()
			if (this.seriesSheetVisible) this.closeSeriesSheet()
			// iOS 滑回第 0 条时强制重建 video，避免首条黑屏残留
			if (this.isIosApp && next === 0) this.mediaEpoch += 1
			this.activeIndex = next
		},
		haltAll() {
			this.clips.forEach((row) => {
				this.$set(row, 'play', false)
			})
		},
		commitPlayState(index) {
			if (!this.tabActive || !this.clips.length) return
			this.haltAll()
			const row = this.clips[this.slideIndex(index)]
			if (!row || !this.mediaSrc(row)) return
			if (this.isClipLocked(row) || row.is_can_play === 0 || row.is_can_play === '0') return
			this.$set(row, 'userPaused', false)
			this.$set(row, 'play', true)
		},
		togglePause(index) {
			if (!this.isActiveSlide(index)) return
			const row = this.clips[this.slideIndex(index)]
			if (!row || this.isClipLocked(row)) return
			const next = !row.play
			this.$set(row, 'play', next)
			this.$set(row, 'userPaused', !next)
		},
		freezePlayback() {
			this.haltAll()
		},
		wakePlayback() {
			const idx = this.slideIndex(this.activeIndex)
			const row = this.clips[idx]
			if (row && row.userPaused) return
			this.commitPlayState(idx)
		},
		onPlaybackFallback(index, remote) {
			const clip = this.clips[index]
			if (!clip) return
			clip._localSrc = ''
			dropCachedPlaybackUrl(remote || this.mediaSrc(clip))
		},
		/**
		 * 点 "💬" 评论按钮：
		 *   1. 校验登录 / clipId
		 *   2. 拉评论列表（带 count）
		 *   3. 打开底部弹层 <CommentThread>
		 *
		 * 注：未登录用户也允许「看」评论，只在点赞 / 回复 / 删除时再要求登录。
		 * 参考 HKLive components/find/play.vue#handleComment 行为一致。
		 */
		async onCommentTap(targetIndex) {
			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
			const row = this.clips[idx]
			console.log('[feed.reel] onCommentTap idx=', idx, 'clipId=', row && row.id)
			if (!row) return
			if (!row.id) {
				uni.showToast({ title: '视频信息缺失 (id 为空)', icon: 'none' })
				return
			}
			this.commentClipId = row.id
			this.commentClipIndex = idx
			this.commentLoading = true
			// 先置 true，避免关闭合辑弹层时把 tabbar 闪回
			this.commentVisible = true
			if (this.seriesSheetVisible) this.closeSeriesSheet()
			// 打开评论时隐藏底部 tabbar，避免键盘与输入框之间多出 tabbar 高度
			this.$emit('tabbar-change', false)
			try {
				const result = await fetchClipComments(row.id)
				console.log('[feed.reel] fetchClipComments ←', result)
				const count = result && result.count != null ? result.count : 0
				this.$set(row, 'comments', count)
				this.commentList = Array.isArray(result.list) ? result.list : []
			} catch (err) {
				console.warn('[feed.reel] fetchClipComments failed', err)
				this.commentList = []
				uni.showToast({ title: (err && err.message) || '评论加载失败', icon: 'none' })
			} finally {
				this.commentLoading = false
			}
		},
		onReportTap(targetIndex) {
			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
			const row = this.clips[idx]
			if (!row) return
			if (this.$appKit && typeof this.$appKit.requireLogin === 'function') {
				if (!this.$appKit.requireLogin()) return
			} else if (!this.requireSignIn()) {
				return
			}
			let url = '/pages/account/complaint'
			const userNo = this.userNoOf(row)
			if (userNo) {
				url += `?type=0&id=${encodeURIComponent(String(userNo))}`
			}
			uni.navigateTo({ url })
		},

		closeComment() {
			this.commentVisible = false
			this.$emit('tabbar-change', true)
			this.commentList = []
			this.commentClipId = 0
			this.commentClipIndex = -1
		},

		/**
		 * <CommentThread> 子组件回调：评论数发生变化（新增 / 删除）
		 * 把变化同步回当前视频卡片，让外层 "💬 X" 数字实时刷新
		 */
		onCommentComplete(evt) {
			if (!evt || this.commentClipIndex < 0) return
			const row = this.clips[this.commentClipIndex]
			if (row) this.$set(row, 'comments', Number(evt.count) || 0)
		},
		/**
		 * 点赞 / 取消点赞
		 *
		 * 策略：乐观翻转 + 防抖 + 异常回滚
		 *   1. 立刻按当前 isLike 反转更新 UI，给用户即时反馈
		 *   2. _likeBusy 防止短时间内连续点击触发多次请求
		 *   3. 等服务端响应后只在结果跟前端预测不一致时矫正
		 *   4. 接口异常时回滚到点击前的状态
		 *
		 * 不能像参考 play.vue 那样直接用服务端返回值 +/- likes —— 那只在
		 * 服务端正确返回 toggle 后状态时才对，本工程后端遇到过始终返回 1
		 * 的情况，会导致连点 N 次 likes 加 N。
		 */
		async onLikeTap(targetIndex) {
			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
			const row = this.clips[idx]
			console.log('[feed.reel] onLikeTap idx=', idx, 'clipId=', row && row.id)
			if (!row) return
			if (!row.id) {
				uni.showToast({ title: '视频信息缺失 (id 为空)', icon: 'none' })
				return
			}
			if (!this.requireSignIn()) return
			if (row._likeBusy) {
				console.log('[feed.reel] onLikeTap throttled (busy)')
				return
			}
			row._likeBusy = true

			// 乐观翻转
			const wasLiked = this.isLiked(row)
			const nextLiked = !wasLiked
			const wasCount = Number(row.likes || 0)
			this.$set(row, 'isLike', nextLiked ? 1 : 0)
			this.$set(row, 'likes', Math.max(0, wasCount + (nextLiked ? 1 : -1)))

			try {
				const flag = await toggleClipLike(row.id)
				console.log('[feed.reel] toggleClipLike ←', flag, 'predicted=', nextLiked)
				// setFindUser type=likes 返回值：
				//   flag === 1  →  这次操作是 "点赞" → 新状态：已点赞 → isLike = 1
				//   flag === 0  →  这次操作是 "取消" → 新状态：未点赞 → isLike = 0
				//   其它（null/undefined）→ 无法判定，信任前端乐观预测
				if (flag === 1 || flag === 0) {
					const serverLiked = flag === 1
					if (serverLiked !== nextLiked) {
						console.warn('[feed.reel] onLikeTap server mismatch, reconciling')
						this.$set(row, 'isLike', serverLiked ? 1 : 0)
						this.$set(row, 'likes', Math.max(0, wasCount + (serverLiked ? 1 : -1)))
					}
				}
			} catch (err) {
				console.warn('[feed.reel] toggleClipLike failed, rolling back', err)
				this.$set(row, 'isLike', wasLiked ? 1 : 0)
				this.$set(row, 'likes', wasCount)
				uni.showToast({ title: (err && err.message) || '点赞失败', icon: 'none' })
			} finally {
				row._likeBusy = false
			}
		}
	}
}
</script>

<style scoped>
.reel-root {
	position: relative;
	background-color: #000000;
	overflow: hidden;
}
.reel-swiper {
	width: 100%;
	height: 100%;
}
.reel-slide {
	position: relative;
	background-color: #000000;
	width: 100%;
	height: 100%;
}
.reel-tap-layer {
	position: absolute;
	left: 0;
	top: 0;
	z-index: 5;
	background-color: rgba(0, 0, 0, 0.001);
}
.reel-pause-overlay {
	position: absolute;
	left: 0;
	top: 0;
	z-index: 6;
	display: flex;
	justify-content: center;
	align-items: center;
}
.reel-pause-hint {
	width: 120px;
	height: 120px;
	border-radius: 60px;
	background-color: transparent;
	/* background-color: rgba(0, 0, 0, 0.35); */
	display: flex;
	justify-content: center;
	align-items: center;
}
.reel-pause-icon-img {
	width: 48px;
	height: 48px;
}
.reel-bottom-overlay {
	position: absolute;
	left: 0;
	bottom: 0;
	z-index: 8;
	padding-top: 44rpx;
	display: flex;
	flex-direction: column;
	align-items: stretch;
}
/* 两层 rgba 模拟自下而上渐变，高度随介绍文字展开；top 留出头像上探区域 */
.reel-bottom-scrim {
	position: absolute;
	left: 0;
	right: 0;
	z-index: 0;
}
.reel-bottom-scrim-deep {
	top: 44rpx;
	bottom: 0;
	background-color: rgba(0, 0, 0, 0.58);
}
.reel-bottom-overlay--expanded .reel-bottom-scrim-deep {
	background-color: rgba(0, 0, 0, 0.78);
}
.reel-bottom-scrim-top {
	top: 44rpx;
	height: 80rpx;
	background-color: rgba(0, 0, 0, 0);
}
.reel-bottom-overlay--expanded .reel-bottom-scrim-top {
	height: 45%;
	background-color: rgba(0, 0, 0, 0.42);
}
.reel-empty,
.reel-loading {
	position: absolute;
	left: 0;
	top: 0;
	background-color: #000000;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	z-index: 20;
}
.reel-loading-inner,
.reel-empty-inner {
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	padding: 40rpx 48rpx;
	border-radius: 24rpx;
	background-color: rgba(255, 255, 255, 0.08);
}
.reel-loading-spinner {
	width: 56px;
	height: 56px;
	border-radius: 28px;
	border-width: 4px;
	border-style: solid;
	border-color: rgba(255, 255, 255, 0.18);
	border-top-color: #4a8cff;
	margin-bottom: 20px;
}
/* #ifndef APP-NVUE */
.reel-loading-spinner {
	animation: reel-spin 0.85s linear infinite;
}
@keyframes reel-spin {
	from {
		transform: rotate(0deg);
	}
	to {
		transform: rotate(360deg);
	}
}
/* #endif */
.reel-empty-icon {
	font-size: 44px;
	margin-bottom: 16px;
	opacity: 0.85;
}
.reel-empty-txt,
.reel-loading-txt {
	color: rgba(255, 255, 255, 0.88);
	font-size: 28rpx;
	line-height: 40rpx;
	text-align: center;
}

/* ===== user-card 内联样式（原 feed-user-card.vue） ===== */
.fuc {
	position: relative;
	z-index: 2;
	display: flex;
	flex-direction: row;
	align-items: flex-start;
	padding: 8rpx 24rpx 0;
}
.fuc-left {
	display: flex;
	flex-direction: column;
	align-items: center;
	margin-right: 20rpx;
	margin-top: -44rpx;
	width: 132rpx;
	position: relative;
	z-index: 3;
}
.fuc-avatar-wrap {
	width: 153rpx;
	height: 153rpx;
	border-radius: 50%;
	border: 5rpx solid #ffffff;
	display: flex;
	justify-content: center;
	align-items: center;
	overflow: hidden;
}
.fuc-avatar {
	width: 143rpx;
	height: 143rpx;
	border-radius: 50%;
}
.fuc-avatar-fallback {
	background-color: #4a8cff;
	display: flex;
	justify-content: center;
	align-items: center;
}
.fuc-avatar-initial {
	color: #ffffff;
	font-size: 42rpx;
	font-weight: bold;
}
.fuc-follow {
	margin-top: -18rpx;
	background-color: #ff4d6a;
	border-radius: 22rpx;
	height: 40rpx;
	display: flex;
	justify-content: center;
	align-items: center;
	padding: 0 16rpx;
}
.fuc-followed {
	background-color: rgba(255, 255, 255, 0.18);
	border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.fuc-follow-text {
	color: #ffffff;
	font-size: 22rpx;
	font-weight: bold;
	line-height: 32rpx;
}
.fuc-followed-text {
	color: rgba(255, 255, 255, 0.88);
	font-size: 22rpx;
	line-height: 32rpx;
}

.fuc-body {
	flex: 1;
	display: flex;
	flex-direction: column;
	padding-top: 16rpx;
}
.fuc-top {
	display: flex;
	flex-direction: row;
	flex-wrap: nowrap;
	align-items: center;
	justify-content: space-between;
	padding-bottom: 8rpx;
}
.fuc-nickname-wrap {
	flex: 1;
	overflow: hidden;
	margin-right: 12rpx;
}
.fuc-nickname {
	color: #ffffff;
	font-size: 30rpx;
	font-weight: bold;
	line-height: 42rpx;
}
.fuc-actions {
	display: flex;
	flex-direction: row;
	align-items: center;
}
.fuc-action {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	height: 72rpx;
	padding: 0 8rpx;
}
.fuc-action-spaced {
	margin-left: 0;
}
.fuc-action-icon {
	color: rgba(255, 255, 255, 0.92);
	font-size: 44rpx;
	line-height: 56rpx;
}
.fuc-action-liked {
	color: #ff4d6a;
}
.fuc-action-num {
	color: rgba(255, 255, 255, 0.85);
	font-size: 22rpx;
	margin-left: 6rpx;
}
.fuc-caption-wrap {
	margin-top: 4rpx;
}
.fuc-caption-collapsed {
	display: flex;
	flex-direction: row;
	align-items: flex-end;
	overflow: hidden;
}
.fuc-caption-expanded {
	display: flex;
	flex-direction: column;
}
.fuc-caption {
	color: rgba(255, 255, 255, 0.92);
	font-size: 26rpx;
	line-height: 38rpx;
	overflow: hidden;
	text-overflow: ellipsis;
}
.fuc-caption--clip {
	flex: 1;
	height: 38rpx;
	overflow: hidden;
}
.fuc-caption-toggle {
	color: rgba(255, 255, 255, 0.65);
	font-size: 26rpx;
	line-height: 38rpx;
	padding-left: 8rpx;
}
.fuc-caption-expanded .fuc-caption-toggle {
	padding-left: 0;
	padding-top: 4rpx;
}

/* ─── 评论弹层 ─── */
.reel-comment-mask {
	position: fixed;
	left: 0;
	top: 0;
	z-index: 50;
	background-color: rgba(0, 0, 0, 0.4);
}
.reel-comment-sheet {
	position: fixed;
	left: 0;
	bottom: 0;
	z-index: 51;
	background-color: rgba(10, 10, 10, 0.98);
	border-top-left-radius: 24rpx;
	border-top-right-radius: 24rpx;
	overflow: hidden;
}
.reel-comment-header {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	padding: 24rpx 32rpx;
	border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.reel-comment-title {
	color: #ffffff;
	font-size: 30rpx;
	font-weight: 600;
}
.reel-comment-close {
	width: 60rpx;
	height: 60rpx;
	display: flex;
	align-items: center;
	justify-content: center;
}
.reel-comment-close-icon {
	color: rgba(255, 255, 255, 0.7);
	font-size: 48rpx;
	line-height: 48rpx;
}

/* ─── 合辑付费解锁遮罩 ─── */
.paid-video-overlay {
	position: absolute;
	top: 0;
	left: 0;
	background-color: #000000;
	overflow: hidden;
	z-index: 10;
}
.paid-video-cover {
	position: absolute;
	top: 0;
	left: 0;
	background-color: #000000;
	z-index: 1;
}
.paid-video-mask {
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	background-color: rgba(0, 0, 0, 0.5);
	z-index: 2;
}
.paid-video-btn-wrap {
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	z-index: 3;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
}
.paid-video-btn {
	background-color: #e6162c;
	border-radius: 50rpx;
	padding: 24rpx 48rpx;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	min-width: 200rpx;
}
.paid-video-btn-icon {
	width: 36rpx;
	height: 36rpx;
	margin-right: 12rpx;
}
.paid-video-btn-amount {
	color: #ffffff;
	font-size: 28rpx;
	font-weight: bold;
	margin-right: 12rpx;
}
.paid-video-btn-text {
	color: #ffffff;
	font-size: 32rpx;
	font-weight: bold;
}

/* ─── 挂商品 ─── */
.reel-goods {
	position: relative;
	z-index: 9;
	margin-top: 16rpx;
}
.reel-goods-viewport {
	height: 174rpx;
	overflow: hidden;
}
.reel-goods-track {
	display: flex;
	flex-direction: row;
	align-items: center;
	height: 174rpx;
}
.reel-goods-item {
	display: flex;
	flex-direction: row;
	align-items: center;
	flex-shrink: 0;
	background-color: rgba(255, 255, 255, 0.92);
	border-radius: 18rpx;
	margin-left: 14rpx;
	padding: 14rpx;
	height: 154rpx;
	box-sizing: border-box;
}
.reel-goods-tail {
	width: 24rpx;
	height: 154rpx;
	flex-shrink: 0;
}
.reel-goods-img {
	width: 126rpx;
	height: 126rpx;
	margin-right: 14rpx;
	border-radius: 4rpx;
	flex-shrink: 0;
	background-color: #f0f0f0;
}
.reel-goods-body {
	flex: 1;
	min-width: 0;
	height: 126rpx;
	display: flex;
	flex-direction: column;
	justify-content: space-between;
}
.reel-goods-title {
	color: rgba(0, 0, 0, 0.8);
	font-size: 24rpx;
	line-height: 34rpx;
	overflow: hidden;
}
.reel-goods-foot {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
}
.reel-goods-price {
	color: #ff6a00;
	font-size: 28rpx;
	line-height: 36rpx;
	font-weight: 600;
}
.reel-goods-buy {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	background-color: #ff6a00;
	border-radius: 8rpx;
	padding: 8rpx 16rpx;
}
.reel-goods-buy-txt {
	color: #ffffff;
	font-size: 22rpx;
	line-height: 28rpx;
}

/* ─── 合辑入口 + 列表弹层 ─── */
.series-entry {
	margin-top: 16rpx;
	align-self: flex-start;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	background-color: rgba(0, 0, 0, 0.5);
	border-radius: 40rpx;
	padding: 12rpx 28rpx;
	position: relative;
	z-index: 9;
}
.series-entry-txt {
	color: #ffffff;
	font-size: 24rpx;
	line-height: 34rpx;
}
.series-sheet {
	position: fixed;
	left: 0;
	bottom: 0;
	z-index: 61;
	background-color: rgba(0, 0, 0, 0.94);
	border-top-left-radius: 26rpx;
	border-top-right-radius: 26rpx;
	overflow: hidden;
}
.series-sheet-header {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	padding: 24rpx 32rpx;
	border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
}
.series-sheet-title {
	color: #ffffff;
	font-size: 30rpx;
	font-weight: 600;
}
.series-sheet-close {
	width: 60rpx;
	height: 60rpx;
	display: flex;
	align-items: center;
	justify-content: center;
}
.series-sheet-close-icon {
	color: rgba(255, 255, 255, 0.7);
	font-size: 48rpx;
	line-height: 48rpx;
}
.series-sheet-scroll {
	padding-left: 30rpx;
	padding-right: 30rpx;
}
.series-row {
	display: flex;
	flex-direction: row;
	align-items: center;
	padding-top: 24rpx;
	padding-bottom: 24rpx;
	border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
}
.series-row-cover {
	width: 120rpx;
	height: 120rpx;
	border-radius: 12rpx;
	margin-right: 24rpx;
	background-color: rgba(255, 255, 255, 0.1);
	flex-shrink: 0;
}
.series-row-body {
	flex: 1;
	display: flex;
	flex-direction: column;
	justify-content: center;
	overflow: hidden;
}
.series-row-title-line {
	display: flex;
	flex-direction: row;
	align-items: center;
	margin-bottom: 8rpx;
	min-height: 56rpx;
}
.series-row-title {
	flex: 1;
	min-width: 0;
	color: #ffffff;
	font-size: 28rpx;
	line-height: 40rpx;
	overflow: hidden;
	lines: 1;
	text-overflow: ellipsis;
}
.series-row-actions {
	margin-left: 16rpx;
	flex-shrink: 0;
	display: flex;
	flex-direction: row;
	align-items: center;
}
.series-row-badge {
	margin-right: 16rpx;
	flex-shrink: 0;
	height: 56rpx;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
}
.series-row-lock {
	width: 32rpx;
	height: 32rpx;
}
.series-row-free {
	color: #4caf50;
	font-size: 22rpx;
	font-weight: 500;
	line-height: 28rpx;
	padding: 4rpx 12rpx;
	background-color: rgba(76, 175, 80, 0.2);
	border-radius: 8rpx;
}
.series-row-unlocked {
	color: #64b5f6;
	font-size: 22rpx;
	font-weight: 500;
	line-height: 28rpx;
	padding: 4rpx 12rpx;
	background-color: rgba(100, 181, 246, 0.2);
	border-radius: 8rpx;
}
.series-row-time {
	color: rgba(255, 255, 255, 0.55);
	font-size: 22rpx;
	line-height: 32rpx;
	margin-top: 4rpx;
}
.series-row-price {
	color: #ffb020;
	font-size: 24rpx;
	line-height: 34rpx;
	font-weight: 600;
}
.series-row-play {
	width: 56rpx;
	height: 56rpx;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	background-color: rgba(255, 255, 255, 0.2);
	border-radius: 28rpx;
	flex-shrink: 0;
}
.series-row-play-icon {
	color: #ffffff;
	font-size: 22rpx;
	line-height: 22rpx;
	text-align: center;
}
</style>