Skip to content

Commit 5bb1f54

Browse files
committed
feat: synced snapshots feature, sync playback time of current track
1 parent 909dcbc commit 5bb1f54

File tree

19 files changed

+868
-120
lines changed

19 files changed

+868
-120
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
This documents the user-facing changes for each version
44

5+
## 0.7
6+
7+
- Add synced snapshot mode (disables automatic snapshots while active)
8+
- Manual and synced snapshots now include the playback position of the current track
9+
- Improve extension metadata, readme and screenshots
10+
511
## 0.6.4
612

713
- Shorten queue warning description

README.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,61 @@
22

33
<h1>Queue Manager</h1>
44

5-
Do you hate it when Spotify randomly decides your queue is gone — or just want to manage queues more easily? Spicetify Queue Manager snapshots your current queue (including local files), lets you restore it later, and export snapshots to playlists.
5+
This extension probably serves a kinda niche purpose but if you are like me and always queue a bunch of songs, swap them around, want to play a different genre or mood but still retain the same queue for later, this is for you.
6+
7+
It also helps when Spotify forgets your queue or reshuffles it unexpectedly.
8+
9+
With this extension you can manage queues more easily. It snapshots your current queue, lets you restore it later, and export snapshots to playlists.
10+
11+
This can be done by:
12+
13+
- Automatically capturing snapshots on a schedule or when the queue changes.
14+
- Manually creating snapshots
15+
- Creating synced snapshots that automatically stay in sync with your queue, allowing you to switch between different ones and have your queue instantly replaced with the one from the snapshot (including playback position).
16+
17+
The extension adds an icon to the topbar right side of the player. Clicking it opens a modal with all the snapshots and allows you to manage settings for customizing the behavior of the extension. (see below)
18+
There is also a hotkey of `Ctrl+Alt+Q` to open it.
19+
20+
# Screenshots
21+
622

723
| | |
824
|---|---|
925
| <img height="750" alt="image" src="https://github.com/user-attachments/assets/f0802551-2acd-4bcd-8f8d-2bb33ef3e95a" /> | <img width="488" height="499" alt="image" src="https://github.com/user-attachments/assets/114a00cc-4571-41fc-9f62-3d8b139318d3" /> |
1026
| Main interface | Settings |
1127

28+
1229
</div>
1330

1431
## Status
15-
🚧 Beta. Core features are implemented and undergoing polish.
32+
33+
🚧 Late Beta. Core features are implemented and undergoing polish. Beta testing would be appreciated.
1634

