diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 247ab281..e503db17 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -125,6 +125,7 @@ describe("podNotesURIHandler", () => { test("keeps the requested time for the player to apply after loading metadata", async () => { playedEpisodes.markAsPlayed(testEpisode); + const revealPlayer = vi.fn(); await podNotesURIHandler( { @@ -134,6 +135,7 @@ describe("podNotesURIHandler", () => { time: "240", }, api as never, + revealPlayer, ); expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl); @@ -147,6 +149,25 @@ describe("podNotesURIHandler", () => { expect( get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`]?.finished, ).toBe(true); + expect(revealPlayer).toHaveBeenCalledTimes(1); + }); + + test("does not reveal the player when URI validation fails", async () => { + const revealPlayer = vi.fn(); + + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + time: "not-a-number", + }, + api as never, + revealPlayer, + ); + + expect(revealPlayer).not.toHaveBeenCalled(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); }); test("keeps the requested segment end for the player to apply after loading metadata", async () => { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 77930e55..a6ed1460 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -23,6 +23,7 @@ type PodNotesProtocolData = ObsidianProtocolData & { endTime?: string; to?: string; }; +type RevealPodNotesPlayer = () => Promise | void; /** * Obsidian decodes protocol query values with decodeURIComponent only, which does NOT turn '+' @@ -72,7 +73,8 @@ function resolveResumeTime(episode: Episode): number { export default async function podNotesURIHandler( { url, episodeName, time, endTime, end, to }: PodNotesProtocolData, - api: IAPI + api: IAPI, + revealPlayer?: RevealPodNotesPlayer, ) { if (!url || !episodeName) { new Notice("URL and episode name are required to play an episode"); @@ -127,6 +129,7 @@ export default async function podNotesURIHandler( requestedPlaybackTime.set(null); } + await revealPlayer?.(); return; } @@ -143,6 +146,7 @@ export default async function podNotesURIHandler( } isPaused.set(false); + await revealPlayer?.(); return; } @@ -197,6 +201,7 @@ export default async function podNotesURIHandler( ); currentEpisode.set(episode); viewState.set(ViewState.Player); + await revealPlayer?.(); } function parseSegmentEndTime( diff --git a/src/getContextMenuHandler.ts b/src/getContextMenuHandler.ts index 0a56d5c4..ec587fda 100644 --- a/src/getContextMenuHandler.ts +++ b/src/getContextMenuHandler.ts @@ -7,6 +7,7 @@ import { playedEpisodes, currentEpisode, viewState, + plugin, } from "./store"; import type { LocalEpisode } from "./types/LocalEpisode"; import { ViewState } from "./types/ViewState"; @@ -75,6 +76,7 @@ export default function getContextMenuHandler(app: App): EventRef { currentEpisode.set(localEpisode); viewState.set(ViewState.Player); + get(plugin)?.enablePodcastViewMount(); // Setting the stores above only updates an already-mounted // PodNotes view. When the view is closed (or hidden in a diff --git a/src/global.d.ts b/src/global.d.ts index 630e524e..714e7f7a 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -14,6 +14,7 @@ interface Manifest { // Not everything is implemented. interface App2 extends App { + isMobile?: boolean; plugins: { app: App; enabledPlugins: Set; diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index bfa69e0d..55c5f807 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PodNotes from "./main"; import { VIEW_TYPE } from "./constants"; +import { Platform } from "obsidian"; // Regression coverage for #55: "Show PodNotes" / the ribbon icon must reliably // surface the view. The bug was that the command was gated on the leaf NOT @@ -14,6 +15,13 @@ function makeLeaf() { }; } +const originalPlatform = { ...Platform }; + +afterEach(() => { + Object.assign(Platform, originalPlatform); + vi.useRealTimers(); +}); + function setupPlugin({ existingLeaves = [] as ReturnType[], rightLeaf = makeLeaf() as ReturnType | null, @@ -27,6 +35,10 @@ function setupPlugin({ // Build a bare instance so we exercise activateView without running the full // onload() side effects (store wiring, command registration, etc.). const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin as unknown as Record, { + podcastViewMountEnabled: true, + views: new Set(), + }); (plugin as unknown as { app: { workspace: typeof workspace } }).app = { workspace, }; @@ -70,6 +82,115 @@ describe("PodNotes.activateView", () => { await expect(plugin.activateView()).resolves.toBeUndefined(); expect(workspace.revealLeaf).not.toHaveBeenCalled(); }); + + it("enables and mounts a dormant restored view before revealing it", async () => { + const existing = makeLeaf(); + const firstRestoredView = { mountPodcastView: vi.fn() }; + const secondRestoredView = { mountPodcastView: vi.fn() }; + const { plugin } = setupPlugin({ existingLeaves: [existing] }); + Object.assign(plugin as unknown as Record, { + podcastViewMountEnabled: false, + views: new Set([firstRestoredView, secondRestoredView]), + }); + + expect(plugin.shouldMountPodcastView()).toBe(false); + + await plugin.activateView(); + + expect(plugin.shouldMountPodcastView()).toBe(true); + expect(firstRestoredView.mountPodcastView).toHaveBeenCalledTimes(1); + expect(secondRestoredView.mountPodcastView).toHaveBeenCalledTimes(1); + }); +}); + +describe("PodNotes.onLayoutReady", () => { + function setupLayoutPlugin({ + existingLeaves = [] as ReturnType[], + isMobile = false, + layoutReady = true, + rightLeaf = makeLeaf() as ReturnType | null, + } = {}) { + const workspace = { + layoutReady, + getLeavesOfType: vi.fn().mockReturnValue(existingLeaves), + getRightLeaf: vi.fn().mockReturnValue(rightLeaf), + detachLeavesOfType: vi.fn(), + }; + + const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin as unknown as Record, { + isUnloaded: false, + layoutReadyAttempts: 0, + layoutReadyRetry: null, + maxLayoutReadyAttempts: 10, + views: new Set(), + }); + (plugin as unknown as { app: { isMobile: boolean; workspace: typeof workspace } }).app = { + isMobile, + workspace, + }; + + return { plugin, workspace, rightLeaf }; + } + + it("creates the startup view on desktop when no leaf exists", () => { + const { plugin, workspace, rightLeaf } = setupLayoutPlugin(); + + plugin.onLayoutReady(); + + expect(workspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(rightLeaf?.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE, + }); + }); + + it("does not auto-create the startup view in the mobile app", () => { + Object.assign(Platform, { + isDesktop: false, + isDesktopApp: false, + isIosApp: true, + isMobile: true, + isMobileApp: true, + isPhone: true, + }); + const { plugin, workspace, rightLeaf } = setupLayoutPlugin(); + + plugin.onLayoutReady(); + + expect(workspace.getLeavesOfType).not.toHaveBeenCalled(); + expect(workspace.getRightLeaf).not.toHaveBeenCalled(); + expect(rightLeaf?.setViewState).not.toHaveBeenCalled(); + expect(workspace.detachLeavesOfType).not.toHaveBeenCalled(); + }); + + it("does not auto-create the startup view when desktop Obsidian emulates mobile", () => { + const { plugin, workspace, rightLeaf } = setupLayoutPlugin({ + isMobile: true, + }); + + plugin.onLayoutReady(); + + expect(workspace.getLeavesOfType).not.toHaveBeenCalled(); + expect(workspace.getRightLeaf).not.toHaveBeenCalled(); + expect(rightLeaf?.setViewState).not.toHaveBeenCalled(); + expect(workspace.detachLeavesOfType).not.toHaveBeenCalled(); + }); + + it("cancels a pending startup retry on unload", () => { + vi.useFakeTimers(); + const { plugin, workspace } = setupLayoutPlugin({ layoutReady: false }); + + plugin.onLayoutReady(); + expect(vi.getTimerCount()).toBe(1); + + plugin.onunload(); + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(100); + + expect(workspace.getRightLeaf).not.toHaveBeenCalled(); + expect(workspace.detachLeavesOfType).toHaveBeenCalledWith(VIEW_TYPE); + }); }); // Locks the actual #55 wiring (not just activateView's internals): the @@ -124,6 +245,7 @@ describe("PodNotes onload wiring (#55)", () => { registerObsidianProtocolHandler: vi.fn(), registerEvent: vi.fn(), mediaSessionActions: [], + views: new Set(), app: { workspace: { onLayoutReady: vi.fn(), diff --git a/src/main.ts b/src/main.ts index b3a01182..82d07829 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,13 @@ import { playbackRate, volume, } from "src/store"; -import { Notice, Plugin, type Editor, type WorkspaceLeaf } from "obsidian"; +import { + Notice, + Platform, + Plugin, + type Editor, + type WorkspaceLeaf, +} from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants"; @@ -82,7 +88,7 @@ export default class PodNotes extends Plugin implements IPodNotes { public settings!: IPodNotesSettings; public override app!: PartialAppExtension; - private view: MainView | null = null; + private views = new Set(); private playedEpisodeController?: StoreController<{ [episodeName: string]: PlayedEpisode; @@ -107,6 +113,9 @@ export default class PodNotes extends Plugin implements IPodNotes { private maxLayoutReadyAttempts = 10; private layoutReadyAttempts = 0; + private layoutReadyRetry: ReturnType | null = null; + private isUnloaded = false; + private podcastViewMountEnabled = true; private isReady = false; private pendingSave: IPodNotesSettings | null = null; private saveScheduled = false; @@ -114,6 +123,8 @@ export default class PodNotes extends Plugin implements IPodNotes { private mediaSessionActions: MediaSessionActionName[] = []; override async onload() { + this.isUnloaded = false; + this.podcastViewMountEnabled = !this.isMobileRuntime(); plugin.set(this); await this.loadSettings(); @@ -500,8 +511,9 @@ export default class PodNotes extends Plugin implements IPodNotes { this.addSettingTab(new PodNotesSettingsTab(this.app, this)); this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => { - this.view = new MainView(leaf, this); - return this.view; + const view = new MainView(leaf, this); + this.views.add(view); + return view; }); // Persistent, discoverable entry point in the left ribbon. The right @@ -515,7 +527,7 @@ export default class PodNotes extends Plugin implements IPodNotes { this.app.workspace.onLayoutReady(this.onLayoutReady.bind(this)); this.registerObsidianProtocolHandler("podnotes", (action) => - podNotesURIHandler(action, this.api), + podNotesURIHandler(action, this.api, () => this.activateView()), ); this.registerEvent(getContextMenuHandler(this.app)); @@ -525,19 +537,37 @@ export default class PodNotes extends Plugin implements IPodNotes { } onLayoutReady(): void { + if (this.isUnloaded) { + return; + } + + if (this.isMobileRuntime()) { + // Mobile startup is sensitive to creating plugin-owned side panes; keep + // PodNotes dormant until the user opens it with the command or ribbon. + this.clearLayoutReadyRetry(); + this.layoutReadyAttempts = 0; + return; + } + if (!this.app.workspace || !this.app.workspace.layoutReady) { // Workspace is not ready, schedule a retry this.layoutReadyAttempts++; - if (this.layoutReadyAttempts < this.maxLayoutReadyAttempts) { - setTimeout(() => this.onLayoutReady(), 100); - } else { + if (this.layoutReadyAttempts >= this.maxLayoutReadyAttempts) { console.error( "Failed to initialize PodNotes layout after maximum attempts", ); + } else if (!this.layoutReadyRetry) { + this.layoutReadyRetry = setTimeout(() => { + this.layoutReadyRetry = null; + this.onLayoutReady(); + }, 100); } return; } + this.clearLayoutReadyRetry(); + this.layoutReadyAttempts = 0; + if (this.app.workspace.getLeavesOfType(VIEW_TYPE).length) { return; } @@ -545,9 +575,13 @@ export default class PodNotes extends Plugin implements IPodNotes { const leaf = this.app.workspace.getRightLeaf(false); if (leaf) { - leaf.setViewState({ - type: VIEW_TYPE, - }); + void leaf + .setViewState({ + type: VIEW_TYPE, + }) + .catch((error) => { + console.error("PodNotes: failed to initialize startup view", error); + }); } } @@ -556,6 +590,8 @@ export default class PodNotes extends Plugin implements IPodNotes { // makes "Show PodNotes" and the ribbon icon reliably surface the view even // when it is already open but hidden in a collapsed/overflowing sidebar (#55). async activateView(): Promise { + this.enablePodcastViewMount(); + const { workspace } = this.app; const existing = workspace.getLeavesOfType(VIEW_TYPE); @@ -571,6 +607,25 @@ export default class PodNotes extends Plugin implements IPodNotes { } } + shouldMountPodcastView(): boolean { + return this.podcastViewMountEnabled; + } + + enablePodcastViewMount(): void { + this.podcastViewMountEnabled = true; + for (const view of this.views) { + view.mountPodcastView(); + } + } + + unregisterPodcastView(view: MainView): void { + this.views.delete(view); + } + + private isMobileRuntime(): boolean { + return Platform.isMobileApp || this.app.isMobile === true; + } + private getTranscriptionService(): TranscriptionService { if (!this.transcriptionService) { this.transcriptionService = new TranscriptionService(this); @@ -680,6 +735,8 @@ export default class PodNotes extends Plugin implements IPodNotes { } override onunload() { + this.isUnloaded = true; + this.clearLayoutReadyRetry(); this.clearMediaSessionHandlers(); this.playedEpisodeController?.off(); this.savedFeedsController?.off(); @@ -692,11 +749,19 @@ export default class PodNotes extends Plugin implements IPodNotes { this.hidePlayedEpisodesController?.off(); this.volumeUnsubscribe?.(); this.localFilesMirrorUnsubscribe?.(); + this.views.clear(); // Detach all leaves of this view type to prevent duplicates on reload this.app.workspace.detachLeavesOfType(VIEW_TYPE); } + private clearLayoutReadyRetry(): void { + if (!this.layoutReadyRetry) return; + + clearTimeout(this.layoutReadyRetry); + this.layoutReadyRetry = null; + } + async loadSettings() { const loadedData = await this.loadData(); diff --git a/src/types/IPodNotes.ts b/src/types/IPodNotes.ts index 3837af40..456b8411 100644 --- a/src/types/IPodNotes.ts +++ b/src/types/IPodNotes.ts @@ -1,9 +1,14 @@ -import type { IAPI } from 'src/API/IAPI'; -import type { IPodNotesSettings } from './IPodNotesSettings'; +import type { IAPI } from "src/API/IAPI"; +import type { IPodNotesSettings } from "./IPodNotesSettings"; +export interface PodNotesViewRegistration { + mountPodcastView(): void; +} export interface IPodNotes { settings: IPodNotesSettings; api: IAPI; + shouldMountPodcastView(): boolean; + unregisterPodcastView(view: PodNotesViewRegistration): void; saveSettings(): Promise; } diff --git a/src/ui/PodcastView/index.test.ts b/src/ui/PodcastView/index.test.ts new file mode 100644 index 00000000..cab022ca --- /dev/null +++ b/src/ui/PodcastView/index.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceLeaf } from "obsidian"; +import type { IPodNotes } from "../../types/IPodNotes"; + +const svelte = vi.hoisted(() => ({ + mount: vi.fn(() => ({ component: "podcast-view" })), + unmount: vi.fn(), +})); + +vi.mock("svelte", () => svelte); +vi.mock("./PodcastView.svelte", () => ({ default: {} })); + +import { MainView } from "."; + +function createView({ shouldMount }: { shouldMount: boolean }) { + const plugin = { + shouldMountPodcastView: vi.fn(() => shouldMount), + unregisterPodcastView: vi.fn(), + } as unknown as IPodNotes; + const view = new MainView({} as WorkspaceLeaf, plugin); + const contentEl = document.createElement("div"); + (view as unknown as { contentEl: HTMLElement }).contentEl = contentEl; + + return { contentEl, plugin, view }; +} + +async function openView(view: MainView): Promise { + await (view as unknown as { onOpen(): Promise }).onOpen(); +} + +async function closeView(view: MainView): Promise { + await (view as unknown as { onClose(): Promise }).onClose(); +} + +describe("MainView mobile startup mounting", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not mount the Svelte UI when plugin startup keeps the view dormant", async () => { + const { plugin, view } = createView({ shouldMount: false }); + + await openView(view); + + expect(plugin.shouldMountPodcastView).toHaveBeenCalled(); + expect(svelte.mount).not.toHaveBeenCalled(); + }); + + it("mounts the Svelte UI when the plugin allows the view to wake", async () => { + const { contentEl, view } = createView({ shouldMount: true }); + + await openView(view); + + expect(svelte.mount).toHaveBeenCalledTimes(1); + expect(svelte.mount).toHaveBeenCalledWith(expect.anything(), { + target: contentEl, + }); + }); + + it("mountPodcastView wakes a dormant restored view exactly once", () => { + const { contentEl, view } = createView({ shouldMount: false }); + + view.mountPodcastView(); + view.mountPodcastView(); + + expect(svelte.mount).toHaveBeenCalledTimes(1); + expect(svelte.mount).toHaveBeenCalledWith(expect.anything(), { + target: contentEl, + }); + }); + + it("unmounts a mounted PodcastView", async () => { + const { plugin, view } = createView({ shouldMount: true }); + + await openView(view); + await closeView(view); + + expect(svelte.unmount).toHaveBeenCalledTimes(1); + expect(plugin.unregisterPodcastView).toHaveBeenCalledWith(view); + }); +}); diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index 821b600d..efcc8847 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -25,6 +25,18 @@ export class MainView extends ItemView { } protected override async onOpen(): Promise { + if (!this.plugin.shouldMountPodcastView()) { + return; + } + + this.mountPodcastView(); + } + + mountPodcastView(): void { + if (this.podcastView) { + return; + } + this.podcastView = mount(PodcastView, { target: this.contentEl, }); @@ -37,5 +49,6 @@ export class MainView extends ItemView { } this.contentEl.empty(); + this.plugin.unregisterPodcastView(this); } }