This commit is contained in:
truewhile
2026-09-01 16:26:25 +08:00
parent 73de139d1f
commit af67f4cd6e
23 changed files with 671 additions and 131 deletions
+9
View File
@@ -187,6 +187,15 @@ export const strmAPI = {
cancelPendingUploads: () =>
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
clearDoneUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-done').then((r) => r.data),
clearFinishedUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-finished').then((r) => r.data),
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
retryFailedUploads: () =>
api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data),
}
+2 -2
View File
@@ -1,4 +1,3 @@
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
@@ -7,6 +6,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types'
import { isDirectStreamMedia } from './playerPageModel'
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
import { MediaDetailPoster } from './MediaDetailArtwork'
import { MediaDetailMetadata } from './MediaDetailMetadata'
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
<span>立即播放</span>
</Link>
{!isRemoteEmbyID(media.id) && (
{!isDirectStreamMedia(media) && (
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
+28 -6
View File
@@ -11,7 +11,8 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import type { Media } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
@@ -186,10 +187,11 @@ export function PlayerPage() {
if (!id) return
mediaAPI.get(id).then((m) => {
setMedia(m)
const isDirect = isDirectStreamMedia(m)
const forced = params.get('mode') as PlayerMode | null
const auto = pickPlayerMode(m)
// 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。
setMode(directOnly ? 'direct' : (forced ?? auto))
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
setMode(directOnly || isDirect ? 'direct' : (forced ?? auto))
setPlayerError('')
})
subtitlesAPI
@@ -429,12 +431,18 @@ export function PlayerPage() {
return () => window.removeEventListener('keydown', onKey)
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
const isDirectStream = isDirectStreamMedia(media)
const toggleMode = useCallback(() => {
if (isDirectStream) {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}, [mode, params, setParams])
}, [isDirectStream, mode, params, setParams])
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
const selectSubtitle = useCallback((index: number) => {
@@ -450,7 +458,13 @@ export function PlayerPage() {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (directOnly) {
if (isRemoteEmbyID(media?.id)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (isDirectStreamMedia(media)) {
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (hlsUnavailable) {
@@ -467,12 +481,20 @@ export function PlayerPage() {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, mode, params, setParams])
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={
isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
: isDirectStream
? 'STRM 直连播放'
: undefined
}
mode={mode}
onBack={goBack}
onToggleMode={toggleMode}
+12 -1
View File
@@ -4,6 +4,8 @@ import type { PlayerMode } from './playerPageModel'
type PlayerTopBarProps = {
directOnly: boolean
isDirectStream?: boolean
directStreamLabel?: string
mode: PlayerMode
onBack: () => void
onToggleMode: () => void
@@ -11,6 +13,8 @@ type PlayerTopBarProps = {
export function PlayerTopBar({
directOnly,
isDirectStream,
directStreamLabel,
mode,
onBack,
onToggleMode,
@@ -25,7 +29,14 @@ export function PlayerTopBar({
</button>
<div className="pointer-events-auto flex items-center gap-2">
{directOnly ? (
{isDirectStream ? (
<span
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
>
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
</span>
) : directOnly ? (
<span
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
+42 -35
View File
@@ -309,7 +309,10 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.retryFailedDownloads(),
() =>
isDownload
? strmAPI.retryFailedDownloads()
: strmAPI.retryFailedUploads(),
'确定重新入队所有失败任务?',
)
}}
@@ -340,23 +343,26 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
</button>
)}
<div className="my-1 border-t border-gray-100" />
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.clearDoneDownloads(),
'确定清空所有已完成的下载记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录</span>
</button>
)}
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() =>
isDownload
? strmAPI.clearDoneDownloads()
: strmAPI.clearDoneUploads(),
isDownload
? '确定清空所有已完成的下载记录?'
: '确定清空所有已完成的上传记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录</span>
</button>
<button
type="button"
disabled={batchBusy}
@@ -375,23 +381,24 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录</span>
</button>
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.clearFinishedDownloads(),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录</span>
</button>
)}
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() =>
isDownload
? strmAPI.clearFinishedDownloads()
: strmAPI.clearFinishedUploads(),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录</span>
</button>
</div>
</details>
</div>
+17 -4
View File
@@ -1,4 +1,5 @@
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
export type PlayerMode = 'direct' | 'hls'
@@ -6,16 +7,27 @@ const directContainers = ['mp4', 'webm', 'm4v']
const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
/**
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
*/
export function isDirectStreamMedia(media?: Media | null): boolean {
if (!media) return false
if (isRemoteEmbyID(media.id)) return true
const container = (media.container ?? '').toLowerCase()
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
}
export function pickPlayerMode(media: Media): PlayerMode {
return needsTranscodeForBrowser(media) ? 'hls' : 'direct'
}
export function needsTranscodeForBrowser(media: Media): boolean {
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
if (isDirectStreamMedia(media)) return false
const container = (media.container ?? '').toLowerCase()
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
// 文本 strm),恒走 direct play。
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
const videoCodec = (media.video_codec ?? '').toLowerCase()
const audioCodec = (media.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((item) => container.includes(item))
@@ -23,3 +35,4 @@ export function needsTranscodeForBrowser(media: Media): boolean {
const audioOK = !audioCodec || directAudioCodecs.some((item) => audioCodec.includes(item))
return !(containerOK && videoOK && audioOK)
}
+6 -4
View File
@@ -58,7 +58,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
const loadAll = async () => {
if (isSeriesLibrary) {
const collected = await loadAllSeriesCards(libraryID, (next) => {
const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
@@ -70,7 +70,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
return
}
const collected = await loadAllMedia(libraryID, (next) => {
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
@@ -147,9 +147,10 @@ function isSeriesLibraryType(type?: string) {
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = 500
const pageSize = isRemoteEmby ? 100 : 500
let page = 1
let collected: SeriesCard[] = []
for (;;) {
@@ -166,9 +167,10 @@ async function loadAllSeriesCards(
async function loadAllMedia(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = 2000
const pageSize = isRemoteEmby ? 100 : 2000
let page = 1
let collected: Media[] = []
for (;;) {