diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md index a4fa4974..e55aafd2 100644 --- a/docs/docs/podcasts.md +++ b/docs/docs/podcasts.md @@ -54,6 +54,16 @@ The new order is saved automatically and restored the next time you open Obsidia Episodes in the queue are kept unique by title, so adding an episode that is already queued will not create a duplicate. +## Turning off the queue +By default the queue fills itself: when you switch to a new episode, the one you were listening to is kept at the top of the queue, and playback continues with the next queued episode when the current one ends. + +If you would rather manage the queue yourself, turn off **Keep a queue of episodes you switch away from** in PodNotes settings. With it off: + +- episodes are no longer added to the queue automatically when you switch, and +- playback no longer advances to the next queued episode on its own. + +You can still add, remove, and reorder episodes in the queue manually from the episode context menu. While the queue is empty in this mode, its tile in the podcast grid and its list in the player are hidden. + ## Player The player will automatically load and play the current episode. diff --git a/src/constants.ts b/src/constants.ts index 09838bc4..b18414e1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -51,6 +51,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { ...QUEUE_SETTINGS, episodes: [], }, + autoQueue: true, playlists: {}, skipBackwardLength: 15, skipForwardLength: 15, diff --git a/src/store/index.test.ts b/src/store/index.test.ts index c77cd5ee..30f36deb 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -1,5 +1,5 @@ import { get } from "svelte/store"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Episode } from "src/types/Episode"; import type { IPodNotes } from "src/types/IPodNotes"; @@ -13,6 +13,7 @@ import { downloadedEpisodes, localFiles, playedEpisodes, + plugin, queue, reorderEpisodes, } from "./index"; @@ -444,3 +445,63 @@ describe("queue store", () => { } }); }); + +describe("queue automation toggle (issue #108)", () => { + beforeEach(() => { + queue.set({ ...QUEUE_SETTINGS, episodes: [] }); + currentEpisode.set(undefined as unknown as Episode, false); + }); + + afterEach(() => { + // Reset the shared module singletons so other suites keep the default + // (enabled) behavior that an unset plugin store implies. + plugin.set(undefined as never); + currentEpisode.set(undefined as unknown as Episode, false); + }); + + function setAutoQueue(value: boolean) { + plugin.set({ settings: { autoQueue: value } } as never); + } + + test("enqueues the episode you switch away from when enabled", () => { + setAutoQueue(true); + currentEpisode.set(ep("A")); + currentEpisode.set(ep("B")); + + expect(queueTitles()).toEqual(["A"]); + }); + + test("does not enqueue the previous episode when disabled", () => { + setAutoQueue(false); + currentEpisode.set(ep("A")); + currentEpisode.set(ep("B")); + + expect(queueTitles()).toEqual([]); + }); + + test("treats an unset plugin store as enabled (historical default)", () => { + currentEpisode.set(ep("A")); + currentEpisode.set(ep("B")); + + expect(queueTitles()).toEqual(["A"]); + }); + + test("playNext advances to the front of the queue when enabled", () => { + setAutoQueue(true); + setQueue("A", "B"); + + queue.playNext(); + + expect(get(currentEpisode)?.title).toBe("A"); + expect(queueTitles()).toEqual(["B"]); + }); + + test("playNext is a no-op when disabled", () => { + setAutoQueue(false); + setQueue("A", "B"); + + queue.playNext(); + + expect(queueTitles()).toEqual(["A", "B"]); + }); +}); diff --git a/src/store/index.ts b/src/store/index.ts index 6a94bea3..40972ad6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -525,6 +525,11 @@ export const queue = (() => { }); }, playNext: () => { + // Auto-advance is part of queue automation (issue #108): when the user + // has turned the queue off, finishing an episode must not pull the next + // one in. The manual queue is left intact for when they re-enable it. + if (!autoQueueEnabled()) return; + update((queue) => { const nextEp = queue.episodes.shift(); @@ -663,7 +668,21 @@ export const viewState = (() => { }; })(); +/** + * Whether the queue's automatic behavior is enabled (issue #108). Gates both + * auto-population (enqueuing the episode you switch away from) and auto-advance + * (queue.playNext on episode end). Reads the live plugin setting on each call so + * toggling takes effect without a reload. Defaults to enabled so a missing + * setting or an uninitialised plugin store preserves the historical behavior. + */ +function autoQueueEnabled(): boolean { + return get(plugin)?.settings?.autoQueue !== false; +} + function addEpisodeToQueue(episode: Episode) { + // Gate at the operation, not the call site, so no future caller can bypass it. + if (!autoQueueEnabled()) return; + queue.update((playlist) => { // Keep the queue title-unique: a previously-played episode that is already // queued stays in place rather than being re-prepended. diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 70b80dc3..08afe596 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -16,6 +16,13 @@ export interface IPodNotesSettings { skipForwardLength: number; playlists: { [playlistName: string]: Playlist }; queue: Playlist; + /** + * Queue automation (issue #108). When `true` (default) switching episodes + * keeps the one you left at the top of the queue and playback auto-advances to + * the next queued episode. When `false` the queue stops filling and advancing + * on its own; it remains usable as a manual playlist. + */ + autoQueue: boolean; favorites: Playlist; localFiles: Playlist; currentEpisode?: Episode; diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index eb410ac8..5f847156 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -48,6 +48,12 @@ //#endregion const clampVolume = (value: number): number => Math.min(1, Math.max(0, value)); + // Hide the player's Queue list only when queue automation is off AND the queue + // is empty (issue #108) — otherwise show it (default-on state or a non-empty + // manual queue). + $: showQueue = + $plugin?.settings?.autoQueue !== false || $queue.episodes.length > 0; + let isHoveringArtwork: boolean = false; let isLoading: boolean = true; let playerVolume: number = 1; @@ -338,17 +344,19 @@ on:seek={onChapterSeek} /> - - -

