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
|
- [ ] `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)。
|
P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ app.whenReady().then(async () => {
|
|||||||
autoHideMenuBar: false,
|
autoHideMenuBar: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js')
|
||||||
},
|
},
|
||||||
icon: path.join(__dirname, '..', 'public', 'icon.png') // 应用图标
|
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) {
|
if (this.isInitialScene) {
|
||||||
this.isInitialScene = false
|
this.isInitialScene = false
|
||||||
this.videoManager.playInitial(scene.videoUrl, preloadUrls)
|
this.videoManager.playInitial(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
|
||||||
} else {
|
} 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 preloaded: Map<'A' | 'B', string> = new Map()
|
||||||
private switching = false
|
private switching = false
|
||||||
private sceneVideo: HTMLVideoElement | null = null
|
private sceneVideo: HTMLVideoElement | null = null
|
||||||
|
streamingQuality = ''
|
||||||
|
|
||||||
private get active(): HTMLVideoElement {
|
private get active(): HTMLVideoElement {
|
||||||
return this.activeSlot === 'A' ? this.elA! : this.elB!
|
return this.activeSlot === 'A' ? this.elA! : this.elB!
|
||||||
@@ -169,6 +170,30 @@ export class VideoManager {
|
|||||||
if (this.elB) this.elB.muted = muted
|
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) {
|
onEnd(cb: VideoEndCallback) {
|
||||||
this.onEndCallback = cb
|
this.onEndCallback = cb
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface SceneNode {
|
|||||||
bgmDuckFade?: number
|
bgmDuckFade?: number
|
||||||
videoMuted?: boolean
|
videoMuted?: boolean
|
||||||
skippable?: boolean
|
skippable?: boolean
|
||||||
|
streamingUrl?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
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": {
|
"intro": {
|
||||||
"id": "intro",
|
"id": "intro",
|
||||||
"videoUrl": "intro/intro.mp4",
|
"videoUrl": "intro/intro.mp4",
|
||||||
|
"streamingUrl": {
|
||||||
|
"超清 (1080P)": "intro/1080p/index.m3u8",
|
||||||
|
"高清 (720P)": "intro/720p/index.m3u8",
|
||||||
|
"标清 (480P)": "intro/480p/index.m3u8"
|
||||||
|
},
|
||||||
"subtitleUrl": "intro/intro.vtt",
|
"subtitleUrl": "intro/intro.vtt",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"zh": "intro/intro.vtt",
|
"zh": "intro/intro.vtt",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ execSync('npx vite build', { cwd: root, stdio: 'inherit' })
|
|||||||
|
|
||||||
// 3. Copy public assets into dist
|
// 3. Copy public assets into dist
|
||||||
console.log('📁 Copying assets...')
|
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 src = path.join(root, 'public', dir)
|
||||||
const dest = path.join(dist, dir)
|
const dest = path.join(dist, dir)
|
||||||
if (fs.existsSync(src)) {
|
if (fs.existsSync(src)) {
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => {
|
|||||||
applyQteParams()
|
applyQteParams()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => store.preferredQuality, (q) => {
|
||||||
|
engine.videoManager.streamingQuality = q
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [
|
() => [
|
||||||
showPauseMenu.value, showMenu.value,
|
showPauseMenu.value, showMenu.value,
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ const emit = defineEmits<{
|
|||||||
const fontSizeOptions = [20, 24, 28, 32]
|
const fontSizeOptions = [20, 24, 28, 32]
|
||||||
const bgAlphaOptions = [0, 0.3, 0.5, 0.7, 0.9]
|
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> = {
|
const langLabels: Record<string, string> = {
|
||||||
zh: '中文',
|
zh: '中文',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
@@ -39,6 +48,15 @@ const langLabels: Record<string, string> = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="setting-row">
|
||||||
<span class="setting-label">{{ t('ui.subtitleSize') }}</span>
|
<span class="setting-label">{{ t('ui.subtitleSize') }}</span>
|
||||||
<select :value="store.subFontSize" @change="store.setSubFontSize(+($event.target as HTMLSelectElement).value)">
|
<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 antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
||||||
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const preferredQuality = ref(localStorage.getItem('preferredQuality') || '')
|
||||||
const introVideo = ref('')
|
const introVideo = ref('')
|
||||||
const menuVideo = 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 setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
|
||||||
function setShowSettings(v: boolean) { showSettings.value = 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 setIntroVideo(url: string) { introVideo.value = url }
|
||||||
function setMenuVideo(url: string) { menuVideo.value = url }
|
function setMenuVideo(url: string) { menuVideo.value = url }
|
||||||
|
|
||||||
@@ -223,6 +226,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
storyLocales,
|
storyLocales,
|
||||||
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
||||||
showSettings, introVideo, menuVideo,
|
showSettings, introVideo, menuVideo,
|
||||||
|
preferredQuality,
|
||||||
setScene, setChoices, clearChoices, setGameEnded,
|
setScene, setChoices, clearChoices, setGameEnded,
|
||||||
setTimer, clearTimer, setSaves,
|
setTimer, clearTimer, setSaves,
|
||||||
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
||||||
@@ -235,6 +239,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
setStoryLocales,
|
setStoryLocales,
|
||||||
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
||||||
setShowSettings, setIntroVideo, setMenuVideo,
|
setShowSettings, setIntroVideo, setMenuVideo,
|
||||||
|
setPreferredQuality,
|
||||||
dump,
|
dump,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user