diff --git a/frontend/src/lib/audio/media-session.ts b/frontend/src/lib/audio/media-session.ts index ee56bff..cbfd0c8 100644 --- a/frontend/src/lib/audio/media-session.ts +++ b/frontend/src/lib/audio/media-session.ts @@ -98,6 +98,8 @@ export function updateMediaSessionPlaybackState( /** * Update Media Session position state (for seek bar on lock screen) + * + * Debug: Use ?debug=media in URL (persists in sessionStorage) to see detailed position updates */ export function updateMediaSessionPositionState( position: number, @@ -110,10 +112,25 @@ export function updateMediaSessionPositionState( // Validate inputs to prevent errors (NaN, Infinity, negative values) if (duration <= 0 || !isFinite(duration) || !isFinite(position)) { + logger.debug("[MediaSession][setPositionState]", "Skipped: invalid inputs", { + raw: { position, duration, playbackRate } + }); return; } const clampedPosition = Math.max(0, Math.min(position, duration)); + + // Log position updates for debugging lock screen seek bar issues + // Use debug level so it only shows with ?debug=media + logger.debug("[MediaSession][setPositionState]", "Updating position", { + raw: { + position, + clampedPosition, + duration, + playbackRate, + pct: (clampedPosition / duration) * 100 + } + }); try { navigator.mediaSession.setPositionState({ @@ -123,7 +140,9 @@ export function updateMediaSessionPositionState( }); } catch (error) { // Some browsers don't support setPositionState - logger.debug("[MediaSession][updateMediaSessionPositionState]", "Failed to update Media Session position state", { raw: { error } }); + logger.warn("[MediaSession][setPositionState]", "Failed to set position state", { + raw: { error, position: clampedPosition, duration } + }); } } diff --git a/frontend/src/lib/audio/player.ts b/frontend/src/lib/audio/player.ts index 2855ade..64b367a 100644 --- a/frontend/src/lib/audio/player.ts +++ b/frontend/src/lib/audio/player.ts @@ -230,32 +230,72 @@ class AudioPlayer { const howl = this.howl; const id = this.soundId ?? undefined; let currentTime = 0; + let duration = 0; + let isPlaying = false; + let isLoading = false; if (howl) { - // howl.seek(id) returns current position for the given sound - // When 1 arg is passed, Howler checks if it's a soundId or position - const seekResult = howl.seek(id); - currentTime = typeof seekResult === "number" ? seekResult : 0; + try { + // Wrap all Howler calls in try-catch to handle race conditions + // where howl._sounds array may be empty during unload/switch + const howlState = howl.state(); + + const seekResult = howl.seek(id); + currentTime = typeof seekResult === "number" && isFinite(seekResult) ? seekResult : 0; + + if (howlState !== "loaded" && this.pendingSeek !== null) { + currentTime = this.pendingSeek; + } - if (howl.state() !== "loaded" && this.pendingSeek !== null) { - currentTime = this.pendingSeek; + // Get duration once and reuse to avoid race condition + const howlDuration = howl.duration(id); + duration = typeof howlDuration === "number" && isFinite(howlDuration) && howlDuration > 0 + ? howlDuration + : (this.currentTrack?.duration ?? 0); + + isPlaying = howl.playing(id) ?? false; + isLoading = howlState === "loading"; + } catch (error) { + // Howler internal error (e.g., _sounds[id] undefined during race condition) + // Fall back to safe defaults - log as warn so it shows in prod console + // Wrap howl.state() in try-catch to prevent logging from failing too + let howlState: string | undefined; + try { + howlState = howl?.state(); + } catch { + howlState = "unknown"; + } + logger.warn("[AudioPlayer][getState]", "Howler internal error, using fallbacks", { + raw: { + error: error instanceof Error ? error.message : String(error), + soundId: id, + howlState, + trackId: this.currentTrack?.id + } + }); + duration = this.currentTrack?.duration ?? 0; + // If we had a pending seek when the error occurred, use it as currentTime + if (this.pendingSeek !== null) { + currentTime = this.pendingSeek; + } + // Set loading state from howl if available (howlState already safely retrieved) + isLoading = howlState === "loading"; + // Conservatively mark as not playing in error state + isPlaying = false; } } else if (this.pendingSeek !== null) { currentTime = this.pendingSeek; + duration = this.currentTrack?.duration ?? 0; } return { currentTrack: this.currentTrack, - isPlaying: howl?.playing(id) ?? false, + isPlaying, currentTime, - // Use howl.duration() only if it's a positive number (audio loaded) - // Otherwise fallback to track metadata duration - duration: (howl?.duration(id) || 0) > 0 - ? howl!.duration(id) - : (this.currentTrack?.duration ?? 0), + duration, volume: Howler.volume(), isMuted: this.isMuted, - isLoading: howl?.state() === "loading", + isLoading, }; } diff --git a/frontend/src/lib/logger-client.test.ts b/frontend/src/lib/logger-client.test.ts index dd22346..72554e9 100644 --- a/frontend/src/lib/logger-client.test.ts +++ b/frontend/src/lib/logger-client.test.ts @@ -187,4 +187,44 @@ describe("logger-client", () => { trace.end(); }); }); + + describe("debug mode", () => { + // Note: URL-based debug mode is tested through integration behavior + // since window.location cannot be easily mocked in browser environment. + // The caching mechanism improves performance by avoiding repeated + // sessionStorage access. + + it("should handle sessionStorage persistence", () => { + const getItemSpy = vi.spyOn(Storage.prototype, "getItem"); + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + + // Logger reads from sessionStorage when checking debug mode + logger.debug("[Test][debug]", "Test message"); + + // In dev mode, debug always shows but sessionStorage should be checked + expect(consoleDebugSpy).toHaveBeenCalled(); + + // Cleanup + getItemSpy.mockRestore(); + setItemSpy.mockRestore(); + }); + + it("should log debug messages in dev mode regardless of debug filter", () => { + // In dev mode (import.meta.env.DEV = true), debug logs are always shown + logger.debug("[MediaSession][test]", "Media message"); + expect(consoleDebugSpy).toHaveBeenCalledWith( + "[Debug] [MediaSession][test] Media message", + "" + ); + }); + + it("should log info messages in dev mode regardless of debug filter", () => { + // In dev mode, info logs are always shown + logger.info("[AudioPlayer][test]", "Audio message"); + expect(consoleInfoSpy).toHaveBeenCalledWith( + "[Info] [AudioPlayer][test] Audio message", + "" + ); + }); + }); }); diff --git a/frontend/src/lib/logger-client.ts b/frontend/src/lib/logger-client.ts index 456a2c3..75ceabc 100644 --- a/frontend/src/lib/logger-client.ts +++ b/frontend/src/lib/logger-client.ts @@ -29,6 +29,81 @@ const SERVICE = "m3w-frontend"; const FLUSH_INTERVAL_MS = 5000; const MAX_BUFFER_SIZE = 10; +/** + * Get URL-based debug mode for production debugging. + * + * This allows debugging in production without redeploying. + * - ?debug=1 or ?debug=all: Enable all verbose logs + * - ?debug=audio: Enable only audio-related logs + * - ?debug=media: Enable only media session logs + * + * The setting persists in sessionStorage for the current tab. + * Result is cached once per page load to avoid repeated sessionStorage reads + * and ensure consistent behavior throughout the session. + * Cache is never invalidated during runtime - change requires page reload with new URL param. + * This is intentional: debug mode is a diagnostic tool, not meant for dynamic switching. + * Note: In SPA context, client-side navigation won't trigger cache reset. + */ +let cachedDebugMode: { enabled: boolean; filter: string } | null = null; + +/** + * Check if a log should be shown in console based on dev mode or debug mode. + * Extracted as shared helper to avoid code duplication. + */ +function shouldShowInConsole(source: string): boolean { + if (isDev) return true; + const debugMode = getDebugMode(); + return debugMode.enabled && matchesDebugFilter(source, debugMode.filter); +} + +function getDebugMode(): { enabled: boolean; filter: string } { + // In non-browser environments (e.g. SSR), never use or set the cache + if (typeof window === "undefined") { + return { enabled: false, filter: "" }; + } + + // Return cached result if available (browser only) + if (cachedDebugMode !== null) return cachedDebugMode; + + // Check URL parameter first (and persist to sessionStorage) + const urlParams = new URLSearchParams(window.location.search); + const debugParam = urlParams.get("debug"); + + if (debugParam !== null) { + // Persist to sessionStorage so it survives navigation + if (debugParam === "0" || debugParam === "false" || debugParam === "") { + sessionStorage.removeItem("m3w_debug"); + cachedDebugMode = { enabled: false, filter: "" }; + return cachedDebugMode; + } + // Normalize "1" to "all" for consistent storage/retrieval + const filter = debugParam === "1" ? "all" : debugParam; + sessionStorage.setItem("m3w_debug", filter); + cachedDebugMode = { enabled: true, filter }; + return cachedDebugMode; + } + + // Check sessionStorage for persisted setting + const stored = sessionStorage.getItem("m3w_debug"); + if (stored) { + cachedDebugMode = { enabled: true, filter: stored }; + return cachedDebugMode; + } + + cachedDebugMode = { enabled: false, filter: "" }; + return cachedDebugMode; +} + +/** + * Check if a log source matches the debug filter + */ +function matchesDebugFilter(source: string, filter: string): boolean { + if (!filter || filter === "all") return true; // No filter or "all" = show all + const lowerSource = source.toLowerCase(); + const lowerFilter = filter.toLowerCase(); + return lowerSource.includes(lowerFilter); +} + /** * Check if remote logging is enabled (runtime injection) * Priority: @@ -286,7 +361,7 @@ class TraceImpl implements Trace { debug(source: string, message: string, options?: LogOptions): void { if (this.ended) return; - if (isDev) { + if (shouldShowInConsole(source)) { console.debug(`[Debug] ${source} ${message}`, options?.raw ?? ""); } // debug not sent to backend @@ -294,7 +369,7 @@ class TraceImpl implements Trace { info(source: string, message: string, options?: LogOptions): void { if (this.ended) return; - if (isDev) { + if (shouldShowInConsole(source)) { console.info(`[Info] ${source} ${message}`, options?.raw ?? ""); } if (isRemoteLoggingEnabled()) { @@ -352,14 +427,14 @@ class FrontendLogger implements Logger { } debug(source: string, message: string, options?: LogOptions): void { - if (isDev) { + if (shouldShowInConsole(source)) { console.debug(`[Debug] ${source} ${message}`, options?.raw ?? ""); } // debug not sent to backend } info(source: string, message: string, options?: LogOptions): void { - if (isDev) { + if (shouldShowInConsole(source)) { console.info(`[Info] ${source} ${message}`, options?.raw ?? ""); } if (isRemoteLoggingEnabled()) {