ziplayer 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugins/index.d.ts +1 -8
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +59 -107
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.d.ts +9 -24
- package/dist/structures/FilterManager.d.ts.map +1 -1
- package/dist/structures/FilterManager.js +182 -93
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/Player.d.ts +8 -1
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +233 -133
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PreloadManager.d.ts +1 -0
- package/dist/structures/PreloadManager.d.ts.map +1 -1
- package/dist/structures/PreloadManager.js +26 -6
- package/dist/structures/PreloadManager.js.map +1 -1
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +4 -0
- package/dist/structures/Queue.js.map +1 -1
- package/dist/structures/StreamManager.d.ts +8 -0
- package/dist/structures/StreamManager.d.ts.map +1 -1
- package/dist/structures/StreamManager.js +23 -0
- package/dist/structures/StreamManager.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/index.ts +70 -120
- package/src/structures/FilterManager.ts +439 -303
- package/src/structures/Player.ts +268 -140
- package/src/structures/PreloadManager.ts +293 -274
- package/src/structures/Queue.ts +5 -0
- package/src/structures/StreamManager.ts +585 -563
- package/src/types/index.ts +1 -0
package/src/structures/Player.ts
CHANGED
|
@@ -118,6 +118,7 @@ export class Player extends EventEmitter {
|
|
|
118
118
|
|
|
119
119
|
private skipLoop = false;
|
|
120
120
|
private refreshLock = false;
|
|
121
|
+
private seekInProgress = false;
|
|
121
122
|
private remoteHandle: StreamInfo["handle"];
|
|
122
123
|
|
|
123
124
|
private currentSlot: StreamSlot = {
|
|
@@ -171,6 +172,7 @@ export class Player extends EventEmitter {
|
|
|
171
172
|
private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
172
173
|
private ttsPlayer: DiscordAudioPlayer | null = null;
|
|
173
174
|
private lastDuration: number = 0;
|
|
175
|
+
private seekOffset: number = 0;
|
|
174
176
|
|
|
175
177
|
constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
|
|
176
178
|
super();
|
|
@@ -267,7 +269,7 @@ export class Player extends EventEmitter {
|
|
|
267
269
|
extractorTimeout: this.options.extractorTimeout,
|
|
268
270
|
});
|
|
269
271
|
this.streamManager = new StreamManager({
|
|
270
|
-
maxConcurrentStreams:
|
|
272
|
+
maxConcurrentStreams: 4,
|
|
271
273
|
streamTimeout: 5 * 60 * 1000,
|
|
272
274
|
maxListenersPerStream: 15,
|
|
273
275
|
enableMetrics: true,
|
|
@@ -276,7 +278,12 @@ export class Player extends EventEmitter {
|
|
|
276
278
|
this.preloadManager = new PreloadManager({
|
|
277
279
|
streamManager: this.streamManager,
|
|
278
280
|
debug: this.debug.bind(this),
|
|
279
|
-
getNextTrack: () =>
|
|
281
|
+
getNextTrack: () => {
|
|
282
|
+
if (this.queue.loop() === "track") {
|
|
283
|
+
return this.queue.currentTrack;
|
|
284
|
+
}
|
|
285
|
+
return this.queue.nextTrack;
|
|
286
|
+
},
|
|
280
287
|
getStream: (track) => this.getStream(track),
|
|
281
288
|
isDestroyed: () => this.destroyed,
|
|
282
289
|
isEnabled: () => this.preloadEnabled,
|
|
@@ -880,48 +887,41 @@ export class Player extends EventEmitter {
|
|
|
880
887
|
* @param {number} position - Position in milliseconds to seek to (0 = no seek)
|
|
881
888
|
* @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
|
|
882
889
|
*/
|
|
883
|
-
private async createResource(
|
|
890
|
+
private async createResource(
|
|
891
|
+
streamInfo: StreamInfo,
|
|
892
|
+
track: Track,
|
|
893
|
+
position: number = 0,
|
|
894
|
+
): Promise<{ resource: AudioResource; processedStream: import("stream").Readable | null }> {
|
|
884
895
|
const filterString = this.filter.getFilterString();
|
|
896
|
+
this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
|
|
885
897
|
|
|
886
|
-
this.
|
|
898
|
+
this.filter.setSourceStreamType(streamInfo.type);
|
|
887
899
|
|
|
888
|
-
|
|
889
|
-
let stream: Readable = streamInfo.stream;
|
|
890
|
-
// Apply filters and seek if needed
|
|
891
|
-
if (filterString || position > 0) {
|
|
892
|
-
stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
|
|
893
|
-
streamInfo.type = StreamType.Arbitrary;
|
|
894
|
-
}
|
|
900
|
+
const seekArg = position > 0 ? position : -1;
|
|
895
901
|
|
|
896
|
-
|
|
897
|
-
const
|
|
902
|
+
if (filterString || position > 0) {
|
|
903
|
+
const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
|
|
904
|
+
|
|
905
|
+
// rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
|
|
906
|
+
const resource = createAudioResource(processedStream, {
|
|
898
907
|
metadata: track,
|
|
899
|
-
inputType:
|
|
900
|
-
streamInfo.type === "webm/opus" ? StreamType.WebmOpus
|
|
901
|
-
: streamInfo.type === "ogg/opus" ? StreamType.OggOpus
|
|
902
|
-
: StreamType.Arbitrary,
|
|
908
|
+
inputType: StreamType.Arbitrary,
|
|
903
909
|
inlineVolume: true,
|
|
904
910
|
});
|
|
905
911
|
|
|
906
|
-
return resource;
|
|
907
|
-
} catch (error) {
|
|
908
|
-
this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
|
|
909
|
-
// Fallback to basic AudioResource
|
|
910
|
-
try {
|
|
911
|
-
const resource = createAudioResource(streamInfo.stream, {
|
|
912
|
-
metadata: track,
|
|
913
|
-
inputType:
|
|
914
|
-
streamInfo.type === "webm/opus" ? StreamType.WebmOpus
|
|
915
|
-
: streamInfo.type === "ogg/opus" ? StreamType.OggOpus
|
|
916
|
-
: StreamType.Arbitrary,
|
|
917
|
-
inlineVolume: true,
|
|
918
|
-
});
|
|
919
|
-
return resource;
|
|
920
|
-
} catch (fallbackError) {
|
|
921
|
-
this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
|
|
922
|
-
throw fallbackError;
|
|
923
|
-
}
|
|
912
|
+
return { resource, processedStream };
|
|
924
913
|
}
|
|
914
|
+
|
|
915
|
+
const resource = createAudioResource(streamInfo.stream, {
|
|
916
|
+
metadata: track,
|
|
917
|
+
inputType:
|
|
918
|
+
streamInfo.type === "webm/opus" ? StreamType.WebmOpus
|
|
919
|
+
: streamInfo.type === "ogg/opus" ? StreamType.OggOpus
|
|
920
|
+
: StreamType.Arbitrary,
|
|
921
|
+
inlineVolume: true,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
return { resource, processedStream: null };
|
|
925
925
|
}
|
|
926
926
|
|
|
927
927
|
private mergeTrackPreserveRef(target: Track, source: Track): void {
|
|
@@ -1035,9 +1035,16 @@ export class Player extends EventEmitter {
|
|
|
1035
1035
|
private async startTrack(track: Track): Promise<boolean> {
|
|
1036
1036
|
if (this.destroyed) return false;
|
|
1037
1037
|
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1038
|
+
// Check preload BEFORE calling getStream so we never fetch a
|
|
1039
|
+
// stream we're about to throw away. The original code called getStream()
|
|
1040
|
+
// unconditionally at the top, then used the preload if available — leaking
|
|
1041
|
+
// the just-fetched stream and running middleware twice.
|
|
1042
|
+
if (this.preloadManager.hasValidPreload(track)) {
|
|
1043
|
+
return await this.startFromPreload(track);
|
|
1044
|
+
}
|
|
1040
1045
|
|
|
1046
|
+
// Only fetch a stream when there is no usable preload.
|
|
1047
|
+
let streamInfo: StreamInfo | null = null;
|
|
1041
1048
|
try {
|
|
1042
1049
|
streamInfo = await this.getStream(track);
|
|
1043
1050
|
} catch (error) {
|
|
@@ -1045,73 +1052,70 @@ export class Player extends EventEmitter {
|
|
|
1045
1052
|
throw error;
|
|
1046
1053
|
}
|
|
1047
1054
|
|
|
1048
|
-
//
|
|
1055
|
+
// Remote playback
|
|
1049
1056
|
if (streamInfo?.remote && streamInfo.handle) {
|
|
1050
1057
|
return await this.playRemote(track, streamInfo);
|
|
1051
1058
|
}
|
|
1052
1059
|
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1060
|
+
// Native playback — pass the already-fetched streamInfo to avoid a second fetch
|
|
1061
|
+
return await this.loadFreshStream(track, streamInfo);
|
|
1062
|
+
}
|
|
1063
|
+
private async startFromPreload(track: Track): Promise<boolean> {
|
|
1064
|
+
if (this.destroyed) return false;
|
|
1058
1065
|
|
|
1059
|
-
|
|
1066
|
+
this.debug(`[Player] Using preloaded stream for: ${track.title}`);
|
|
1060
1067
|
|
|
1061
|
-
|
|
1062
|
-
if (oldStreamId && this.streamManager) {
|
|
1063
|
-
setTimeout(() => {
|
|
1064
|
-
if (this.currentSlot.streamId === oldStreamId) {
|
|
1065
|
-
this.streamManager.unregisterStream(oldStreamId, true);
|
|
1066
|
-
}
|
|
1067
|
-
}, 3000);
|
|
1068
|
-
}
|
|
1068
|
+
const oldStreamId = this.currentSlot.streamId;
|
|
1069
1069
|
|
|
1070
|
-
|
|
1071
|
-
const currentResource = this.currentSlot.resource;
|
|
1072
|
-
if (!currentResource) {
|
|
1073
|
-
return false;
|
|
1074
|
-
}
|
|
1075
|
-
const targetVolume = this.getTrackTargetVolume(track);
|
|
1070
|
+
this.promotePreloadToCurrent(track);
|
|
1076
1071
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1072
|
+
if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
|
|
1073
|
+
this.streamManager.unregisterStream(oldStreamId, true);
|
|
1074
|
+
this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
|
|
1075
|
+
}
|
|
1080
1076
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
|
|
1084
|
-
await this.applyCrossfadeIn(currentResource, track);
|
|
1077
|
+
const currentResource = this.currentSlot.resource;
|
|
1078
|
+
if (!currentResource) return false;
|
|
1085
1079
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
});
|
|
1080
|
+
this.seekOffset = 0;
|
|
1081
|
+
const targetVolume = this.getTrackTargetVolume(track);
|
|
1089
1082
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1083
|
+
if (currentResource.volume) {
|
|
1084
|
+
currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
|
|
1085
|
+
}
|
|
1092
1086
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
this.
|
|
1097
|
-
this.
|
|
1098
|
-
|
|
1087
|
+
await this.maybeAlignToBeatBoundary();
|
|
1088
|
+
this.refreshLock = true;
|
|
1089
|
+
try {
|
|
1090
|
+
this.audioPlayer.stop(true);
|
|
1091
|
+
this.audioPlayer.play(currentResource);
|
|
1092
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
|
|
1093
|
+
} finally {
|
|
1094
|
+
this.refreshLock = false;
|
|
1099
1095
|
}
|
|
1096
|
+
|
|
1097
|
+
await this.applyCrossfadeIn(currentResource, track);
|
|
1098
|
+
|
|
1099
|
+
this.preloadNextTrack().catch((err: any) => {
|
|
1100
|
+
this.debug(`[Player] Preload error:`, err);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
return true;
|
|
1100
1104
|
}
|
|
1101
1105
|
|
|
1102
1106
|
/**
|
|
1103
1107
|
* Load fresh stream when no preload available
|
|
1104
1108
|
*/
|
|
1105
|
-
private async loadFreshStream(track: Track): Promise<boolean> {
|
|
1109
|
+
private async loadFreshStream(track: Track, preloadedStreamInfo?: StreamInfo | null): Promise<boolean> {
|
|
1106
1110
|
if (this.destroyed) return false;
|
|
1107
1111
|
|
|
1108
|
-
// Cancel preload to free resources
|
|
1109
1112
|
await this.safeCancelPreload();
|
|
1110
1113
|
|
|
1111
1114
|
try {
|
|
1112
|
-
|
|
1115
|
+
// use caller-supplied streamInfo when available so we don't
|
|
1116
|
+
// call getStream() a second time and run middleware twice.
|
|
1117
|
+
const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
|
|
1113
1118
|
|
|
1114
|
-
// Handle remote playback
|
|
1115
1119
|
if (streamInfo?.remote && streamInfo.handle) {
|
|
1116
1120
|
return await this.playRemote(track, streamInfo);
|
|
1117
1121
|
}
|
|
@@ -1120,45 +1124,64 @@ export class Player extends EventEmitter {
|
|
|
1120
1124
|
throw new Error(`No stream available`);
|
|
1121
1125
|
}
|
|
1122
1126
|
|
|
1123
|
-
// Register
|
|
1124
|
-
const
|
|
1127
|
+
// Register the RAW source stream — this is what we can reuse on seek
|
|
1128
|
+
const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
|
|
1125
1129
|
source: track.source || "stream",
|
|
1126
1130
|
isPreload: false,
|
|
1127
1131
|
isRemote: !!streamInfo?.remote,
|
|
1128
1132
|
priority: 10,
|
|
1129
1133
|
});
|
|
1130
1134
|
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1135
|
+
// createResource now returns both the AudioResource
|
|
1136
|
+
// AND the processedStream (ffmpeg stdout) when filters/seek are involved.
|
|
1137
|
+
const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
|
|
1138
|
+
|
|
1139
|
+
// when a processedStream exists, register it too so its
|
|
1140
|
+
// lifecycle is tracked. Store its id separately in currentSlot so
|
|
1141
|
+
// destroyCurrentStream() and refreshPlayerResource() clean the right object.
|
|
1142
|
+
let playStreamId = rawStreamId;
|
|
1143
|
+
if (processedStream && processedStream !== streamInfo.stream) {
|
|
1144
|
+
playStreamId = this.streamManager.registerStream(processedStream, track, {
|
|
1145
|
+
source: track.source || "stream-processed",
|
|
1146
|
+
isPreload: false,
|
|
1147
|
+
priority: 10,
|
|
1148
|
+
});
|
|
1149
|
+
this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
|
|
1150
|
+
}
|
|
1151
|
+
if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
|
|
1136
1152
|
this.streamManager.unregisterStream(this.currentSlot.streamId, true);
|
|
1137
1153
|
}
|
|
1154
|
+
if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
|
|
1155
|
+
this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
|
|
1156
|
+
}
|
|
1138
1157
|
|
|
1139
|
-
// Set current slot
|
|
1140
1158
|
this.currentSlot.resource = resource;
|
|
1141
1159
|
this.currentSlot.track = track;
|
|
1142
|
-
this.currentSlot.streamId =
|
|
1160
|
+
this.currentSlot.streamId = rawStreamId;
|
|
1161
|
+
(this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
|
|
1143
1162
|
this.currentSlot.isValid = true;
|
|
1144
1163
|
this.currentResource = resource;
|
|
1164
|
+
this.seekOffset = 0;
|
|
1145
1165
|
|
|
1146
|
-
// Apply volume
|
|
1147
1166
|
const targetVolume = this.getTrackTargetVolume(track);
|
|
1148
1167
|
if (resource.volume) {
|
|
1149
1168
|
resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
|
|
1150
1169
|
}
|
|
1151
1170
|
|
|
1152
|
-
// Play
|
|
1153
1171
|
await this.maybeAlignToBeatBoundary();
|
|
1154
|
-
this.
|
|
1155
|
-
|
|
1156
|
-
|
|
1172
|
+
this.refreshLock = true;
|
|
1173
|
+
try {
|
|
1174
|
+
this.audioPlayer.stop(true);
|
|
1175
|
+
this.audioPlayer.play(resource);
|
|
1176
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
|
|
1177
|
+
} finally {
|
|
1178
|
+
this.refreshLock = false;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1157
1181
|
await this.applyCrossfadeIn(resource, track);
|
|
1158
1182
|
|
|
1159
|
-
// Preload next (async)
|
|
1160
1183
|
if (!this.destroyed) {
|
|
1161
|
-
this.preloadNextTrack().catch((err) => {
|
|
1184
|
+
this.preloadNextTrack().catch((err: any) => {
|
|
1162
1185
|
this.debug(`[Player] Preload error:`, err);
|
|
1163
1186
|
});
|
|
1164
1187
|
}
|
|
@@ -1239,6 +1262,9 @@ export class Player extends EventEmitter {
|
|
|
1239
1262
|
if (this.antiStuckRetryDelayMs > 0) {
|
|
1240
1263
|
await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
|
|
1241
1264
|
}
|
|
1265
|
+
} else {
|
|
1266
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
1267
|
+
this.skipLoop = true;
|
|
1242
1268
|
}
|
|
1243
1269
|
} catch (err) {
|
|
1244
1270
|
this.debug(`[Player] playNext error:`, err);
|
|
@@ -1263,6 +1289,9 @@ export class Player extends EventEmitter {
|
|
|
1263
1289
|
if (this.antiStuckRetryDelayMs > 0) {
|
|
1264
1290
|
await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
|
|
1265
1291
|
}
|
|
1292
|
+
} else {
|
|
1293
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
1294
|
+
this.skipLoop = true;
|
|
1266
1295
|
}
|
|
1267
1296
|
continue;
|
|
1268
1297
|
}
|
|
@@ -1338,7 +1367,7 @@ export class Player extends EventEmitter {
|
|
|
1338
1367
|
throw new Error(`No stream available for track: ${track.title}`);
|
|
1339
1368
|
}
|
|
1340
1369
|
ttsStream = streamInfo.stream;
|
|
1341
|
-
const resource = await this.createResource(streamInfo as StreamInfo, track);
|
|
1370
|
+
const { resource, processedStream } = await this.createResource(streamInfo as StreamInfo, track);
|
|
1342
1371
|
if (!resource) {
|
|
1343
1372
|
throw new Error(`No resource available for track: ${track.title}`);
|
|
1344
1373
|
}
|
|
@@ -1410,15 +1439,21 @@ export class Player extends EventEmitter {
|
|
|
1410
1439
|
* @example
|
|
1411
1440
|
* await player.connect(voiceChannel);
|
|
1412
1441
|
*/
|
|
1413
|
-
async connect(
|
|
1442
|
+
async connect(
|
|
1443
|
+
channel: VoiceChannel,
|
|
1444
|
+
options: { group: string; selfDeaf: boolean; selfMute: boolean },
|
|
1445
|
+
): Promise<VoiceConnection> {
|
|
1414
1446
|
try {
|
|
1415
1447
|
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
|
|
1448
|
+
|
|
1416
1449
|
const connection = joinVoiceChannel({
|
|
1450
|
+
...options,
|
|
1417
1451
|
channelId: channel.id,
|
|
1418
1452
|
guildId: channel.guildId,
|
|
1419
1453
|
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
|
1420
|
-
selfDeaf: this.options
|
|
1421
|
-
selfMute: this.options
|
|
1454
|
+
selfDeaf: options?.selfDeaf ?? this.options?.selfDeaf ?? true,
|
|
1455
|
+
selfMute: options?.selfMute ?? this.options?.selfMute ?? false,
|
|
1456
|
+
group: options?.group ?? this.options?.group ?? "Ziplayer",
|
|
1422
1457
|
});
|
|
1423
1458
|
|
|
1424
1459
|
await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
|
|
@@ -1772,8 +1807,11 @@ export class Player extends EventEmitter {
|
|
|
1772
1807
|
return false;
|
|
1773
1808
|
}
|
|
1774
1809
|
|
|
1775
|
-
await this.refreshPlayerResource(true, position);
|
|
1776
|
-
|
|
1810
|
+
const ok = await this.refreshPlayerResource(true, position);
|
|
1811
|
+
if (!ok) {
|
|
1812
|
+
this.debug(`[Player] Seek failed at position: ${position}ms`);
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1777
1815
|
return true;
|
|
1778
1816
|
}
|
|
1779
1817
|
|
|
@@ -1956,10 +1994,10 @@ export class Player extends EventEmitter {
|
|
|
1956
1994
|
* console.log(`Loop mode: ${loopMode}`);
|
|
1957
1995
|
*/
|
|
1958
1996
|
loop(mode?: LoopMode | number): LoopMode {
|
|
1959
|
-
this.debug(`[Player] loop called with mode: ${mode}`);
|
|
1960
|
-
|
|
1961
1997
|
if (typeof mode === "number") {
|
|
1962
1998
|
// Number mode: convert to text mode
|
|
1999
|
+
this.debug(`[Player] loop called with mode: ${mode}`);
|
|
2000
|
+
|
|
1963
2001
|
switch (mode) {
|
|
1964
2002
|
case 0:
|
|
1965
2003
|
return this.queue.loop("off");
|
|
@@ -2186,9 +2224,9 @@ export class Player extends EventEmitter {
|
|
|
2186
2224
|
}
|
|
2187
2225
|
|
|
2188
2226
|
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
2189
|
-
if (!total) return this.formatTimeCompact(resource.playbackDuration);
|
|
2227
|
+
if (!total) return this.formatTimeCompact(resource.playbackDuration + this.seekOffset);
|
|
2190
2228
|
|
|
2191
|
-
const current = resource.playbackDuration;
|
|
2229
|
+
const current = resource.playbackDuration + this.seekOffset;
|
|
2192
2230
|
const ratio = Math.min(Math.max(current / total, 0), 1);
|
|
2193
2231
|
const progress = Math.round(ratio * size);
|
|
2194
2232
|
|
|
@@ -2298,7 +2336,7 @@ export class Player extends EventEmitter {
|
|
|
2298
2336
|
}
|
|
2299
2337
|
|
|
2300
2338
|
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
2301
|
-
const current = resource.playbackDuration;
|
|
2339
|
+
const current = resource.playbackDuration + this.seekOffset;
|
|
2302
2340
|
|
|
2303
2341
|
return {
|
|
2304
2342
|
current: current,
|
|
@@ -2411,63 +2449,131 @@ export class Player extends EventEmitter {
|
|
|
2411
2449
|
if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
|
|
2412
2450
|
return false;
|
|
2413
2451
|
}
|
|
2414
|
-
if (this.refreshLock)
|
|
2452
|
+
if (this.refreshLock) {
|
|
2453
|
+
this.debug(`[Player] refreshPlayerResource skipped — lock held`);
|
|
2454
|
+
return false;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2415
2457
|
this.refreshLock = true;
|
|
2458
|
+
|
|
2459
|
+
if (this.stuckTimer) {
|
|
2460
|
+
clearTimeout(this.stuckTimer);
|
|
2461
|
+
this.stuckTimer = null;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2416
2464
|
try {
|
|
2417
2465
|
const track = this.queue.currentTrack;
|
|
2418
2466
|
this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
|
|
2419
2467
|
|
|
2420
|
-
|
|
2421
|
-
|
|
2468
|
+
const currentPosition = position >= 0 ? position : (this.currentResource?.playbackDuration ?? 0);
|
|
2469
|
+
this.seekOffset = currentPosition;
|
|
2470
|
+
const wasPaused = this.isPaused;
|
|
2471
|
+
const playbackDuration = this.currentResource?.playbackDuration ?? 0;
|
|
2472
|
+
|
|
2473
|
+
const isForwardSeek = position < 0 || position >= playbackDuration;
|
|
2474
|
+
const currentStreamId = this.currentSlot.streamId;
|
|
2475
|
+
|
|
2476
|
+
// Try to grab the raw source stream for reuse (forward seeks only)
|
|
2477
|
+
let reuseStream: import("stream").Readable | null = null;
|
|
2478
|
+
if (isForwardSeek && currentStreamId) {
|
|
2479
|
+
reuseStream = this.streamManager.getRawStream(currentStreamId);
|
|
2480
|
+
if (reuseStream) {
|
|
2481
|
+
this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (reuseStream) {
|
|
2486
|
+
reuseStream.unpipe();
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// Clean up processedStream first (it's what AudioResource reads)
|
|
2490
|
+
const processedStreamId = (this.currentSlot as any).processedStreamId;
|
|
2491
|
+
if (processedStreamId && processedStreamId !== currentStreamId) {
|
|
2492
|
+
this.streamManager.unregisterStream(processedStreamId, true);
|
|
2493
|
+
(this.currentSlot as any).processedStreamId = null;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
if (currentStreamId) {
|
|
2497
|
+
this.streamManager.unregisterStream(currentStreamId, !reuseStream);
|
|
2498
|
+
this.currentSlot.streamId = null;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
this.audioPlayer.stop(true);
|
|
2502
|
+
this.currentResource = null;
|
|
2503
|
+
this.currentSlot.resource = null;
|
|
2504
|
+
this.currentSlot.isValid = false;
|
|
2505
|
+
|
|
2506
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
2507
|
+
|
|
2508
|
+
if (reuseStream) {
|
|
2509
|
+
if (reuseStream.destroyed || (reuseStream as any).readable === false) {
|
|
2510
|
+
this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
|
|
2511
|
+
reuseStream = null;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
let streaminfo: StreamInfo | null = null;
|
|
2516
|
+
|
|
2517
|
+
if (reuseStream) {
|
|
2518
|
+
streaminfo = { stream: reuseStream, type: "arbitrary" };
|
|
2519
|
+
} else {
|
|
2520
|
+
this.pluginManager.clearStreamCache();
|
|
2521
|
+
this.extensionManager.clearCache("stream");
|
|
2522
|
+
this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
|
|
2523
|
+
streaminfo = await this.getStream(track);
|
|
2524
|
+
}
|
|
2422
2525
|
|
|
2423
|
-
const streaminfo = await this.getStream(track);
|
|
2424
2526
|
if (!streaminfo?.stream) {
|
|
2425
|
-
this.debug(`[Player] No stream
|
|
2527
|
+
this.debug(`[Player] No stream available for refresh`);
|
|
2426
2528
|
return false;
|
|
2427
2529
|
}
|
|
2428
2530
|
|
|
2429
|
-
|
|
2430
|
-
const resource = await this.createResource(streaminfo, track, currentPosition);
|
|
2531
|
+
const createPosition = reuseStream ? -1 : currentPosition;
|
|
2431
2532
|
|
|
2432
|
-
|
|
2433
|
-
const wasPlaying = this.isPlaying;
|
|
2434
|
-
const wasPaused = this.isPaused;
|
|
2533
|
+
const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
|
|
2435
2534
|
|
|
2436
|
-
|
|
2535
|
+
// Register raw source stream
|
|
2536
|
+
const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
|
|
2537
|
+
source: track.source || "stream",
|
|
2538
|
+
isPreload: false,
|
|
2539
|
+
priority: 10,
|
|
2540
|
+
});
|
|
2437
2541
|
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
}
|
|
2446
|
-
} catch (error) {
|
|
2447
|
-
this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
|
|
2448
|
-
} finally {
|
|
2449
|
-
this.refreshLock = false;
|
|
2542
|
+
let newProcessedStreamId: string | null = null;
|
|
2543
|
+
if (processedStream && processedStream !== streaminfo.stream) {
|
|
2544
|
+
newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
|
|
2545
|
+
source: track.source || "stream-processed",
|
|
2546
|
+
isPreload: false,
|
|
2547
|
+
priority: 10,
|
|
2548
|
+
});
|
|
2450
2549
|
}
|
|
2451
2550
|
|
|
2551
|
+
this.currentSlot.resource = resource;
|
|
2552
|
+
this.currentSlot.track = track;
|
|
2553
|
+
this.currentSlot.streamId = newStreamId;
|
|
2554
|
+
(this.currentSlot as any).processedStreamId = newProcessedStreamId;
|
|
2555
|
+
this.currentSlot.isValid = true;
|
|
2452
2556
|
this.currentResource = resource;
|
|
2453
2557
|
|
|
2454
|
-
|
|
2558
|
+
if (position >= 0) {
|
|
2559
|
+
this.seekInProgress = true;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2455
2562
|
if (this.connection) {
|
|
2456
2563
|
this.connection.subscribe(this.audioPlayer);
|
|
2457
2564
|
this.audioPlayer.play(resource);
|
|
2458
2565
|
}
|
|
2566
|
+
if (wasPaused) this.audioPlayer.pause();
|
|
2459
2567
|
|
|
2460
|
-
|
|
2461
|
-
if (wasPaused) {
|
|
2462
|
-
this.audioPlayer.pause();
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
|
|
2568
|
+
this.debug(`[Player] Resource refreshed at position ${currentPosition}ms`);
|
|
2466
2569
|
return true;
|
|
2467
2570
|
} catch (error) {
|
|
2468
|
-
this.debug(`[Player]
|
|
2469
|
-
|
|
2470
|
-
|
|
2571
|
+
this.debug(`[Player] refreshPlayerResource error:`, error);
|
|
2572
|
+
this.seekInProgress = false;
|
|
2573
|
+
this.emit("playerError", error as Error, this.queue.currentTrack ?? undefined);
|
|
2574
|
+
return false;
|
|
2575
|
+
} finally {
|
|
2576
|
+
this.refreshLock = false;
|
|
2471
2577
|
}
|
|
2472
2578
|
}
|
|
2473
2579
|
|
|
@@ -2523,7 +2629,13 @@ export class Player extends EventEmitter {
|
|
|
2523
2629
|
this.audioPlayer.on("stateChange", (oldState, newState) => {
|
|
2524
2630
|
if (this.destroyed) return;
|
|
2525
2631
|
this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
|
|
2632
|
+
|
|
2633
|
+
// ── Idle: track ended naturally ───────────────────────────────────────
|
|
2526
2634
|
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
|
|
2635
|
+
if (this.refreshLock) {
|
|
2636
|
+
this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2527
2639
|
// Track ended
|
|
2528
2640
|
const track = this.queue.currentTrack;
|
|
2529
2641
|
if (track) {
|
|
@@ -2534,12 +2646,19 @@ export class Player extends EventEmitter {
|
|
|
2534
2646
|
}
|
|
2535
2647
|
}
|
|
2536
2648
|
void this.playNext();
|
|
2649
|
+
// ── Playing: started or resumed ───────────────────────────────────────
|
|
2537
2650
|
} else if (
|
|
2538
2651
|
newState.status === AudioPlayerStatus.Playing &&
|
|
2539
2652
|
(oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
|
|
2540
2653
|
) {
|
|
2541
2654
|
// Track started
|
|
2542
2655
|
this.clearLeaveTimeout();
|
|
2656
|
+
|
|
2657
|
+
if (this.seekInProgress) {
|
|
2658
|
+
this.debug(`[Player] Seek complete — audio output started`);
|
|
2659
|
+
this.seekInProgress = false;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2543
2662
|
const track = this.queue.currentTrack;
|
|
2544
2663
|
if (track) {
|
|
2545
2664
|
this.debug(`[Player] Track started: ${track.title}`);
|
|
@@ -2556,6 +2675,7 @@ export class Player extends EventEmitter {
|
|
|
2556
2675
|
}
|
|
2557
2676
|
}
|
|
2558
2677
|
}
|
|
2678
|
+
// ── Paused ────────────────────────────────────────────────────────────
|
|
2559
2679
|
} else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
|
|
2560
2680
|
const track = this.queue.currentTrack;
|
|
2561
2681
|
if (track) {
|
|
@@ -2565,6 +2685,7 @@ export class Player extends EventEmitter {
|
|
|
2565
2685
|
fp.emit("playerPause", track);
|
|
2566
2686
|
}
|
|
2567
2687
|
}
|
|
2688
|
+
// ── Resumed from pause ────────────────────────────────────────────────
|
|
2568
2689
|
} else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
|
|
2569
2690
|
const track = this.queue.currentTrack;
|
|
2570
2691
|
if (track) {
|
|
@@ -2576,8 +2697,15 @@ export class Player extends EventEmitter {
|
|
|
2576
2697
|
}
|
|
2577
2698
|
} else if (newState.status === AudioPlayerStatus.AutoPaused) {
|
|
2578
2699
|
this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
|
|
2700
|
+
// ── Buffering: start stuck detector ───────────────────────────────────
|
|
2579
2701
|
} else if (newState.status === AudioPlayerStatus.Buffering) {
|
|
2580
2702
|
this.debug(`[Player] AudioPlayerStatus.Buffering`);
|
|
2703
|
+
|
|
2704
|
+
if (this.seekInProgress) {
|
|
2705
|
+
this.debug(`[Player] Buffering during seek — stuckTimer suppressed`);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2581
2709
|
this.lastDuration = this.currentResource?.playbackDuration || 0;
|
|
2582
2710
|
this.stuckTimer = setTimeout(() => {
|
|
2583
2711
|
if (this.currentResource?.playbackDuration === this.lastDuration) {
|