Queue

-
-
+ {#if showQueue} + + +

Queue

+
+
+ {/if} diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 9ab51d8a..e118a6fe 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -72,8 +72,16 @@ onMount(() => { const updateDisplayedPlaylists = () => { const customPlaylists = Object.values(get(playlists)); + const queueValue = get(queue); + // Hide the Queue tile only when queue automation is off AND the queue is + // empty (issue #108), so turning the queue off doesn't leave a permanent + // empty "Queue" tile. The default-on state and a non-empty manual queue + // both keep it visible. + const showQueue = + get(plugin)?.settings?.autoQueue !== false || + queueValue.episodes.length > 0; displayedPlaylists = [ - get(queue), + ...(showQueue ? [queueValue] : []), get(favorites), get(localFiles), getPlayedPlaylist(), @@ -86,6 +94,9 @@ queue.subscribe(updateDisplayedPlaylists), favorites.subscribe(updateDisplayedPlaylists), localFiles.subscribe(updateDisplayedPlaylists), + // Recompute when the plugin store re-emits so toggling the autoQueue + // setting hides/shows the empty Queue tile immediately (issue #108). + plugin.subscribe(updateDisplayedPlaylists), playedEpisodes.subscribe(() => { updateDisplayedPlaylists(); if (isShowingPlayedEpisodes) { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 7db6bec1..83e98613 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -22,6 +22,7 @@ import { hidePlayedEpisodes, localFiles, playlists, + plugin, queue, savedFeeds, volume, @@ -82,6 +83,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { target: playlistManagerContainer, }); + this.addQueueSettings(settingsContainer); this.addDefaultPlaybackRateSetting(settingsContainer); this.addDefaultVolumeSetting(settingsContainer); this.addSkipLengthSettings(settingsContainer); @@ -104,6 +106,25 @@ export class PodNotesSettingsTab extends PluginSettingTab { } } + private addQueueSettings(container: HTMLElement): void { + new Setting(container) + .setName("Keep a queue of episodes you switch away from") + .setDesc( + "When on, the episode you switch away from is kept at the top of the queue and playback automatically continues with the next queued episode when one ends. Turn this off to stop the queue from filling and advancing on its own — you can still add episodes to the queue manually.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.autoQueue) + .onChange(async (value) => { + this.plugin.settings.autoQueue = value; + await this.plugin.saveSettings(); + // Re-emit the plugin store so an open player/grid recomputes + // the Queue tile/list visibility immediately (issue #108). + plugin.set(this.plugin); + }), + ); + } + private addDefaultPlaybackRateSetting(container: HTMLElement): void { new Setting(container) .setName("Default Playback Rate") @@ -627,6 +648,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { volume.set(Math.min(1, Math.max(0, importedVolume))); await this.plugin.saveSettings(); + // Re-emit the plugin store so an open player/grid recomputes Queue tile/list + // visibility (and any other $plugin-derived UI) after an import, mirroring the + // autoQueue toggle. Today the queue.set above already triggers that recompute; + // this keeps the import path correct independent of that incidental emission + // (issue #108). + plugin.set(this.plugin); this.display(); new Notice("Imported PodNotes settings."); }