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
126 changes: 55 additions & 71 deletions src/main.activateView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import PodNotes from "./main";
import { dataFileText } from "../tests/mocks/dataFileWriter";
import { VIEW_TYPE } from "./constants";
import { Notice, Platform } from "obsidian";
import { Notice, Platform, type WorkspaceLeaf } from "obsidian";
import type { Episode } from "./types/Episode";

// Regression coverage for #55: "Show PodNotes" / the ribbon icon must reliably
Expand Down Expand Up @@ -257,7 +257,6 @@ describe("PodNotes onload wiring (#55)", () => {
registerDomEvent: vi.fn(),
writeRefusal: null,
writeInFlight: null,
mediaSessionActions: [],
storeUnsubscribers: [],
views: new Set(),
manifest: { dir: ".obsidian/plugins/podnotes" },
Expand Down Expand Up @@ -411,37 +410,6 @@ describe("PodNotes onload wiring (#55)", () => {
expect(commandIds.has("reset-playback-rate")).toBe(true);
});

it("registers a previous-track Media Session handler for headphone timestamp capture", async () => {
const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession");
const calls: Array<{ action: string; hasHandler: boolean }> = [];

Object.defineProperty(navigator, "mediaSession", {
configurable: true,
value: {
setActionHandler: vi.fn((action: string, handler: (() => void) | null) => {
calls.push({
action,
hasHandler: typeof handler === "function",
});
}),
},
});

try {
await loadPlugin();
expect(calls).toContainEqual({
action: "previoustrack",
hasHandler: true,
});
} finally {
if (originalMediaSession) {
Object.defineProperty(navigator, "mediaSession", originalMediaSession);
} else {
Reflect.deleteProperty(navigator, "mediaSession");
}
}
});

it("takes its refusal Notice away on unload", async () => {
const { plugin } = await loadPlugin({ schemaVersion: 2 });
Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(undefined) });
Expand All @@ -456,44 +424,6 @@ describe("PodNotes onload wiring (#55)", () => {
expect(hide).toHaveBeenCalledTimes(1);
});

it("clears the previous-track Media Session handler on unload", async () => {
const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession");
const calls: Array<{ action: string; hasHandler: boolean }> = [];

Object.defineProperty(navigator, "mediaSession", {
configurable: true,
value: {
setActionHandler: vi.fn((action: string, handler: (() => void) | null) => {
calls.push({
action,
hasHandler: typeof handler === "function",
});
}),
},
});

try {
const { plugin } = await loadPlugin();
plugin.onunload();
loaded.splice(loaded.indexOf(plugin), 1);

expect(calls).toContainEqual({
action: "previoustrack",
hasHandler: true,
});
expect(calls).toContainEqual({
action: "previoustrack",
hasHandler: false,
});
} finally {
if (originalMediaSession) {
Object.defineProperty(navigator, "mediaSession", originalMediaSession);
} else {
Reflect.deleteProperty(navigator, "mediaSession");
}
}
});

it("Show PodNotes command and ribbon icon both route to activateView", async () => {
const { commands, ribbonCalls, activateSpy } = await loadPlugin();

Expand Down Expand Up @@ -579,6 +509,60 @@ describe("PodNotes onload wiring (#55)", () => {
});
});

function recordMediaSession(target: { navigator: Navigator }) {
const calls: Array<{ action: string; hasHandler: boolean }> = [];
const original = Object.getOwnPropertyDescriptor(target.navigator, "mediaSession");
Object.defineProperty(target.navigator, "mediaSession", {
configurable: true,
value: {
setActionHandler: vi.fn((action: string, handler: (() => void) | null) => {
calls.push({ action, hasHandler: typeof handler === "function" });
}),
setPositionState: vi.fn(),
},
});
restoreMediaSessions.push(() => {
if (original) Object.defineProperty(target.navigator, "mediaSession", original);
else Reflect.deleteProperty(target.navigator, "mediaSession");
});
return calls;
}
const restoreMediaSessions: Array<() => void> = [];
afterEach(() => {
for (const restore of restoreMediaSessions.splice(0)) restore();
vi.unstubAllGlobals();
});

it("sets the headphone capture handler on the main window's Media Session once an episode is shown", async () => {
const calls = recordMediaSession(window);
const popout = { navigator: {} as Navigator };
const popoutCalls = recordMediaSession(popout);
vi.stubGlobal("activeWindow", popout);

const { plugin } = await loadPlugin(savedData);
const createView = vi.mocked(plugin.registerView).mock.calls[0][1];
createView({} as WorkspaceLeaf);
createView({} as WorkspaceLeaf);

expect(calls.filter(({ action }) => action === "previoustrack")).toEqual([
{ action: "previoustrack", hasHandler: true },
]);
expect(popoutCalls).toEqual([]);
});

it("clears the Media Session handlers on unload", async () => {
const calls = recordMediaSession(window);

const { plugin } = await loadPlugin(savedData);
plugin.onunload();
loaded.splice(loaded.indexOf(plugin), 1);

const set = calls.filter(({ hasHandler }) => hasHandler).map(({ action }) => action);
const cleared = calls.filter(({ hasHandler }) => !hasHandler).map(({ action }) => action);
expect(set).toContain("previoustrack");
expect(cleared.sort()).toEqual(set.sort());
});

it("does not offer transcription for known video episodes", async () => {
const videoEpisode: Episode = {
title: "Video Episode",
Expand Down
61 changes: 10 additions & 51 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import getContextMenuHandler from "./getContextMenuHandler";
import { TranscriptionService } from "./services/TranscriptionService";
import type { Unsubscriber } from "svelte/store";
import { PlayerController } from "./player/PlayerController";
import { bindMediaSession } from "./player/mediaSession";
import {
decodePodNotesData,
encodePodNotesData,
Expand All @@ -45,17 +46,6 @@ import { FeedUrlRepository } from "./services/FeedUrlRepository";
import { protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds";
import { FeedRepository, loadFeed } from "./services/FeedRepository";

type MediaSessionActionName =
| "previoustrack"
| "play"
| "pause"
| "stop"
| "nexttrack"
| "seekbackward"
| "seekforward"
| "seekto"
| "skipad";

type WriteRefusal =
| "unreadable-data-file"
| "older-data-file"
Expand Down Expand Up @@ -130,7 +120,6 @@ export default class PodNotes extends Plugin implements IPodNotes {
private writer: DataFileWriter<PersistedPodNotesDataV2> | null = null;
private refusalNotice: Notice | null = null;
private persistenceUnknownFields: Record<string, unknown> = {};
private mediaSessionActions: MediaSessionActionName[] = [];

override async onload() {
this.isUnloaded = false;
Expand Down Expand Up @@ -229,7 +218,15 @@ export default class PodNotes extends Plugin implements IPodNotes {
);

this.registerEvent(getContextMenuHandler(this.app));
this.registerMediaSessionHandlers();
this.storeUnsubscribers.push(
bindMediaSession(this.player, {
mediaSession: window.navigator.mediaSession,
MediaMetadata: window.MediaMetadata,
captureTimestamp: () => void this.captureTimestampFromMediaSession(),
skipBackward: () => this.api.skipBackward(),
skipForward: () => this.api.skipForward(),
}),
);
this.isReady = true;
}

Expand Down Expand Up @@ -409,43 +406,6 @@ export default class PodNotes extends Plugin implements IPodNotes {
return await this.appendTimestampToEpisodeNote();
}

private registerMediaSessionHandlers(): void {
this.registerMediaSessionAction("previoustrack", () => {
void this.captureTimestampFromMediaSession();
});
}

private registerMediaSessionAction(action: MediaSessionActionName, handler: () => void): void {
const mediaSession = window.navigator?.mediaSession;
if (!mediaSession?.setActionHandler) {
return;
}

try {
mediaSession.setActionHandler(action, handler);
this.mediaSessionActions.push(action);
} catch (error) {
console.warn(`PodNotes: Media Session action "${action}" is not supported`, error);
}
}

private clearMediaSessionHandlers(): void {
const mediaSession = window.navigator?.mediaSession;
if (!mediaSession?.setActionHandler) {
return;
}

for (const action of this.mediaSessionActions) {
try {
mediaSession.setActionHandler(action, null);
} catch {
// Ignore unsupported cleanup paths; registration already guarded them.
}
}

this.mediaSessionActions = [];
}

override onunload() {
this.isUnloaded = true;
// The refusal ends with this instance; the next one reads the file itself.
Expand All @@ -454,7 +414,6 @@ export default class PodNotes extends Plugin implements IPodNotes {
this.player?.dispose();
void this.writer?.unload();
this.clearLayoutReadyRetry();
this.clearMediaSessionHandlers();
for (const unsubscribe of this.storeUnsubscribers) unsubscribe();
this.storeUnsubscribers = [];
this.localFilesMirrorUnsubscribe?.();
Expand Down
Loading
Loading