Skip to content

Commit 3a3c361

Browse files
authored
Merge pull request #766 from apoint123/feat/stream-decoding-for-ffmpeg
✨ feat: 为 ffmpeg 解码器添加流式解码功能
2 parents 5d07916 + ab16a75 commit 3a3c361

18 files changed

Lines changed: 1662 additions & 761 deletions

electron/main/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { electronApp } from "@electron-toolkit/utils";
2-
import { app, BrowserWindow } from "electron";
2+
import { app, BrowserWindow, session } from "electron";
33
import { existsSync, mkdirSync } from "fs";
44
import { join } from "path";
55
import initAppServer from "../server";
@@ -70,6 +70,23 @@ class MainProcess {
7070
// 某些 API 只有在此事件发生后才能使用
7171
app.whenReady().then(async () => {
7272
processLog.info("🚀 Application Process Startup");
73+
74+
// 配置 COOP/COEP/CORP 头,FFmpeg 需要
75+
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
76+
const responseHeaders = { ...details.responseHeaders };
77+
78+
// 同样可以解决 CORS 限制,但为了避免安全问题,等真有需要的时候再开
79+
// responseHeaders["Access-Control-Allow-Origin"] = ["*"];
80+
// responseHeaders["Access-Control-Allow-Headers"] = ["*"];
81+
82+
// COOP/COEP/CORP 配置
83+
responseHeaders["Cross-Origin-Opener-Policy"] = ["same-origin"];
84+
responseHeaders["Cross-Origin-Embedder-Policy"] = ["require-corp"];
85+
responseHeaders["Cross-Origin-Resource-Policy"] = ["cross-origin"];
86+
87+
callback({ responseHeaders });
88+
});
89+
7390
// 设置应用程序名称
7491
electronApp.setAppUserModelId("com.imsyy.splayer");
7592
// 启动主服务进程

public/wasm/decode-audio.wasm

-2.46 MB
Binary file not shown.

public/wasm/ffmpeg.wasm

2.98 MB
Binary file not shown.

src/components/Player/PlayerRightMenu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const controlsOptions = computed<DropdownOption[]>(() => [
8686
{
8787
label: "播放速度",
8888
key: "rate",
89-
disabled: settingStore.audioEngine === "ffmpeg" && settingStore.playbackEngine !== "mpv",
89+
disabled: settingStore.playbackEngine === "mpv",
9090
icon: renderIcon("PlayRate"),
9191
},
9292
]);

src/components/Setting/PlaySetting.vue

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
<n-select
123123
:value="audioEngineSelectValue"
124124
:options="audioEngineOptions"
125+
:render-option="renderAudioEngineOption"
125126
class="set"
126127
@update:value="handleAudioEngineSelect"
127128
/>
@@ -430,12 +431,13 @@
430431
import { usePlayerController } from "@/core/player/PlayerController";
431432
import { useSettingStore } from "@/stores";
432433
import { isLogin } from "@/utils/auth";
433-
import { isElectron } from "@/utils/env";
434+
import { checkIsolationSupport, isElectron } from "@/utils/env";
434435
import { renderOption } from "@/utils/helper";
435436
import { AI_AUDIO_LEVELS } from "@/utils/meta";
436437
import { openSongUnlockManager } from "@/utils/modal";
437438
import { uniqBy } from "lodash-es";
438-
import type { SelectOption } from "naive-ui";
439+
import { NTooltip, type SelectOption } from "naive-ui";
440+
import { h, type VNodeChild } from "vue";
439441
440442
const player = usePlayerController();
441443
const settingStore = useSettingStore();
@@ -444,6 +446,11 @@ const outputDevices = ref<SelectOption[]>([]);
444446
445447
// 统一处理音频引擎选择
446448
const handleAudioEngineSelect = async (value: "element" | "ffmpeg" | "mpv") => {
449+
if (value === "ffmpeg" && !checkIsolationSupport()) {
450+
window.$message.warning("当前环境不支持 FFmpeg 引擎,已回退至默认引擎");
451+
return;
452+
}
453+
447454
const targetPlaybackEngine = value === "mpv" ? "mpv" : "web-audio";
448455
// 如果是切回 web-audio,且 value 为 element/ffmpeg,则更新 audioEngine
449456
const targetAudioEngine = value !== "mpv" ? value : settingStore.audioEngine;
@@ -511,10 +518,28 @@ const engineTip = computed(() => {
511518
return audioEngineData[settingStore.audioEngine]?.tip;
512519
});
513520
521+
const renderAudioEngineOption = ({ node, option }: { node: VNodeChild; option: SelectOption }) => {
522+
if (option.value === "ffmpeg" && option.disabled) {
523+
return h(
524+
NTooltip,
525+
{ placement: "left", keepAliveOnHover: false },
526+
{
527+
trigger: () => h("div", { style: "cursor: not-allowed;" }, [node]),
528+
default: () => "当前环境不支持 FFmpeg",
529+
},
530+
);
531+
}
532+
return node;
533+
};
534+
514535
// 组合下拉选项:包含 WebAudio / FFmpeg / MPV
515536
const audioEngineOptions = [
516537
{ label: "Web Audio (默认)", value: "element" },
517-
{ label: "FFmpeg", value: "ffmpeg" },
538+
{
539+
label: "FFmpeg",
540+
value: "ffmpeg",
541+
disabled: !checkIsolationSupport(),
542+
},
518543
{ label: "MPV", value: "mpv" },
519544
];
520545

src/core/audio-player/AudioElementPlayer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,12 @@ export class AudioElementPlayer extends BaseAudioPlayer {
187187
events.forEach((eventType) => {
188188
this.audioElement.addEventListener(eventType, (e) => {
189189
if (eventType === AUDIO_EVENTS.ERROR) {
190-
this.emit(AUDIO_EVENTS.ERROR, {
190+
this.dispatch(AUDIO_EVENTS.ERROR, {
191191
originalEvent: e,
192192
errorCode: this.getErrorCode(),
193193
});
194194
} else {
195-
this.emit(eventType);
195+
this.dispatch(eventType);
196196
}
197197
});
198198
});

src/core/audio-player/BaseAudioPlayer.ts

Lines changed: 32 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TypedEventTarget } from "@/utils/TypedEventTarget";
12
import { AudioEffectManager } from "./AudioEffectManager";
23
import type { EngineCapabilities, IPlaybackEngine } from "./IPlaybackEngine";
34

@@ -19,14 +20,20 @@ export const AUDIO_EVENTS = {
1920
ERROR: "error",
2021
CAN_PLAY: "canplay",
2122
LOAD_START: "loadstart",
23+
SEEKED: "seeked",
24+
WAITING: "waiting",
25+
VOLUME_CHANGE: "volumechange",
26+
PLAYING: "playing",
27+
SEEKING: "seeking",
28+
EMPTIED: "emptied",
2229
} as const;
2330

2431
export type AudioEventType = (typeof AUDIO_EVENTS)[keyof typeof AUDIO_EVENTS];
2532

2633
export type AudioEventMap = {
2734
[K in AudioEventType]: K extends typeof AUDIO_EVENTS.ERROR
2835
? CustomEvent<AudioErrorDetail>
29-
: Event;
36+
: CustomEvent<undefined>;
3037
};
3138

3239
export enum AudioErrorCode {
@@ -50,7 +57,10 @@ const SEEK_FADE_TIME = 0.05;
5057
* 管理 AudioContext、音量增益、EQ连接、以及通用的淡入淡出/Seek逻辑
5158
* 实现 IPlaybackEngine 接口
5259
*/
53-
export abstract class BaseAudioPlayer extends EventTarget implements IPlaybackEngine {
60+
export abstract class BaseAudioPlayer
61+
extends TypedEventTarget<AudioEventMap>
62+
implements IPlaybackEngine
63+
{
5464
/** 核心上下文 */
5565
protected audioCtx: IExtendedAudioContext | null = null;
5666
/** 主输出增益节点 (控制音量) */
@@ -183,7 +193,7 @@ export abstract class BaseAudioPlayer extends EventTarget implements IPlaybackEn
183193
const duration = options.fadeOut ? (options.fadeDuration ?? 0.5) : 0;
184194

185195
const performPause = async () => {
186-
this.doPause();
196+
await this.doPause();
187197

188198
if (this.audioCtx && this.audioCtx.state === "running") {
189199
try {
@@ -211,26 +221,36 @@ export abstract class BaseAudioPlayer extends EventTarget implements IPlaybackEn
211221
* 跳转进度
212222
* @param time 目标时间 (秒)
213223
*/
214-
public async seek(time: number) {
224+
public async seek(time: number, immediate = false) {
215225
this.cancelPendingPause();
216226
// 如果已经暂停,直接跳转
217227
if (this.paused) {
218228
this.doSeek(time);
219229
return;
220230
}
221-
this.applyFadeTo(0, SEEK_FADE_TIME);
222-
await new Promise((resolve) => setTimeout(resolve, SEEK_FADE_TIME * 1000));
223-
this.doSeek(time);
224-
this.applyFadeTo(this.volume, SEEK_FADE_TIME);
231+
232+
if (!immediate) {
233+
this.applyFadeTo(0, SEEK_FADE_TIME);
234+
await new Promise((resolve) => setTimeout(resolve, SEEK_FADE_TIME * 1000));
235+
}
236+
237+
await this.doSeek(time);
238+
239+
if (!immediate) {
240+
this.applyFadeTo(this.volume, SEEK_FADE_TIME);
241+
} else {
242+
this.applyFadeTo(this.volume, 0);
243+
}
225244
}
226245

227246
/**
228247
* 停止播放并重置
229248
*/
230249
public stop() {
231250
this.cancelPendingPause();
232-
this.pause({ fadeOut: false });
233-
this.doSeek(0);
251+
// 捕获可能产生的异步错误
252+
Promise.resolve(this.pause({ fadeOut: false })).catch(() => {});
253+
Promise.resolve(this.doSeek(0)).catch(() => {});
234254
}
235255

236256
/**
@@ -340,10 +360,10 @@ export abstract class BaseAudioPlayer extends EventTarget implements IPlaybackEn
340360
protected abstract doPlay(): Promise<void>;
341361

342362
/** 执行底层暂停 */
343-
protected abstract doPause(): void;
363+
protected abstract doPause(): void | Promise<void>;
344364

345365
/** 执行底层 Seek */
346-
protected abstract doSeek(time: number): void;
366+
protected abstract doSeek(time: number): void | Promise<void>;
347367

348368
/** 设置播放速率 */
349369
public abstract setRate(value: number): void;
@@ -359,47 +379,4 @@ export abstract class BaseAudioPlayer extends EventTarget implements IPlaybackEn
359379
public abstract get currentTime(): number;
360380
public abstract get paused(): boolean;
361381
public abstract getErrorCode(): number;
362-
363-
/**
364-
* 触发事件
365-
* @param type 事件类型
366-
* @param detail 事件详情
367-
*/
368-
protected emit(type: Exclude<AudioEventType, "error">): void;
369-
protected emit(type: typeof AUDIO_EVENTS.ERROR, detail: AudioErrorDetail): void;
370-
protected emit(type: AudioEventType, detail?: AudioErrorDetail): void {
371-
if (type === AUDIO_EVENTS.ERROR && detail) {
372-
this.dispatchEvent(new CustomEvent(type, { detail }));
373-
} else {
374-
this.dispatchEvent(new Event(type));
375-
}
376-
}
377-
378-
/**
379-
* 添加事件监听
380-
* @param type 事件类型
381-
* @param listener 事件监听器
382-
* @param options 事件选项
383-
*/
384-
public override addEventListener<K extends keyof AudioEventMap>(
385-
type: K,
386-
listener: (this: BaseAudioPlayer, ev: AudioEventMap[K]) => unknown,
387-
options?: boolean | AddEventListenerOptions,
388-
): void {
389-
super.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
390-
}
391-
392-
/**
393-
* 移除事件监听
394-
* @param type 事件类型
395-
* @param listener 事件监听器
396-
* @param options 事件选项
397-
*/
398-
public override removeEventListener<K extends keyof AudioEventMap>(
399-
type: K,
400-
listener: (this: BaseAudioPlayer, ev: AudioEventMap[K]) => unknown,
401-
options?: boolean | EventListenerOptions,
402-
): void {
403-
super.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
404-
}
405382
}

0 commit comments

Comments
 (0)