Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
35e647e
feat: Add transcription queue for episodes (#132)
chhoumann Nov 23, 2025
79e5b80
feat: Add volume control to player (#133)
chhoumann Nov 23, 2025
2633603
fix: mount views with svelte api (#134)
chhoumann Nov 23, 2025
87222b6
fix: Reserve image space and use native lazy load (#138)
chhoumann Nov 24, 2025
65e3e54
fix: fix Obsidian listener accumulation (#141)
chhoumann Nov 24, 2025
7ffec62
feaet: Optimize latest episodes aggregation (#142)
chhoumann Nov 24, 2025
5327887
fix: Add keys to podcast lists (#136)
chhoumann Nov 24, 2025
0c6dc7e
fix: Stabilize episode list layout (#139)
chhoumann Nov 24, 2025
5402c29
style: move inline podcast styles into CSS (#145)
chhoumann Nov 24, 2025
17cd475
fix: persist hide-played toggle (#144)
chhoumann Nov 24, 2025
519cacc
feat: Optimize latest episodes updates (#143)
chhoumann Nov 24, 2025
8b0aca5
refactor: Remove redundant currentTime sync (#147)
chhoumann Nov 24, 2025
7b0de87
fix: Replace moment date formatting in episode list (#148)
chhoumann Nov 24, 2025
e698155
feat: Stream feed loading in PodcastView (#140)
chhoumann Nov 24, 2025
6ecee78
fix: Optimize Fuse reuse in search (#149)
chhoumann Nov 24, 2025
c660df9
feat: Improve topbar focus contrast (#150)
chhoumann Nov 24, 2025
0521a25
feat: Add live input handling for episode search (#135)
chhoumann Nov 24, 2025
b6a6fec
fix: Use Intl formatter for episode dates (#153)
chhoumann Nov 24, 2025
c94c0a0
feat: Improve nav cues and native lazy images (#152)
chhoumann Nov 24, 2025
22ba238
feat: Improve podcast loading feedback (#151)
chhoumann Nov 24, 2025
2900339
fix: guard obsidian inputs from feedback loops (#154)
chhoumann Nov 24, 2025
b73a70e
fix: settings corruption
chhoumann Nov 24, 2025
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
12 changes: 12 additions & 0 deletions src/API/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import {
duration,
isPaused,
plugin,
volume as volumeStore,
} from "src/store";
import { get } from "svelte/store";
import encodePodnotesURI from "src/utility/encodePodnotesURI";
import { isLocalFile } from "src/utility/isLocalFile";

const clampVolume = (value: number): number =>
Math.min(1, Math.max(0, value));

export class API implements IAPI {
public get podcast(): Episode {
return get(currentEpisode);
Expand All @@ -34,6 +38,14 @@ export class API implements IAPI {
return !get(isPaused);
}

public get volume(): number {
return get(volumeStore);
}

public set volume(value: number) {
volumeStore.set(clampVolume(value));
}

/**
* Gets the current time in the given moment format.
* @param format Moment format.
Expand Down
1 change: 1 addition & 0 deletions src/API/IAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface IAPI {
readonly isPlaying: boolean;
readonly length: number;
currentTime: number;
volume: number;

getPodcastTimeFormatted(
format: string,
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
savedFeeds: {},
podNotes: {},
defaultPlaybackRate: 1,
defaultVolume: 1,
hidePlayedEpisodes: false,
playedEpisodes: {},
favorites: {
...FAVORITES_SETTINGS,
Expand Down
78 changes: 77 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
playlists,
queue,
savedFeeds,
hidePlayedEpisodes,
volume,
} from "src/store";
import { Plugin, type WorkspaceLeaf } from "obsidian";
import { API } from "src/API/API";
Expand All @@ -28,6 +30,7 @@ import { QueueController } from "./store_controllers/QueueController";
import { FavoritesController } from "./store_controllers/FavoritesController";
import type { Episode } from "./types/Episode";
import CurrentEpisodeController from "./store_controllers/CurrentEpisodeController";
import { HidePlayedEpisodesController } from "./store_controllers/HidePlayedEpisodesController";
import { TimestampTemplateEngine } from "./TemplateEngine";
import createPodcastNote from "./createPodcastNote";
import downloadEpisodeWithNotice from "./downloadEpisode";
Expand All @@ -40,6 +43,7 @@ import getContextMenuHandler from "./getContextMenuHandler";
import getUniversalPodcastLink from "./getUniversalPodcastLink";
import type { IconType } from "./types/IconType";
import { TranscriptionService } from "./services/TranscriptionService";
import type { Unsubscriber } from "svelte/store";

export default class PodNotes extends Plugin implements IPodNotes {
public api!: IAPI;
Expand All @@ -64,10 +68,16 @@ export default class PodNotes extends Plugin implements IPodNotes {
private downloadedEpisodesController?: StoreController<{
[podcastName: string]: DownloadedEpisode[];
}>;
private hidePlayedEpisodesController?: StoreController<boolean>;
private transcriptionService?: TranscriptionService;
private volumeUnsubscribe?: Unsubscriber;

private maxLayoutReadyAttempts = 10;
private layoutReadyAttempts = 0;
private isReady = false;
private pendingSave: IPodNotesSettings | null = null;
private saveScheduled = false;
private saveChain: Promise<void> = Promise.resolve();

override async onload() {
plugin.set(this);
Expand All @@ -84,6 +94,10 @@ export default class PodNotes extends Plugin implements IPodNotes {
if (this.settings.currentEpisode) {
currentEpisode.set(this.settings.currentEpisode);
}
hidePlayedEpisodes.set(this.settings.hidePlayedEpisodes);
volume.set(
Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)),
);

this.playedEpisodeController = new EpisodeStatusController(
playedEpisodes,
Expand All @@ -102,8 +116,27 @@ export default class PodNotes extends Plugin implements IPodNotes {
currentEpisode,
this,
).on();
this.hidePlayedEpisodesController = new HidePlayedEpisodesController(
hidePlayedEpisodes,
this,
).on();

this.api = new API();
this.volumeUnsubscribe = volume.subscribe((value) => {
const clamped = Math.min(1, Math.max(0, value));

if (clamped !== value) {
volume.set(clamped);
return;
}

if (clamped === this.settings.defaultVolume) {
return;
}

this.settings.defaultVolume = clamped;
void this.saveSettings();
});

this.addCommand({
id: "podnotes-show-leaf",
Expand Down Expand Up @@ -291,6 +324,8 @@ export default class PodNotes extends Plugin implements IPodNotes {
);

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

this.isReady = true;
}

onLayoutReady(): void {
Expand Down Expand Up @@ -337,6 +372,8 @@ export default class PodNotes extends Plugin implements IPodNotes {
this.localFilesController?.off();
this.downloadedEpisodesController?.off();
this.currentEpisodeController?.off();
this.hidePlayedEpisodesController?.off();
this.volumeUnsubscribe?.();
}

async loadSettings() {
Expand All @@ -350,6 +387,45 @@ export default class PodNotes extends Plugin implements IPodNotes {
}

async saveSettings() {
await this.saveData(this.settings);
if (!this.isReady) return;

this.pendingSave = this.cloneSettings();

if (this.saveScheduled) {
return this.saveChain;
}

this.saveScheduled = true;

this.saveChain = this.saveChain
.then(async () => {
while (this.pendingSave) {
const snapshot = this.pendingSave;
this.pendingSave = null;
await this.saveData(snapshot);
}
})
.catch((error) => {
console.error("PodNotes: failed to save settings", error);
})
.finally(() => {
this.saveScheduled = false;

// If a save was requested while we were saving, run again.
if (this.pendingSave) {
void this.saveSettings();
}
});

return this.saveChain;
}

private cloneSettings(): IPodNotesSettings {
// structuredClone is available in Obsidian's Electron runtime; fallback for safety.
if (typeof structuredClone === "function") {
return structuredClone(this.settings);
}

return JSON.parse(JSON.stringify(this.settings)) as IPodNotesSettings;
}
}
80 changes: 68 additions & 12 deletions src/services/TranscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,15 @@ export class TranscriptionService {
private readonly CHUNK_SIZE_BYTES = 20 * 1024 * 1024;
private readonly WAV_HEADER_SIZE = 44;
private readonly PCM_BYTES_PER_SAMPLE = 2;
private isTranscribing = false;
private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2;
private pendingEpisodes: Episode[] = [];
private activeTranscriptions = new Set<string>();

constructor(plugin: PodNotes) {
this.plugin = plugin;
}

async transcribeCurrentEpisode(): Promise<void> {
if (this.isTranscribing) {
new Notice("A transcription is already in progress.");
return;
}

if (!this.plugin.settings.openAIApiKey?.trim()) {
new Notice(
"Please add your OpenAI API key in the transcript settings first.",
Expand All @@ -81,7 +78,6 @@ export class TranscriptionService {
return;
}

// Check if transcription file already exists
const transcriptPath = FilePathTemplateEngine(
this.plugin.settings.transcript.path,
currentEpisode,
Expand All @@ -95,13 +91,72 @@ export class TranscriptionService {
return;
}

this.isTranscribing = true;
const notice = TimerNotice("Transcription", "Preparing to transcribe...");
const episodeKey = this.getEpisodeKey(currentEpisode);
const isAlreadyQueued =
this.pendingEpisodes.some(
(episode) => this.getEpisodeKey(episode) === episodeKey,
) || this.activeTranscriptions.has(episodeKey);

if (isAlreadyQueued) {
new Notice("This episode is already queued or transcribing.");
return;
}

this.pendingEpisodes.push(currentEpisode);
new Notice(
`Queued "${currentEpisode.title}" for transcription. It will start automatically.`,
);
this.drainQueue();
}

private drainQueue(): void {
while (
this.activeTranscriptions.size < this.MAX_CONCURRENT_TRANSCRIPTIONS &&
this.pendingEpisodes.length > 0
) {
const nextEpisode = this.pendingEpisodes.shift();
if (!nextEpisode) {
return;
}

const episodeKey = this.getEpisodeKey(nextEpisode);
this.activeTranscriptions.add(episodeKey);

void this.transcribeEpisode(nextEpisode).finally(() => {
this.activeTranscriptions.delete(episodeKey);
this.drainQueue();
});
}
}

private getEpisodeKey(episode: Episode): string {
return `${episode.podcastName}:${episode.title}`;
}

private async transcribeEpisode(episode: Episode): Promise<void> {
const notice = TimerNotice(
`Transcription: ${episode.title}`,
"Preparing to transcribe...",
);

try {
const transcriptPath = FilePathTemplateEngine(
this.plugin.settings.transcript.path,
episode,
);
const existingFile =
this.plugin.app.vault.getAbstractFileByPath(transcriptPath);
if (existingFile instanceof TFile) {
notice.stop();
notice.update(
`Transcript already exists - skipped (${transcriptPath}).`,
);
return;
}

notice.update("Downloading episode...");
const downloadPath = await downloadEpisode(
currentEpisode,
episode,
this.plugin.settings.download.path,
);
const podcastFile =
Expand All @@ -127,16 +182,17 @@ export class TranscriptionService {
const transcription = await this.transcribeChunks(files, notice.update);

notice.update("Saving transcription...");
await this.saveTranscription(currentEpisode, transcription);
await this.saveTranscription(episode, transcription);

notice.stop();
notice.update("Transcription completed and saved.");
} catch (error) {
console.error("Transcription error:", error);
const message = error instanceof Error ? error.message : String(error);
notice.stop();
notice.update(`Transcription failed: ${message}`);
} finally {
this.isTranscribing = false;
notice.stop();
setTimeout(() => notice.hide(), 5000);
}
}
Expand Down
Loading