1735
For changes, see [CHANGELOG.md](https://github.com/D3SOX/spicetify-queue-manager/blob/master/CHANGELOG.md)
1836

1937
## Installation
38+
2039
- Open the [Spicetify Marketplace](https://github.com/spicetify/marketplace/wiki/Installation).
2140
- Search for "Queue Manager" (You might have to click on "Load more" first)
2241
- Install & reload
2342

24-
## What it does
25-
- Save your current queue as named snapshots
26-
- Automatically create snapshots on a schedule or when the queue changes
27-
- Restore a snapshot to replace your current queue
28-
- Export any snapshot to a playlist
29-
- Manage all snapshots in a simple queue manager UI
30-
- Works with local files (not only the Web API)
31-
32-
## Why
33-
Spotify can sometimes clear or reshuffle your queue unexpectedly. This extension preserves your listening state by periodically recording `Spicetify.Queue` and providing quick restore/export actions.
34-
3543
## Development
36-
Requires Spicetify CLI and pnpm.
44+
45+
Requires [Spicetify CLI](https://spicetify.app) and [pnpm](https://pnpm.io).
3746

3847
```bash
3948
pnpm install
40-
pnpm watch # or: pnpm build
49+
pnpm build # or: pnpm watch
50+
spicetify extensions queue-manager.js
4151
spicetify apply -n
4252
```
4353

4454
This project is built with [Spicetify Creator](https://spicetify.app/docs/development/spicetify-creator)
4555

4656
## Notes
47-
- If something looks off, open DevTools and inspect `Spicetify.Queue` to help debug.
57+
58+
- If something looks off, open DevTools and inspect the console log any errors and send the contents of `Spicetify.Queue` to help debug.
4859

4960
## License
61+
5062
[GNU General Public License v3.0](./LICENSE)

manifest.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
{
33
"name": "Queue Manager",
44
"description": "Save snapshots of your queue, restore them later, export to playlists and more",
5-
"preview": "https://github.com/user-attachments/assets/7da407d1-26ec-459b-8105-4fb9b62c87ef",
5+
"preview": "https://github.com/user-attachments/assets/f0802551-2acd-4bcd-8f8d-2bb33ef3e95a",
66
"main": "dist/queue-manager.js",
7+
"authors": [
8+
{ "name": "D3SOX", "url": "https://github.com/D3SOX" }
9+
],
710
"readme": "README.md",
811
"tags": [
912
"queue",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "queue-manager",
3-
"version": "0.6.4",
3+
"version": "0.7",
44
"private": true,
55
"scripts": {
66
"build": "spicetify-creator",

src/auto.ts

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Snapshot, Settings, AutoMode, QueueUpdateEvent } from "./types";
1+
import { Snapshot, Settings, AutoMode, QueueUpdateEvent, OnProgressEvent } from "./types";
22
import { getQueueFromSpicetify } from "./queue";
33
import { areQueuesEqual, generateId } from "./utils";
4-
import { addSnapshot, getSortedSnapshots, loadSnapshots, pruneAutosToMax as storagePruneAutosToMax } from "./storage";
4+
import { addSnapshot, getSortedSnapshots, loadSnapshots, saveSnapshots, saveSettings } from "./storage";
55
import { APP_NAME } from "./appInfo";
66
import { showErrorToast, showWarningToast } from "./toast";
77
import { t } from "./i18n";
@@ -24,7 +24,7 @@ export function createAutoManager(getSettings: () => Settings) {
2424
async function runSnapshotIfChanged(s: Settings) {
2525
try {
2626
if (!s.autoEnabled) return;
27-
const currentItems = await getQueueFromSpicetify();
27+
const currentItems = getQueueFromSpicetify();
2828
if (!currentItems.length) {
2929
console.log(`${APP_NAME}: queue is empty, skipping auto snapshot`);
3030
return;
@@ -84,17 +84,11 @@ export function createAutoManager(getSettings: () => Settings) {
8484
if (!s.autoEnabled) return;
8585

8686
try {
87-
type EventFunc = (event: string, callback: (...args: any[]) => void) => void;
88-
const events: {
89-
addListener?: EventFunc;
90-
removeListener: EventFunc;
91-
} | undefined = (Spicetify as any)?.Player?.origin?._events;
87+
const events = Spicetify?.Player?.origin?._events;
9288
if (!events?.addListener || !events?.removeListener) {
93-
throw new Error("queue_update events API not available");
94-
}
95-
96-
97-
const onQueueUpdate = (evt: QueueUpdateEvent) => {
89+
throw new Error("events API not available");
90+
}
91+
const onQueueUpdate: (event?: QueueUpdateEvent) => void = (evt) => {
9892
runSnapshotIfChanged(s);
9993
//console.debug(`${APP_NAME}: queue_update`, evt);
10094
};
@@ -165,7 +159,7 @@ export function createQueueCapacityWatcher(getSettings: () => Settings) {
165159
const threshold = s.queueWarnThreshold;
166160
if (threshold < 0 || maxSize <= 1) return;
167161

168-
const items = await getQueueFromSpicetify();
162+
const items = getQueueFromSpicetify();
169163
const currentSize = items.length;
170164

171165
// Skip if queue size hasn't increased
@@ -201,19 +195,16 @@ export function createQueueCapacityWatcher(getSettings: () => Settings) {
201195
function start(): void {
202196
stop();
203197
try {
204-
type EventFunc = (event: string, callback: (...args: any[]) => void) => void;
205-
const events: {
206-
addListener?: EventFunc;
207-
removeListener: EventFunc;
208-
} | undefined = (Spicetify as any)?.Player?.origin?._events;
198+
const events = Spicetify?.Player?.origin?._events;
209199
if (!events?.addListener || !events?.removeListener) {
210200
throw new Error("queue_update events API not available");
211201
}
212202

213-
const onQueueUpdate = (_evt: QueueUpdateEvent) => {
203+
const onQueueUpdate: (event?: QueueUpdateEvent) => void = (_evt) => {
214204
checkAndWarnOnce();
215205
};
216206
events.addListener("queue_update", onQueueUpdate);
207+
217208
unsubscribe = () => {
218209
try { events.removeListener("queue_update", onQueueUpdate); } catch (e) {
219210
console.error(`${APP_NAME}: failed to remove queue_update listener (capacity watcher)`, e);
@@ -228,4 +219,137 @@ export function createQueueCapacityWatcher(getSettings: () => Settings) {
228219
}
229220

230221
return { start, stop, checkAndWarnOnce };
222+
}
223+
224+
export function createQueueSyncManager(getSettings: () => Settings) {
225+
let unsubscribe: (() => void) | null = null;
226+
let lastKnownQueue: string[] = [];
227+
let isActive = false;
228+
let isSuspended = false;
229+
230+
async function updatePlaybackPosition(progressMs: number) {
231+
const s = getSettings();
232+
if (!s.syncedSnapshotId || !isActive) return;
233+
234+
const snapshots = loadSnapshots();
235+
const idx = snapshots.findIndex(snap => snap.id === s.syncedSnapshotId);
236+
if (idx >= 0) {
237+
snapshots[idx].playbackPosition = progressMs;
238+
saveSnapshots(snapshots);
239+
}
240+
}
241+
242+
async function syncQueueToSnapshot(currentQueue: string[]) {
243+
const s = getSettings();
244+
if (!s.syncedSnapshotId) return;
245+
246+
const snapshots = loadSnapshots();
247+
const idx = snapshots.findIndex(snap => snap.id === s.syncedSnapshotId);
248+
if (idx >= 0) {
249+
// Don't sync empty queues to prevent losing the snapshot content during queue replacements
250+
if (currentQueue.length === 0) {
251+
console.debug(`${APP_NAME}: skipping sync of empty queue to snapshot`);
252+
return;
253+
}
254+
snapshots[idx].items = currentQueue.slice();
255+
snapshots[idx].playbackPosition = Spicetify.Player.getProgress();
256+
saveSnapshots(snapshots);
257+
} else {
258+
// Synced snapshot no longer exists
259+
console.warn(`${APP_NAME}: synced snapshot ${s.syncedSnapshotId} no longer exists, deactivating sync mode`);
260+
const newSettings = { ...s, syncedSnapshotId: undefined };
261+
saveSettings(newSettings);
262+
263+
showWarningToast(t('toasts.syncedSnapshotNotFound'));
264+
}
265+
}
266+
267+
async function handleQueueUpdate() {
268+
try {
269+
const s = getSettings();
270+
if (!s.syncedSnapshotId || !isActive || isSuspended) return;
271+
272+
const currentQueue = getQueueFromSpicetify();
273+
if (!areQueuesEqual(currentQueue, lastKnownQueue)) {
274+
await syncQueueToSnapshot(currentQueue);
275+
lastKnownQueue = currentQueue.slice();
276+
}
277+
} catch (e) {
278+
console.error(`${APP_NAME}: queue sync error`, e);
279+
}
280+
}
281+
282+
function stop(): void {
283+
if (unsubscribe) {
284+
try { unsubscribe(); } catch {}
285+
unsubscribe = null;
286+
}
287+
isActive = false;
288+
isSuspended = false;
289+
lastKnownQueue = [];
290+
}
291+
292+
function start(): void {
293+
stop();
294+
const s = getSettings();
295+
if (!s.syncedSnapshotId) return;
296+
297+
try {
298+
const events = Spicetify?.Player?.origin?._events;
299+
if (!events?.addListener || !events?.removeListener) {
300+
throw new Error("events API not available");
301+
}
302+
303+
const onQueueUpdate: (evt?: QueueUpdateEvent) => void = (_evt) => {
304+
handleQueueUpdate();
305+
};
306+
const onProgress = (evt?: Event) => {
307+
const event = evt as OnProgressEvent;
308+
updatePlaybackPosition(event.data);
309+
};
310+
311+
events.addListener("queue_update", onQueueUpdate);
312+
Spicetify.Player.addEventListener("onprogress", onProgress);
313+
unsubscribe = () => {
314+
try {
315+
events.removeListener("queue_update", onQueueUpdate);
316+
Spicetify.Player.removeEventListener("onprogress", onProgress);
317+
} catch (e) {
318+
console.error(`${APP_NAME}: failed to remove listeners (sync manager)`, e);
319+
}
320+
};
321+
322+
isActive = true;
323+
324+
// Initialize last known queue
325+
lastKnownQueue = getQueueFromSpicetify();
326+
} catch (e) {
327+
console.error(`${APP_NAME}: failed to start queue sync manager`, e);
328+
showErrorToast(t('toasts.failedToStartSyncManager'));
329+
}
330+
}
331+
332+
function applySync(newSettings: Settings): void {
333+
const shouldBeActive = !!newSettings.syncedSnapshotId;
334+
335+
if (shouldBeActive && !isActive) {
336+
start();
337+
} else if (!shouldBeActive && isActive) {
338+
stop();
339+
}
340+
}
341+
342+
function suspend(): void {
343+
isSuspended = true;
344+
console.debug(`${APP_NAME}: sync manager suspended`);
345+
}
346+
347+
async function resume(): Promise<void> {
348+
isSuspended = false;
349+
// After resuming, capture the current queue state so we don't sync the queue replacement
350+
lastKnownQueue = getQueueFromSpicetify();
351+
console.debug(`${APP_NAME}: sync manager resumed`);
352+
}
353+
354+
return { start, stop, applySync, suspend, resume };
231355
}

0 commit comments

Comments
 (0)