Skip to content

Commit 993d23d

Browse files
committed
fix(lifecycle): defer mobile podcast view startup
1 parent d97e59e commit 993d23d

6 files changed

Lines changed: 285 additions & 15 deletions

File tree

‎src/getContextMenuHandler.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
playedEpisodes,
88
currentEpisode,
99
viewState,
10+
plugin,
1011
} from "./store";
1112
import type { LocalEpisode } from "./types/LocalEpisode";
1213
import { ViewState } from "./types/ViewState";
@@ -75,6 +76,7 @@ export default function getContextMenuHandler(app: App): EventRef {
7576

7677
currentEpisode.set(localEpisode);
7778
viewState.set(ViewState.Player);
79+
get(plugin)?.enablePodcastViewMount();
7880

7981
// Setting the stores above only updates an already-mounted
8082
// PodNotes view. When the view is closed (or hidden in a

‎src/main.activateView.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import PodNotes from "./main";
33
import { VIEW_TYPE } from "./constants";
4+
import { Platform } from "obsidian";
45

56
// Regression coverage for #55: "Show PodNotes" / the ribbon icon must reliably
67
// surface the view. The bug was that the command was gated on the leaf NOT
@@ -14,6 +15,13 @@ function makeLeaf() {
1415
};
1516
}
1617

