diff --git a/src/API/API.ts b/src/API/API.ts index 659cfef..c39811d 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -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); @@ -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. diff --git a/src/API/IAPI.ts b/src/API/IAPI.ts index 48f3412..ac68698 100644 --- a/src/API/IAPI.ts +++ b/src/API/IAPI.ts @@ -5,6 +5,7 @@ export interface IAPI { readonly isPlaying: boolean; readonly length: number; currentTime: number; + volume: number; getPodcastTimeFormatted( format: string, diff --git a/src/constants.ts b/src/constants.ts index 5d51db2..315a8c3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,6 +33,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { savedFeeds: {}, podNotes: {}, defaultPlaybackRate: 1, + defaultVolume: 1, + hidePlayedEpisodes: false, playedEpisodes: {}, favorites: { ...FAVORITES_SETTINGS, diff --git a/src/main.ts b/src/main.ts index 7955843..87f0d5f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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"; @@ -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"; @@ -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; @@ -64,10 +68,16 @@ export default class PodNotes extends Plugin implements IPodNotes { private downloadedEpisodesController?: StoreController<{ [podcastName: string]: DownloadedEpisode[]; }>; + private hidePlayedEpisodesController?: StoreController; 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 = Promise.resolve(); override async onload() { plugin.set(this); @@ -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, @@ -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", @@ -291,6 +324,8 @@ export default class PodNotes extends Plugin implements IPodNotes { ); this.registerEvent(getContextMenuHandler(this.app)); + + this.isReady = true; } onLayoutReady(): void { @@ -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() { @@ -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; } } diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 87d8b83..2679324 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -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(); constructor(plugin: PodNotes) { this.plugin = plugin; } async transcribeCurrentEpisode(): Promise { - 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.", @@ -81,7 +78,6 @@ export class TranscriptionService { return; } - // Check if transcription file already exists const transcriptPath = FilePathTemplateEngine( this.plugin.settings.transcript.path, currentEpisode, @@ -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 { + 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 = @@ -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); } } diff --git a/src/store/index.ts b/src/store/index.ts index 2acee09..d42dcd2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import { get, writable } from "svelte/store"; +import { get, readable, writable } from "svelte/store"; import type PodNotes from "src/main"; import type { Episode } from "src/types/Episode"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; @@ -12,6 +12,8 @@ import type { LocalEpisode } from "src/types/LocalEpisode"; export const plugin = writable(); export const currentTime = writable(0); export const duration = writable(0); +export const volume = writable(1); +export const hidePlayedEpisodes = writable(false); export const currentEpisode = (() => { const store = writable(); @@ -101,6 +103,170 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({}); export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({}); +const LATEST_EPISODES_PER_FEED = 10; + +type LatestEpisodesByFeed = Map; +type FeedEpisodeSources = Map; + +function getEpisodeTimestamp(episode?: Episode): number { + if (!episode?.episodeDate) return 0; + + return Number(episode.episodeDate); +} + +function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] { + if (!episodes?.length) return []; + + return episodes + .slice(0, LATEST_EPISODES_PER_FEED) + .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)); +} + +function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean { + if (!a || !b || a.length !== b.length) return false; + + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + + return true; +} + +const latestEpisodeIdentifier = (episode: Episode): string => + `${episode.podcastName}::${episode.title}`; + +function insertEpisodeSorted( + episodes: Episode[], + episodeToInsert: Episode, + limit: number, +): Episode[] { + const nextEpisodes = [...episodes]; + const value = getEpisodeTimestamp(episodeToInsert); + let low = 0; + let high = nextEpisodes.length; + + while (low < high) { + const mid = (low + high) >> 1; + const midValue = getEpisodeTimestamp(nextEpisodes[mid]); + + if (value > midValue) { + high = mid; + } else { + low = mid + 1; + } + } + + nextEpisodes.splice(low, 0, episodeToInsert); + + if (nextEpisodes.length > limit) { + nextEpisodes.length = limit; + } + + return nextEpisodes; +} + +function removeFeedEntries( + currentLatest: Episode[], + feedEpisodes: Episode[] | undefined = [], +): Episode[] { + if (!feedEpisodes?.length) { + return currentLatest; + } + + const feedKeys = new Set(feedEpisodes.map(latestEpisodeIdentifier)); + + return currentLatest.filter( + (episode) => !feedKeys.has(latestEpisodeIdentifier(episode)), + ); +} + +function updateLatestEpisodesForFeed( + currentLatest: Episode[], + previousFeedEpisodes: Episode[] | undefined, + nextFeedEpisodes: Episode[] | undefined, + limit: number, +): Episode[] { + let nextLatest = removeFeedEntries(currentLatest, previousFeedEpisodes); + + if (!nextFeedEpisodes?.length) { + return nextLatest; + } + + for (const episode of nextFeedEpisodes) { + nextLatest = insertEpisodeSorted(nextLatest, episode, limit); + } + + return nextLatest; +} + +export const latestEpisodes = readable([], (set) => { + let latestByFeed: LatestEpisodesByFeed = new Map(); + let feedSources: FeedEpisodeSources = new Map(); + let mergedLatest: Episode[] = []; + + const unsubscribe = episodeCache.subscribe((cache) => { + const cacheEntries = Object.entries(cache); + const feedCount = cacheEntries.length; + const latestLimit = Math.max( + 1, + LATEST_EPISODES_PER_FEED * Math.max(feedCount, 1), + ); + + let changed = false; + let nextMerged = mergedLatest; + const nextSources: FeedEpisodeSources = new Map(); + const nextLatestByFeed: LatestEpisodesByFeed = new Map(); + + for (const [feedTitle, episodes] of cacheEntries) { + nextSources.set(feedTitle, episodes); + const previousSource = feedSources.get(feedTitle); + const previousLatest = latestByFeed.get(feedTitle) || []; + + const nextLatestForFeed = + previousSource === episodes && previousLatest + ? previousLatest + : getLatestEpisodesForFeed(episodes); + + nextLatestByFeed.set(feedTitle, nextLatestForFeed); + + if (!shallowEqualEpisodes(previousLatest, nextLatestForFeed)) { + changed = true; + nextMerged = updateLatestEpisodesForFeed( + nextMerged, + previousLatest, + nextLatestForFeed, + latestLimit, + ); + } + } + + for (const feedTitle of latestByFeed.keys()) { + if (!nextSources.has(feedTitle)) { + changed = true; + nextMerged = removeFeedEntries( + nextMerged, + latestByFeed.get(feedTitle), + ); + } + } + + feedSources = nextSources; + latestByFeed = nextLatestByFeed; + + if (changed) { + mergedLatest = nextMerged; + set(mergedLatest); + } + }); + + return () => { + latestByFeed.clear(); + feedSources.clear(); + mergedLatest = []; + unsubscribe(); + }; +}); + export const downloadedEpisodes = (() => { const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({}); const { subscribe, update, set } = store; diff --git a/src/store_controllers/HidePlayedEpisodesController.ts b/src/store_controllers/HidePlayedEpisodesController.ts new file mode 100644 index 0000000..7f6736d --- /dev/null +++ b/src/store_controllers/HidePlayedEpisodesController.ts @@ -0,0 +1,19 @@ +import type { Writable } from "svelte/store"; +import type { IPodNotes } from "../types/IPodNotes"; +import { StoreController } from "../types/StoreController"; + +export class HidePlayedEpisodesController extends StoreController { + private plugin: IPodNotes; + + constructor(store: Writable, plugin: IPodNotes) { + super(store); + this.plugin = plugin; + } + + protected override onChange(value: boolean) { + if (this.plugin.settings.hidePlayedEpisodes === value) return; + + this.plugin.settings.hidePlayedEpisodes = value; + this.plugin.saveSettings(); + } +} diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 78802cf..70b80dc 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -9,6 +9,8 @@ export interface IPodNotesSettings { savedFeeds: { [podcastName: string]: PodcastFeed }; podNotes: { [episodeName: string]: PodNote }; defaultPlaybackRate: number; + defaultVolume: number; + hidePlayedEpisodes: boolean; playedEpisodes: { [episodeName: string]: PlayedEpisode }; skipBackwardLength: number; skipForwardLength: number; diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index 3fa7312..16a418c 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -1,15 +1,16 @@ @@ -57,12 +82,13 @@ .podcast-episode-item { display: flex; flex-direction: row; - justify-content: space-between; - align-items: center; + justify-content: flex-start; + align-items: flex-start; padding: 0.5rem; + min-height: 5rem; width: 100%; border: solid 1px var(--background-divider); - gap: 0.25rem; + gap: 0.75rem; background: transparent; text-align: left; } @@ -84,8 +110,9 @@ display: flex; flex-direction: column; justify-content: space-between; - align-items: left; - width: 100%; + align-items: flex-start; + flex: 1 1 auto; + min-width: 0; } .episode-item-date { @@ -93,16 +120,24 @@ } .podcast-episode-thumbnail-container { - flex-basis: 20%; + flex: 0 0 5rem; + width: 5rem; + height: 5rem; + max-width: 5rem; + max-height: 5rem; display: flex; align-items: center; justify-content: center; + background: var(--background-secondary); + border-radius: 15%; + overflow: hidden; } :global(.podcast-episode-thumbnail) { + width: 100%; + height: 100%; + object-fit: cover; border-radius: 15%; - max-width: 5rem; - max-height: 5rem; cursor: pointer !important; } diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 63b021a..162b7da 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -5,6 +5,7 @@ currentEpisode, isPaused, plugin, + volume, playedEpisodes, queue, playlists, @@ -36,9 +37,11 @@ const offBinding = new CircumentForcedTwoWayBinding(); //#endregion + const clampVolume = (value: number): number => Math.min(1, Math.max(0, value)); let isHoveringArtwork: boolean = false; let isLoading: boolean = true; + let playerVolume: number = 1; function togglePlayback() { isPaused.update((value) => !value); @@ -84,6 +87,12 @@ offBinding.playbackRate = event.detail.value; } + function onVolumeChange(event: CustomEvent<{ value: number }>) { + const newVolume = clampVolume(event.detail.value); + + volume.set(newVolume); + } + function onMetadataLoaded() { isLoading = false; @@ -105,16 +114,7 @@ let srcPromise: Promise = getSrc($currentEpisode); - // #region Keep player time and currentTime in sync - // Simply binding currentTime to the audio element will result in resets. - // Hence the following solution. - let playerTime: number = 0; - onMount(() => { - const unsub = currentTime.subscribe((ct) => { - playerTime = ct; - }); - // This only happens when the player is open and the user downloads the episode via the context menu. // So we want to update the source of the audio element to local file / online stream. const unsubDownloadedSource = downloadedEpisodes.subscribe(_ => { @@ -125,18 +125,17 @@ srcPromise = getSrc($currentEpisode); }); + const unsubVolume = volume.subscribe((value) => { + playerVolume = clampVolume(value); + }); + return () => { - unsub(); unsubDownloadedSource(); unsubCurrentEpisode(); + unsubVolume(); }; }); - $: { - currentTime.set(playerTime); - } - // #endregion - onDestroy(() => { playedEpisodes.setEpisodeTime($currentEpisode, $currentTime, $duration, ($currentTime === $duration)); isPaused.set(true); @@ -204,9 +203,7 @@ {:else}
@@ -220,9 +217,10 @@