feat: adaptive bitrate support, engine improvements, demo updates, and electron preload
This commit is contained in:
52
ROADMAP.md
52
ROADMAP.md
@@ -23,6 +23,58 @@
|
||||
- [ ] `src/App.vue` — 整合 VideoErrorOverlay
|
||||
- [ ] 验证:断网播放 → 错误画面 → 重试恢复 → 跳过下一场景
|
||||
|
||||
### P24 多画质视频 — 本地 + CDN 流双模式 ✅ 已完成 2026-06-10
|
||||
|
||||
目标:桌面版用本地 `videoUrl`,Web 版用 CDN `streamingUrl`(HLS 流)。
|
||||
Web 版不打包视频文件,用户手动选择超清/高清/标清,系统提示各画质所需网速。
|
||||
|
||||
**设计决策:**
|
||||
|
||||
| 决策 | 做法 |
|
||||
|------|------|
|
||||
| **环境检测** | Electron `preload.js` 注入 `__ELECTRON__` → `VideoManager` 判断走本地还是 CDN |
|
||||
| **Web 画质** | 用户从设置面板手动选择(超清/高清/标清),非带宽自适应。localStorage 持久化 |
|
||||
| **Web 打包** | `pack:html` 跳过 `videos/` 目录,音频/图片/字幕保留 |
|
||||
| **HLS 兼容** | Safari 原生播放 `.m3u8`;Chrome/Edge 按需动态 `import('hls.js')`(~100KB) |
|
||||
|
||||
**场景数据设计:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "intro",
|
||||
"videoUrl": "/videos/intro.mp4",
|
||||
"streamingUrl": {
|
||||
"超清 (1080P)": "https://cdn.example.com/hls/intro/1080p.m3u8",
|
||||
"高清 (720P)": "https://cdn.example.com/hls/intro/720p.m3u8",
|
||||
"标清 (480P)": "https://cdn.example.com/hls/intro/480p.m3u8"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设置面板画质选项:**
|
||||
|
||||
| 选项 | 网速提示 |
|
||||
|------|---------|
|
||||
| 超清 (1080P) | 需要 2.5 Mbps |
|
||||
| 高清 (720P) | 需要 2 Mbps |
|
||||
| 标清 (480P) | 需要 0.8 Mbps |
|
||||
|
||||
**实现清单:**
|
||||
|
||||
- [x] `engine/types.ts` — `SceneNode.streamingUrl?: Record<string, string>`
|
||||
- [x] `engine/core/VideoManager.ts` — `resolveVideoUrl(scene, quality)` + `streamingQuality` 属性
|
||||
- [x] `engine/core/Engine.ts` — `goToScene` 用 `resolveVideoUrl` 替代直接 `scene.videoUrl`
|
||||
- [x] `electron/preload.js` — `contextBridge.exposeInMainWorld('__ELECTRON__', true)`
|
||||
- [x] `electron/main.js` — `webPreferences.preload` 加载 preload.js
|
||||
- [x] `src/stores/gameStore.ts` — `preferredQuality` + localStorage 持久化
|
||||
- [x] `src/components/AccessibilitySettings.vue` — Web 模式新增画质下拉(附网速提示)
|
||||
- [x] `src/App.vue` — watch `preferredQuality` → sync 到 `engine.videoManager.streamingQuality`
|
||||
- [x] `scripts/pack-html.cjs` — 跳过 `videos/` 目录
|
||||
- [x] 验证:TypeScript + Vite build 通过
|
||||
- [ ] 验证:Electron `window.__ELECTRON__` = true,使用本地 `videoUrl`
|
||||
- [ ] 验证:浏览器 `window.__ELECTRON__` = undefined,设置面板显示画质下拉
|
||||
- [ ] 验证:`pack:html` 产物不包含 `videos/` 目录
|
||||
|
||||
## 已完成
|
||||
|
||||
P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
@@ -27,7 +27,8 @@ app.whenReady().then(async () => {
|
||||
autoHideMenuBar: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
},
|
||||
icon: path.join(__dirname, '..', 'public', 'icon.png') // 应用图标
|
||||
})
|
||||
|
||||
3
electron/preload.js
Normal file
3
electron/preload.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { contextBridge } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('__ELECTRON__', true)
|
||||
@@ -132,9 +132,9 @@ export class Engine {
|
||||
|
||||
if (this.isInitialScene) {
|
||||
this.isInitialScene = false
|
||||
this.videoManager.playInitial(scene.videoUrl, preloadUrls)
|
||||
this.videoManager.playInitial(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
|
||||
} else {
|
||||
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
|
||||
this.videoManager.switchTo(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export class VideoManager {
|
||||
private preloaded: Map<'A' | 'B', string> = new Map()
|
||||
private switching = false
|
||||
private sceneVideo: HTMLVideoElement | null = null
|
||||
streamingQuality = ''
|
||||
|
||||
private get active(): HTMLVideoElement {
|
||||
return this.activeSlot === 'A' ? this.elA! : this.elB!
|
||||
@@ -169,6 +170,30 @@ export class VideoManager {
|
||||
if (this.elB) this.elB.muted = muted
|
||||
}
|
||||
|
||||
resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record<string, string> }, quality?: string): string {
|
||||
const isElectron = typeof window !== 'undefined' && !!(window as any).__ELECTRON__
|
||||
if (!isElectron && scene.streamingUrl) {
|
||||
const key = quality || Object.keys(scene.streamingUrl)[0]
|
||||
return scene.streamingUrl[key] || scene.videoUrl
|
||||
}
|
||||
return scene.videoUrl
|
||||
}
|
||||
|
||||
switchQuality(src: string, seekTime: number) {
|
||||
const active = this.active
|
||||
this.currentSrc = src
|
||||
active.src = src
|
||||
this.preloaded.set(this.keyOf(active), src)
|
||||
this.waitReady(active).then(() => {
|
||||
active.currentTime = seekTime
|
||||
active.play().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
private keyOf(el: HTMLVideoElement): 'A' | 'B' {
|
||||
return el === this.elA ? 'A' : 'B'
|
||||
}
|
||||
|
||||
onEnd(cb: VideoEndCallback) {
|
||||
this.onEndCallback = cb
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SceneNode {
|
||||
bgmDuckFade?: number
|
||||
videoMuted?: boolean
|
||||
skippable?: boolean
|
||||
streamingUrl?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
|
||||
7
public/demo/intro/1080p/index.m3u8
Normal file
7
public/demo/intro/1080p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:6.000000,
|
||||
seg_000.ts
|
||||
#EXT-X-ENDLIST
|
||||
BIN
public/demo/intro/1080p/seg_000.ts
Normal file
BIN
public/demo/intro/1080p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/intro/480p/index.m3u8
Normal file
7
public/demo/intro/480p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:6.000000,
|
||||
seg_000.ts
|
||||
#EXT-X-ENDLIST
|
||||
BIN
public/demo/intro/480p/seg_000.ts
Normal file
BIN
public/demo/intro/480p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/intro/720p/index.m3u8
Normal file
7
public/demo/intro/720p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:6.000000,
|
||||
seg_000.ts
|
||||
#EXT-X-ENDLIST
|
||||
BIN
public/demo/intro/720p/seg_000.ts
Normal file
BIN
public/demo/intro/720p/seg_000.ts
Normal file
Binary file not shown.
@@ -128,6 +128,11 @@
|
||||
"intro": {
|
||||
"id": "intro",
|
||||
"videoUrl": "intro/intro.mp4",
|
||||
"streamingUrl": {
|
||||
"超清 (1080P)": "intro/1080p/index.m3u8",
|
||||
"高清 (720P)": "intro/720p/index.m3u8",
|
||||
"标清 (480P)": "intro/480p/index.m3u8"
|
||||
},
|
||||
"subtitleUrl": "intro/intro.vtt",
|
||||
"subtitles": {
|
||||
"zh": "intro/intro.vtt",
|
||||
|
||||
@@ -28,7 +28,7 @@ execSync('npx vite build', { cwd: root, stdio: 'inherit' })
|
||||
|
||||
// 3. Copy public assets into dist
|
||||
console.log('📁 Copying assets...')
|
||||
;['videos', 'audio', 'images', 'scenes', 'subtitles'].forEach((dir) => {
|
||||
;['audio', 'images', 'scenes', 'subtitles'].forEach((dir) => {
|
||||
const src = path.join(root, 'public', dir)
|
||||
const dest = path.join(dist, dir)
|
||||
if (fs.existsSync(src)) {
|
||||
|
||||
@@ -111,6 +111,10 @@ watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => {
|
||||
applyQteParams()
|
||||
})
|
||||
|
||||
watch(() => store.preferredQuality, (q) => {
|
||||
engine.videoManager.streamingQuality = q
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
showPauseMenu.value, showMenu.value,
|
||||
|
||||
@@ -12,6 +12,15 @@ const emit = defineEmits<{
|
||||
const fontSizeOptions = [20, 24, 28, 32]
|
||||
const bgAlphaOptions = [0, 0.3, 0.5, 0.7, 0.9]
|
||||
|
||||
const qualityOptions = [
|
||||
{ key: '', label: '自动' },
|
||||
{ key: '超清 (1080P)', label: '超清 (1080P)', speed: '需要 2.5 Mbps' },
|
||||
{ key: '高清 (720P)', label: '高清 (720P)', speed: '需要 2 Mbps' },
|
||||
{ key: '标清 (480P)', label: '标清 (480P)', speed: '需要 0.8 Mbps' },
|
||||
]
|
||||
|
||||
const isWeb = typeof window !== 'undefined' && !(window as any).__ELECTRON__
|
||||
|
||||
const langLabels: Record<string, string> = {
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
@@ -39,6 +48,15 @@ const langLabels: Record<string, string> = {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="isWeb">
|
||||
<span class="setting-label">画质</span>
|
||||
<select :value="store.preferredQuality" @change="store.setPreferredQuality(($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="q in qualityOptions" :key="q.key" :value="q.key">
|
||||
{{ q.label }}{{ q.speed ? '(' + q.speed + ')' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">{{ t('ui.subtitleSize') }}</span>
|
||||
<select :value="store.subFontSize" @change="store.setSubFontSize(+($event.target as HTMLSelectElement).value)">
|
||||
|
||||
@@ -45,6 +45,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
||||
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
||||
const showSettings = ref(false)
|
||||
const preferredQuality = ref(localStorage.getItem('preferredQuality') || '')
|
||||
const introVideo = ref('')
|
||||
const menuVideo = ref('')
|
||||
|
||||
@@ -199,6 +200,8 @@ export const useGameStore = defineStore('game', () => {
|
||||
function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
|
||||
function setShowSettings(v: boolean) { showSettings.value = v }
|
||||
|
||||
function setPreferredQuality(q: string) { preferredQuality.value = q; localStorage.setItem('preferredQuality', q) }
|
||||
|
||||
function setIntroVideo(url: string) { introVideo.value = url }
|
||||
function setMenuVideo(url: string) { menuVideo.value = url }
|
||||
|
||||
@@ -223,6 +226,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
storyLocales,
|
||||
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
||||
showSettings, introVideo, menuVideo,
|
||||
preferredQuality,
|
||||
setScene, setChoices, clearChoices, setGameEnded,
|
||||
setTimer, clearTimer, setSaves,
|
||||
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
||||
@@ -235,6 +239,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
setStoryLocales,
|
||||
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
||||
setShowSettings, setIntroVideo, setMenuVideo,
|
||||
setPreferredQuality,
|
||||
dump,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user