|
134 | 134 | let audio: HTMLAudioElement; |
135 | 135 | let progressBar: HTMLElement; |
136 | 136 | let volumeBar: HTMLElement; |
| 137 | + let loadRequestId = 0; |
| 138 | + const resolvedUrlCache = new Map<string, string>(); |
| 139 | +
|
| 140 | + function isHttpUrl(url: string): boolean { |
| 141 | + return url.startsWith("http://") || url.startsWith("https://"); |
| 142 | + } |
| 143 | +
|
| 144 | + async function resolvePlayableUrl(url: string): Promise<string> { |
| 145 | + const normalizedUrl = getAssetPath(url); |
| 146 | +
|
| 147 | + // 本地资源不需要做重定向解析 |
| 148 | + if (!isHttpUrl(normalizedUrl)) { |
| 149 | + return normalizedUrl; |
| 150 | + } |
| 151 | +
|
| 152 | + const cachedUrl = resolvedUrlCache.get(normalizedUrl); |
| 153 | + if (cachedUrl) { |
| 154 | + return cachedUrl; |
| 155 | + } |
| 156 | +
|
| 157 | + // 先走自动重定向,拿到最终 response.url |
| 158 | + try { |
| 159 | + const followed = await fetch(normalizedUrl, { |
| 160 | + method: "GET", |
| 161 | + redirect: "follow", |
| 162 | + cache: "no-store", |
| 163 | + mode: "cors", |
| 164 | + }); |
| 165 | + if (followed.ok && followed.url) { |
| 166 | + resolvedUrlCache.set(normalizedUrl, followed.url); |
| 167 | + return followed.url; |
| 168 | + } |
| 169 | + } catch {} |
| 170 | +
|
| 171 | + // 再尝试手动重定向读取 location 头(部分 API 允许) |
| 172 | + try { |
| 173 | + const manual = await fetch(normalizedUrl, { |
| 174 | + method: "GET", |
| 175 | + redirect: "manual", |
| 176 | + cache: "no-store", |
| 177 | + mode: "cors", |
| 178 | + }); |
| 179 | + const location = manual.headers.get("location"); |
| 180 | + if (location) { |
| 181 | + const finalUrl = new URL(location, normalizedUrl).href; |
| 182 | + resolvedUrlCache.set(normalizedUrl, finalUrl); |
| 183 | + return finalUrl; |
| 184 | + } |
| 185 | + } catch {} |
| 186 | +
|
| 187 | + // 解析失败时回退原始地址,让 audio 自己处理重定向 |
| 188 | + return normalizedUrl; |
| 189 | + } |
137 | 190 | |
138 | 191 | async function fetchMetingPlaylist() { |
139 | 192 | if (!meting_api || !meting_id) return; |
|
251 | 304 | playSong(newIndex); |
252 | 305 | } |
253 | 306 | |
254 | | - function playSong(index: number) { |
| 307 | + async function playSong(index: number) { |
255 | 308 | if (index < 0 || index >= playlist.length) return; |
256 | 309 | const wasPlaying = isPlaying; |
257 | 310 | currentIndex = index; |
258 | 311 | if (audio) audio.pause(); |
259 | | - loadSong(playlist[currentIndex]); |
| 312 | + await loadSong(playlist[currentIndex]); |
260 | 313 | if (wasPlaying || !isPlaying) { |
261 | 314 | setTimeout(() => { |
262 | 315 | if (!audio) return; |
|
288 | 341 | } |
289 | 342 | |
290 | 343 | |
291 | | - function loadSong(song: typeof currentSong) { |
| 344 | + async function loadSong(song: typeof currentSong) { |
292 | 345 | if (!song || !audio) return; |
293 | 346 | currentSong = { ...song }; |
| 347 | + const currentRequestId = ++loadRequestId; |
294 | 348 | |
295 | 349 | // 如果没有封面,使用默认封面 |
296 | 350 | if (!currentSong.cover) { |
|
309 | 363 | audio.addEventListener("loadeddata", handleLoadSuccess, { once: true }); |
310 | 364 | audio.addEventListener("error", handleLoadError, { once: true }); |
311 | 365 | audio.addEventListener("loadstart", handleLoadStart, { once: true }); |
312 | | - audio.src = getAssetPath(song.url); |
| 366 | +
|
| 367 | + const playableUrl = await resolvePlayableUrl(song.url); |
| 368 | +
|
| 369 | + // 避免切歌时旧请求覆盖当前歌曲 |
| 370 | + if (currentRequestId !== loadRequestId) return; |
| 371 | +
|
| 372 | + audio.src = playableUrl; |
313 | 373 | audio.load(); |
314 | 374 | } else { |
315 | 375 | isLoading = false; |
|
0 commit comments