diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index c54ff20c..912792b8 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -74,6 +74,7 @@ MACOS - On 120 Hz ProMotion screens Flow now draws at 120 frames per second. WebKit was holding every animation, scroll and lyric line to 60. - The music equalizer and crossfade are now heard on macOS. WebKit cannot pass Opus songs through audio processing, so once either is on, songs stream as AAC and the song playing reloads once from where it is. - The volume slider keeps working on a downloaded Opus song while the equalizer is on. +- AAC songs now show and play for their real length on macOS. WebKit reported them at twice their length, so a song ran on in silence before the next one started. LINUX - The AppImage is built on Ubuntu 24.04 with a newer WebKitGTK that no longer crashes on color emoji fonts, the cause of the freeze on Fedora 44 and Bazzite. It needs glibc 2.39 or newer, so on Ubuntu 22.04, Mint 21 or Debian 12 use the .deb instead. diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index cb4379da..f03d5816 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -44,6 +44,13 @@ export function GlobalMusicAudio() { useLyricsLookup(); const store = () => useMusicPlayerStore.getState(); + const finishTrack = () => { + musicListens.finish(Date.now()); + if (sleepTimerTakesEnd()) return; + store().handleEnded(); + }; + // The engine corrects a length WebKit reports wrong, so the player reads it there. + const syncTime = (el: HTMLAudioElement) => store()._syncTime(el.currentTime, musicAudioEngine.getDuration()); const audioProps = { hidden: true, preload: "auto", @@ -57,14 +64,20 @@ export function GlobalMusicAudio() { store()._setBuffering(true); }), onPlaying: fromActive(() => store()._setBuffering(false)), - onDurationChange: fromActive((el) => store()._syncTime(el.currentTime, el.duration)), - onSeeked: fromActive((el) => store()._syncTime(el.currentTime, el.duration)), + onDurationChange: fromActive(syncTime), + onSeeked: fromActive(syncTime), // Near the end, buffer the next track on the standby element so the change // is a swap. Driven by the element rather than the frame loop, which WebKit // pauses while the window is hidden, as it usually is while music plays. // The listed length stands in when WebKit reports a stream as endless. onTimeUpdate: fromActive((el) => { - const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : store().duration; + // A song whose element reports twice its length ends at the listed one, not after the + // silence the element would play to reach its own. + if (musicAudioEngine.takeListedEnd()) { + finishTrack(); + return; + } + const duration = musicAudioEngine.getDuration() || store().duration; if (duration > 0 && duration - el.currentTime <= PRELOAD_LEAD_SECONDS) store()._preloadUpcoming(); /* @@ -75,7 +88,7 @@ export function GlobalMusicAudio() { card. Anything that needs the exact position (the full player's seek bar, the lyrics) reads the audio engine directly. */ - store()._syncTime(el.currentTime, el.duration); + syncTime(el); reportMediaPosition("music"); tickSleepTimer(); @@ -90,11 +103,7 @@ export function GlobalMusicAudio() { musicListens.hold(); } }), - onEnded: fromActive(() => { - musicListens.finish(Date.now()); - if (sleepTimerTakesEnd()) return; - store().handleEnded(); - }), + onEnded: fromActive(finishTrack), onError: (event: SyntheticEvent) => { if (event.currentTarget === musicAudioEngine.getActiveElement()) store()._onPlaybackError(); else store()._onPreloadError(); diff --git a/src/lib/audio/listedLength.test.ts b/src/lib/audio/listedLength.test.ts new file mode 100644 index 00000000..a2cc773c --- /dev/null +++ b/src/lib/audio/listedLength.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { listedSeconds, pastListedEnd, playableLength } from "./listedLength"; + +describe("playableLength", () => { + it("takes the listed length when WebKit doubles an AAC stream's", () => { + expect(playableLength(490.5, 245.269)).toBe(245.269); + }); + + it("keeps the element's own length when it agrees with the listing", () => { + expect(playableLength(245.3, 245.281)).toBe(245.3); + expect(playableLength(250, 245)).toBe(250); + }); + + it("falls back to the listing while the element has no length", () => { + expect(playableLength(Number.NaN, 200)).toBe(200); + expect(playableLength(Number.POSITIVE_INFINITY, 200)).toBe(200); + expect(playableLength(Number.NaN, null)).toBe(0); + }); + + it("trusts the element when nothing is listed", () => { + expect(playableLength(490.5, null)).toBe(490.5); + expect(playableLength(490.5, 0)).toBe(490.5); + }); +}); + +describe("pastListedEnd", () => { + it("ends a doubled song at its listed length", () => { + expect(pastListedEnd(245.1, 490.5, 245.269)).toBe(false); + expect(pastListedEnd(245.3, 490.5, 245.269)).toBe(true); + }); + + it("leaves a correctly reported song to the element's own end", () => { + expect(pastListedEnd(245.3, 245.3, 245.281)).toBe(false); + expect(pastListedEnd(300, 245.3, null)).toBe(false); + }); +}); + +describe("listedSeconds", () => { + it("prefers the stream's own length over the song's rounded one", () => { + expect(listedSeconds({ approxDurationMs: 245269 }, { duration: 245 })).toBe(245.269); + expect(listedSeconds({ approxDurationMs: null }, { duration: 245 })).toBe(245); + expect(listedSeconds({ approxDurationMs: null }, { duration: null })).toBeNull(); + }); +}); diff --git a/src/lib/audio/listedLength.ts b/src/lib/audio/listedLength.ts new file mode 100644 index 00000000..d844d333 --- /dev/null +++ b/src/lib/audio/listedLength.ts @@ -0,0 +1,31 @@ +import type { MusicStreamInfo, SongItem } from "../../types/music"; + +/** How far past the listed length a reported one has to be before it is taken as wrong. */ +const OVERREPORT_RATIO = 1.25; + +/** + * The length a song really plays for. WebKit on macOS reports YouTube's fragmented MP4 (AAC) + * audio at twice its length, though the file's own header has it right, so the element plays the + * song and then runs on in silence. A length well past the one YouTube lists is that bug, and the + * listed one wins; otherwise the element knows best, and it is 0 while neither is known. + */ +export function playableLength(reported: number, listed: number | null): number { + const known = listed !== null && listed > 0 ? listed : 0; + if (!Number.isFinite(reported) || reported <= 0) return known; + return known > 0 && reported > known * OVERREPORT_RATIO ? known : reported; +} + +/** Whether `position` is past the end of a song whose element reports more than it holds. */ +export function pastListedEnd(position: number, reported: number, listed: number | null): boolean { + const length = playableLength(reported, listed); + return length > 0 && length < reported && position >= length; +} + +/** A stream's listed length in seconds: YouTube's for that file, else the song's own. */ +export function listedSeconds( + info: Pick, + track: Pick, +): number | null { + if (info.approxDurationMs) return info.approxDurationMs / 1000; + return track.duration ?? null; +} diff --git a/src/lib/audio/musicAudioEngine.test.ts b/src/lib/audio/musicAudioEngine.test.ts index fcd879a4..8e07ea2f 100644 --- a/src/lib/audio/musicAudioEngine.test.ts +++ b/src/lib/audio/musicAudioEngine.test.ts @@ -116,3 +116,43 @@ describe("sleep timer fade-out", () => { expect(pair[0].volume).toBeCloseTo(1); }); }); + +describe("a song WebKit reports at twice its length", () => { + const reports = (el: HTMLAudioElement, duration: number, currentTime: number) => { + Object.defineProperty(el, "duration", { value: duration, configurable: true }); + Object.defineProperty(el, "currentTime", { value: currentTime, configurable: true, writable: true }); + }; + + it("plays for its listed length and ends there once", async () => { + await musicAudioEngine.load("http://127.0.0.1/stream/aac", 'audio/mp4; codecs="mp4a.40.2"', 245.269); + reports(pair[0], 490.5, 120); + expect(musicAudioEngine.getDuration()).toBe(245.269); + expect(musicAudioEngine.takeListedEnd()).toBe(false); + + reports(pair[0], 490.5, 245.4); + expect(musicAudioEngine.takeListedEnd()).toBe(true); + expect(pair[0].pause).toHaveBeenCalled(); + expect(musicAudioEngine.takeListedEnd()).toBe(false); + + // Repeat one seeks back, and the song can end again. + musicAudioEngine.seek(0); + reports(pair[0], 490.5, 245.4); + expect(musicAudioEngine.takeListedEnd()).toBe(true); + }); + + it("carries the next song's listed length through the gapless swap", async () => { + await musicAudioEngine.load("http://127.0.0.1/stream/current", null, 200); + musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null, null, 180); + bufferedEnough(pair[1]); + musicAudioEngine.activatePreloaded("next"); + reports(pair[1], 360, 0); + expect(musicAudioEngine.getDuration()).toBe(180); + }); + + it("leaves a correctly reported song to its own end", async () => { + await musicAudioEngine.load("http://127.0.0.1/stream/opus", 'audio/webm; codecs="opus"', 245.281); + reports(pair[0], 245.3, 245.29); + expect(musicAudioEngine.getDuration()).toBe(245.3); + expect(musicAudioEngine.takeListedEnd()).toBe(false); + }); +}); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 60d8f523..04b639f0 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -4,6 +4,7 @@ import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from import { fadeInGain, fadeOutGain, resetFade, scheduleFade, steppedFade } from "./crossfadeRamp"; import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; +import { pastListedEnd, playableLength } from "./listedLength"; import { volumeGain } from "./volumeCurve"; import { webAudioHears } from "./webAudioFormats"; @@ -23,6 +24,10 @@ interface Slot { loudnessDb: number | null; /** The loaded file's MIME type, which decides whether WebAudio can hear it. */ mime: string | null; + /** The song's listed length in seconds, for when the element reports a wrong one. */ + listed: number | null; + /** Set once the end at the listed length has been handed to the player. */ + listedEndTaken: boolean; /** The track a standby slot holds, so a swap only ever plays the right one. */ key: string | null; } @@ -35,6 +40,8 @@ const emptySlot = (): Slot => ({ fade: 1, loudnessDb: null, mime: null, + listed: null, + listedEndTaken: false, key: null, }); @@ -161,11 +168,13 @@ class MusicAudioEngine { // --- transport ---------------------------------------------------------- - async load(url: string, mime: string | null): Promise { + async load(url: string, mime: string | null, listedSeconds: number | null = null): Promise { const el = this.active.el; if (!el) return; this.active.key = null; this.active.mime = mime; + this.active.listed = listedSeconds; + this.active.listedEndTaken = false; this.applyGain(); el.src = url; try { @@ -179,12 +188,20 @@ class MusicAudioEngine { * take over the moment the current track ends. A no-op when it already holds * that track, or while it is still fading out the last one. */ - preload(key: string, url: string, loudnessDb: number | null, mime: string | null): void { + preload( + key: string, + url: string, + loudnessDb: number | null, + mime: string | null, + listedSeconds: number | null = null, + ): void { const slot = this.standby; if (!slot.el || slot.key === key || this.fading) return; slot.key = key; slot.loudnessDb = loudnessDb; slot.mime = mime; + slot.listed = listedSeconds; + slot.listedEndTaken = false; slot.el.src = url; try { slot.el.load(); @@ -245,6 +262,8 @@ class MusicAudioEngine { slot.key = null; slot.loudnessDb = null; slot.mime = null; + slot.listed = null; + slot.listedEndTaken = false; } // The two elements play together while their gains move in opposite directions; the one @@ -346,6 +365,7 @@ class MusicAudioEngine { const el = this.active.el; if (el && Number.isFinite(seconds)) { el.currentTime = Math.max(0, seconds); + this.active.listedEndTaken = false; } } @@ -366,8 +386,21 @@ class MusicAudioEngine { } getDuration(): number { - const d = this.active.el?.duration ?? 0; - return Number.isFinite(d) ? d : 0; + return playableLength(this.active.el?.duration ?? Number.NaN, this.active.listed); + } + + /** + * True once, when the playing song passes its listed length on an element that reports more + * than it holds. The element would play silence until its own end and fire `ended` only then, + * so it is paused here and the caller ends the song instead. + */ + takeListedEnd(): boolean { + const slot = this.active; + const el = slot.el; + if (!el || slot.listedEndTaken || !pastListedEnd(el.currentTime, el.duration, slot.listed)) return false; + slot.listedEndTaken = true; + el.pause(); + return true; } // --- speed -------------------------------------------------------------- diff --git a/src/lib/musicPreload.ts b/src/lib/musicPreload.ts index 22a1dbee..758d7915 100644 --- a/src/lib/musicPreload.ts +++ b/src/lib/musicPreload.ts @@ -1,5 +1,6 @@ import type { SongItem } from "../types/music"; import type { MusicAudioQuality } from "./api/music"; +import { listedSeconds } from "./audio/listedLength"; import { musicAudioEngine } from "./audio/musicAudioEngine"; import { resolveMusicStream } from "./musicStreamResolution"; import { findDownloadedRecord } from "./useDownloads"; @@ -35,7 +36,9 @@ export async function preloadTrack( recordPlayerEvent(`music preload start: ${videoId}`); try { const info = await resolveMusicStream(videoId, quality, { preferAac: musicAudioEngine.wantsAac() }); - if (stillUpcoming(videoId)) musicAudioEngine.preload(videoId, info.audioUrl, info.loudnessDb, info.mimeType); + if (stillUpcoming(videoId)) { + musicAudioEngine.preload(videoId, info.audioUrl, info.loudnessDb, info.mimeType, listedSeconds(info, track)); + } } catch { // Speculative: if it fails, the track resolves again when it is played. } finally { diff --git a/src/store/useMusicPlayerStore.ts b/src/store/useMusicPlayerStore.ts index c12d88d2..9e8bcdc2 100644 --- a/src/store/useMusicPlayerStore.ts +++ b/src/store/useMusicPlayerStore.ts @@ -15,6 +15,7 @@ import { useDownloadsLibraryStore } from "./useDownloadsLibraryStore"; import { normalizeBackendError } from "../lib/api/errors"; import { recordPlayerEvent } from "../lib/playerDiagnostics"; import { logToBackend } from "../lib/diagnostics"; +import { listedSeconds } from "../lib/audio/listedLength"; import { musicAudioEngine } from "../lib/audio/musicAudioEngine"; import { migrateSavedMusicVolume } from "../lib/audio/volumeCurve"; import { preloadTrack } from "../lib/musicPreload"; @@ -505,7 +506,7 @@ export const useMusicPlayerStore = create((set, get) => ({ : {}), }); musicAudioEngine.setLoudness(offline.loudnessDb, get().normalizationEnabled); - await musicAudioEngine.load(offline.url, offline.contentType); + await musicAudioEngine.load(offline.url, offline.contentType, track.duration ?? null); musicAudioEngine.seekWhenReady(resumeAt); await start(); afterStart(); @@ -532,7 +533,7 @@ export const useMusicPlayerStore = create((set, get) => ({ set({ loudnessDb: info.loudnessDb, loadingStreamId: null }); musicAudioEngine.setLoudness(info.loudnessDb, get().normalizationEnabled); - await musicAudioEngine.load(info.audioUrl, info.mimeType); + await musicAudioEngine.load(info.audioUrl, info.mimeType, listedSeconds(info, track)); musicAudioEngine.seekWhenReady(resumeAt); await start(); afterStart();