Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion frontend/src/lib/audio/media-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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 }
});
}
}

Expand Down
66 changes: 53 additions & 13 deletions frontend/src/lib/audio/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
40 changes: 40 additions & 0 deletions frontend/src/lib/logger-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
""
);
});
});
});
83 changes: 79 additions & 4 deletions frontend/src/lib/logger-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -286,15 +361,15 @@ 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
}

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()) {
Expand Down Expand Up @@ -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()) {
Expand Down