18+
const originalPlatform = { ...Platform };
19+
20+
afterEach(() => {
21+
Object.assign(Platform, originalPlatform);
22+
vi.useRealTimers();
23+
});
24+
1725
function setupPlugin({
1826
existingLeaves = [] as ReturnType<typeof makeLeaf>[],
1927
rightLeaf = makeLeaf() as ReturnType<typeof makeLeaf> | null,
@@ -27,6 +35,10 @@ function setupPlugin({
2735
// Build a bare instance so we exercise activateView without running the full
2836
// onload() side effects (store wiring, command registration, etc.).
2937
const plugin = Object.create(PodNotes.prototype) as PodNotes;
38+
Object.assign(plugin as unknown as Record<string, unknown>, {
39+
podcastViewMountEnabled: true,
40+
views: new Set(),
41+
});
3042
(plugin as unknown as { app: { workspace: typeof workspace } }).app = {
3143
workspace,
3244
};
@@ -70,6 +82,100 @@ describe("PodNotes.activateView", () => {
7082
await expect(plugin.activateView()).resolves.toBeUndefined();
7183
expect(workspace.revealLeaf).not.toHaveBeenCalled();
7284
});
85+
86+
it("enables and mounts a dormant restored view before revealing it", async () => {
87+
const existing = makeLeaf();
88+
const firstRestoredView = { mountPodcastView: vi.fn() };
89+
const secondRestoredView = { mountPodcastView: vi.fn() };
90+
const { plugin } = setupPlugin({ existingLeaves: [existing] });
91+
Object.assign(plugin as unknown as Record<string, unknown>, {
92+
podcastViewMountEnabled: false,
93+
views: new Set([firstRestoredView, secondRestoredView]),
94+
});
95+
96+
expect(plugin.shouldMountPodcastView()).toBe(false);
97+
98+
await plugin.activateView();
99+
100+
expect(plugin.shouldMountPodcastView()).toBe(true);
101+
expect(firstRestoredView.mountPodcastView).toHaveBeenCalledTimes(1);
102+
expect(secondRestoredView.mountPodcastView).toHaveBeenCalledTimes(1);
103+
});
104+
});
105+
106+
describe("PodNotes.onLayoutReady", () => {
107+
function setupLayoutPlugin({
108+
existingLeaves = [] as ReturnType<typeof makeLeaf>[],
109+
layoutReady = true,
110+
rightLeaf = makeLeaf() as ReturnType<typeof makeLeaf> | null,
111+
} = {}) {
112+
const workspace = {
113+
layoutReady,
114+
getLeavesOfType: vi.fn().mockReturnValue(existingLeaves),
115+
getRightLeaf: vi.fn().mockReturnValue(rightLeaf),
116+
detachLeavesOfType: vi.fn(),
117+
};
118+
119+
const plugin = Object.create(PodNotes.prototype) as PodNotes;
120+
Object.assign(plugin as unknown as Record<string, unknown>, {
121+
isUnloaded: false,
122+
layoutReadyAttempts: 0,
123+
layoutReadyRetry: null,
124+
maxLayoutReadyAttempts: 10,
125+
views: new Set(),
126+
});
127+
(plugin as unknown as { app: { workspace: typeof workspace } }).app = {
128+
workspace,
129+
};
130+
131+
return { plugin, workspace, rightLeaf };
132+
}
133+
134+
it("creates the startup view on desktop when no leaf exists", () => {
135+
const { plugin, workspace, rightLeaf } = setupLayoutPlugin();
136+
137+
plugin.onLayoutReady();
138+
139+
expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
140+
expect(rightLeaf?.setViewState).toHaveBeenCalledWith({
141+
type: VIEW_TYPE,
142+
});
143+
});
144+
145+
it("does not auto-create the startup view in the mobile app", () => {
146+
Object.assign(Platform, {
147+
isDesktop: false,
148+
isDesktopApp: false,
149+
isIosApp: true,
150+
isMobile: true,
151+
isMobileApp: true,
152+
isPhone: true,
153+
});
154+
const { plugin, workspace, rightLeaf } = setupLayoutPlugin();
155+
156+
plugin.onLayoutReady();
157+
158+
expect(workspace.getLeavesOfType).not.toHaveBeenCalled();
159+
expect(workspace.getRightLeaf).not.toHaveBeenCalled();
160+
expect(rightLeaf?.setViewState).not.toHaveBeenCalled();
161+
expect(workspace.detachLeavesOfType).not.toHaveBeenCalled();
162+
});
163+
164+
it("cancels a pending startup retry on unload", () => {
165+
vi.useFakeTimers();
166+
const { plugin, workspace } = setupLayoutPlugin({ layoutReady: false });
167+
168+
plugin.onLayoutReady();
169+
expect(vi.getTimerCount()).toBe(1);
170+
171+
plugin.onunload();
172+
expect(vi.getTimerCount()).toBe(0);
173+
174+
vi.advanceTimersByTime(100);
175+
176+
expect(workspace.getRightLeaf).not.toHaveBeenCalled();
177+
expect(workspace.detachLeavesOfType).toHaveBeenCalledWith(VIEW_TYPE);
178+
});
73179
});
74180

75181
// Locks the actual #55 wiring (not just activateView's internals): the
@@ -123,6 +229,7 @@ describe("PodNotes onload wiring (#55)", () => {
123229
registerView: vi.fn(),
124230
registerObsidianProtocolHandler: vi.fn(),
125231
registerEvent: vi.fn(),
232+
views: new Set(),
126233
app: {
127234
workspace: {
128235
onLayoutReady: vi.fn(),

‎src/main.ts‎

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
sanitizeEpisodeListLimit,
1313
volume,
1414
} from "src/store";
15-
import { Notice, Plugin, type Editor, type WorkspaceLeaf } from "obsidian";
15+
import {
16+
Notice,
17+
Platform,
18+
Plugin,
19+
type Editor,
20+
type WorkspaceLeaf,
21+
} from "obsidian";
1622
import { API } from "src/API/API";
1723
import type { IAPI } from "src/API/IAPI";
1824
import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants";
@@ -64,7 +70,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
6470
public settings!: IPodNotesSettings;
6571
public override app!: PartialAppExtension;
6672

67-
private view: MainView | null = null;
73+
private views = new Set<MainView>();
6874

6975
private playedEpisodeController?: StoreController<{
7076
[episodeName: string]: PlayedEpisode;
@@ -89,12 +95,17 @@ export default class PodNotes extends Plugin implements IPodNotes {
8995

9096
private maxLayoutReadyAttempts = 10;
9197
private layoutReadyAttempts = 0;
98+
private layoutReadyRetry: ReturnType<typeof setTimeout> | null = null;
99+
private isUnloaded = false;
100+
private podcastViewMountEnabled = !Platform.isMobileApp;
92101
private isReady = false;
93102
private pendingSave: IPodNotesSettings | null = null;
94103
private saveScheduled = false;
95104
private saveChain: Promise<void> = Promise.resolve();
96105

97106
override async onload() {
107+
this.isUnloaded = false;
108+
this.podcastViewMountEnabled = !Platform.isMobileApp;
98109
plugin.set(this);
99110

100111
await this.loadSettings();
@@ -446,8 +457,9 @@ export default class PodNotes extends Plugin implements IPodNotes {
446457
this.addSettingTab(new PodNotesSettingsTab(this.app, this));
447458

448459
this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => {
449-
this.view = new MainView(leaf, this);
450-
return this.view;
460+
const view = new MainView(leaf, this);
461+
this.views.add(view);
462+
return view;
451463
});
452464

453465
// Persistent, discoverable entry point in the left ribbon. The right
@@ -460,39 +472,62 @@ export default class PodNotes extends Plugin implements IPodNotes {
460472

461473
this.app.workspace.onLayoutReady(this.onLayoutReady.bind(this));
462474

463-
this.registerObsidianProtocolHandler("podnotes", (action) =>
464-
podNotesURIHandler(action, this.api),
465-
);
475+
this.registerObsidianProtocolHandler("podnotes", (action) => {
476+
this.enablePodcastViewMount();
477+
return podNotesURIHandler(action, this.api);
478+
});
466479

467480
this.registerEvent(getContextMenuHandler(this.app));
468481

469482
this.isReady = true;
470483
}
471484

472485
onLayoutReady(): void {
486+
if (this.isUnloaded) {
487+
return;
488+
}
489+
490+
if (Platform.isMobileApp) {
491+
// Mobile startup is sensitive to creating plugin-owned side panes; keep
492+
// PodNotes dormant until the user opens it with the command or ribbon.
493+
this.clearLayoutReadyRetry();
494+
this.layoutReadyAttempts = 0;
495+
return;
496+
}
497+
473498
if (!this.app.workspace || !this.app.workspace.layoutReady) {
474499
// Workspace is not ready, schedule a retry
475500
this.layoutReadyAttempts++;
476-
if (this.layoutReadyAttempts < this.maxLayoutReadyAttempts) {
477-
setTimeout(() => this.onLayoutReady(), 100);
478-
} else {
501+
if (this.layoutReadyAttempts >= this.maxLayoutReadyAttempts) {
479502
console.error(
480503
"Failed to initialize PodNotes layout after maximum attempts",
481504
);
505+
} else if (!this.layoutReadyRetry) {
506+
this.layoutReadyRetry = setTimeout(() => {
507+
this.layoutReadyRetry = null;
508+
this.onLayoutReady();
509+
}, 100);
482510
}
483511
return;
484512
}
485513

514+
this.clearLayoutReadyRetry();
515+
this.layoutReadyAttempts = 0;
516+
486517
if (this.app.workspace.getLeavesOfType(VIEW_TYPE).length) {
487518
return;
488519
}
489520

490521
const leaf = this.app.workspace.getRightLeaf(false);
491522

492523
if (leaf) {
493-
leaf.setViewState({
494-
type: VIEW_TYPE,
495-
});
524+
void leaf
525+
.setViewState({
526+
type: VIEW_TYPE,
527+
})
528+
.catch((error) => {
529+
console.error("PodNotes: failed to initialize startup view", error);
530+
});
496531
}
497532
}
498533

@@ -501,6 +536,8 @@ export default class PodNotes extends Plugin implements IPodNotes {
501536
// makes "Show PodNotes" and the ribbon icon reliably surface the view even
502537
// when it is already open but hidden in a collapsed/overflowing sidebar (#55).
503538
async activateView(): Promise<void> {
539+
this.enablePodcastViewMount();
540+
504541
const { workspace } = this.app;
505542

506543
const existing = workspace.getLeavesOfType(VIEW_TYPE);
@@ -516,6 +553,21 @@ export default class PodNotes extends Plugin implements IPodNotes {
516553
}
517554
}
518555

556+
shouldMountPodcastView(): boolean {
557+
return this.podcastViewMountEnabled;
558+
}
559+
560+
enablePodcastViewMount(): void {
561+
this.podcastViewMountEnabled = true;
562+
for (const view of this.views) {
563+
view.mountPodcastView();
564+
}
565+
}
566+
567+
unregisterPodcastView(view: MainView): void {
568+
this.views.delete(view);
569+
}
570+
519571
private getTranscriptionService(): TranscriptionService {
520572
if (!this.transcriptionService) {
521573
this.transcriptionService = new TranscriptionService(this);
@@ -525,6 +577,8 @@ export default class PodNotes extends Plugin implements IPodNotes {
525577
}
526578

527579
override onunload() {
580+
this.isUnloaded = true;
581+
this.clearLayoutReadyRetry();
528582
this.playedEpisodeController?.off();
529583
this.savedFeedsController?.off();
530584
this.playlistController?.off();
@@ -536,11 +590,19 @@ export default class PodNotes extends Plugin implements IPodNotes {
536590
this.hidePlayedEpisodesController?.off();
537591
this.volumeUnsubscribe?.();
538592
this.localFilesMirrorUnsubscribe?.();
593+
this.views.clear();
539594

540595
// Detach all leaves of this view type to prevent duplicates on reload
541596
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
542597
}
543598

599+
private clearLayoutReadyRetry(): void {
600+
if (!this.layoutReadyRetry) return;
601+
602+
clearTimeout(this.layoutReadyRetry);
603+
this.layoutReadyRetry = null;
604+
}
605+
544606
async loadSettings() {
545607
const loadedData = await this.loadData();
546608

‎src/types/IPodNotes.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import type { IAPI } from 'src/API/IAPI';
2-
import type { IPodNotesSettings } from './IPodNotesSettings';
1+
import type { IAPI } from "src/API/IAPI";
2+
import type { IPodNotesSettings } from "./IPodNotesSettings";
33

4+
export interface PodNotesViewRegistration {
5+
mountPodcastView(): void;
6+
}
47

58
export interface IPodNotes {
69
settings: IPodNotesSettings;
710
api: IAPI;
11+
shouldMountPodcastView(): boolean;
12+
unregisterPodcastView(view: PodNotesViewRegistration): void;
813
saveSettings(): Promise<void>;
914
}

0 commit comments

Comments
 (0)