From 81622f70e6e4d3108df3db24b1faa20c343c6017 Mon Sep 17 00:00:00 2001 From: oniivan Date: Tue, 28 Jul 2026 13:11:42 -0700 Subject: [PATCH 1/2] Restore current-track highlighting --- README.md | 9 ++- gem-sort.js | 166 ++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- test/gem-sort.test.cjs | 77 +++++++++++++++++++ 4 files changed, 240 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2e4f2fc..38f3a9f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ alone, and keeps the rest of Spotify's layout familiar. - Keeps ties in Spotify's original order and places unavailable values last. - Starts new playback from an active sorted view with Shuffle off, so Next and Previous follow the displayed sort. +- Keeps the currently playing track title highlighted in Spotify green while + that temporary sorted playback context is active, including duplicate-track + occurrences. - Keeps native drag-out behavior for adding tracks to another playlist while blocking reordering drops inside the temporarily sorted source playlist. - Retains Date added when the playlist has meaningful dates, but hides the @@ -77,8 +80,10 @@ While the sort is active: would make the temporary view order ambiguous. - Starting playback from the playlist hands Spotify a temporary in-memory context containing the displayed order and starts that context with Shuffle - off. Next and Previous then follow the sort; Spotify's manual queue can still - take precedence, and Shuffle can be turned on again afterward. + off. Next and Previous then follow the sort, and the matching visible title + remains highlighted using its exact playlist occurrence ID. Spotify's manual + queue can still take precedence, and Shuffle can be turned on again + afterward. - Sorting alone does not alter current playback. Clearing or changing the view sort does not interrupt a playback context that has already started. diff --git a/gem-sort.js b/gem-sort.js index 8d7cab2..46eacad 100644 --- a/gem-sort.js +++ b/gem-sort.js @@ -24,7 +24,7 @@ })(typeof window !== "undefined" ? window : null, function createStreamRankApi() { "use strict"; - const VERSION = "0.5.2"; + const VERSION = "0.5.3"; const GLOBAL_KEY = "__spotifyGemSort"; const STYLE_ID = "spotify-gem-sort-style"; const GRID_SELECTOR = @@ -468,6 +468,10 @@ null; return { + uid: + typeof item.uid === "string" && item.uid + ? item.uid + : null, trackUri: item.uri, trackName: typeof item.name === "string" ? item.name : "", durationMs: toDurationMs( @@ -650,6 +654,69 @@ : null; } + function getPlayerContextUri(playerData) { + return ( + getPlaybackContextUri(playerData?.context_uri) || + getPlaybackContextUri(playerData?.context) + ); + } + + function getPlaybackContextToken(originalUri) { + return typeof originalUri === "string" + ? originalUri.split(":").at(-1)?.replace(/[^A-Za-z0-9]/g, "") || "" + : ""; + } + + function isGemSortPlaybackContext(contextUri, originalUri) { + const originalToken = getPlaybackContextToken(originalUri); + return ( + Boolean(originalToken) && + typeof contextUri === "string" && + contextUri.startsWith( + `spotify:internal:gem-sort:${originalToken}:`, + ) + ); + } + + function normalizePlaybackIdentity(item) { + const uri = + typeof item?.uri === "string" + ? item.uri + : typeof item?.trackUri === "string" + ? item.trackUri + : ""; + if (!uri) return null; + + return { + uri, + uid: + typeof item.uid === "string" && item.uid + ? item.uid + : null, + }; + } + + function findCurrentTrackIndex(items, currentItem) { + const current = normalizePlaybackIdentity(currentItem); + if (!current) return -1; + + const identities = (Array.isArray(items) ? items : []).map( + normalizePlaybackIdentity, + ); + if (current.uid) { + const uidMatch = identities.findIndex( + (item) => item?.uid === current.uid, + ); + if (uidMatch >= 0) return uidMatch; + + // If Spotify exposed occurrence IDs for these rows, a URI fallback + // could highlight the wrong copy of a duplicated playlist track. + if (identities.some((item) => item?.uid)) return -1; + } + + return identities.findIndex((item) => item?.uri === current.uri); + } + function findMethodOwner(value, methodName) { let current = value; while (current && typeof current === "object") { @@ -665,10 +732,7 @@ } function buildSortedPlaybackContext(items, originalUri, generation = 0) { - const originalToken = - typeof originalUri === "string" - ? originalUri.split(":").at(-1)?.replace(/[^A-Za-z0-9]/g, "") - : ""; + const originalToken = getPlaybackContextToken(originalUri); const generationToken = String(generation).replace(/[^A-Za-z0-9_-]/g, "") || "0"; const contextItems = (Array.isArray(items) ? items : []).flatMap( @@ -947,6 +1011,7 @@ let mutationObserver = null; let resizeObserver = null; let historyUnlisten = null; + let playerSongChangeListener = null; let reconcileTimer = null; let playCountBatchTimer = null; let cachePruneTimer = null; @@ -1211,6 +1276,15 @@ min-width: 0; opacity: 0.35; } + + [data-gem-sort-sort-active="true"] + [data-gem-sort-current-track="true"] + .main-trackList-rowMainContentTitle { + color: var( + --text-bright-accent, + var(--spice-button, #1ed760) + ) !important; + } `; } @@ -1496,6 +1570,7 @@ if (!trackUri) return null; return { + uid: null, trackUri, trackName: trackLink?.textContent?.trim() || "", durationMs: null, @@ -1513,6 +1588,44 @@ }; } + function clearCurrentTrackMarkers(grid) { + grid + ?.querySelectorAll?.("[data-gem-sort-current-track]") + .forEach((row) => { + delete row.dataset.gemSortCurrentTrack; + }); + } + + function syncCurrentTrackMarker(grid, rows, rowInfos) { + clearCurrentTrackMarkers(grid); + + const session = sortSession; + if ( + session?.grid !== grid || + getCurrentPath() !== session.path + ) { + return; + } + + const playerData = getSpicetify()?.Player?.data; + if ( + !isGemSortPlaybackContext( + getPlayerContextUri(playerData), + session.uri, + ) + ) { + return; + } + + const currentIndex = findCurrentTrackIndex( + rowInfos, + playerData?.item, + ); + if (currentIndex >= 0) { + rows[currentIndex].dataset.gemSortCurrentTrack = "true"; + } + } + function resetCell(cell, info) { cell.dataset.gemSortTrackUri = info?.trackUri || ""; cell.__streamRankState = { @@ -2851,6 +2964,7 @@ session.playerPlayHandle = null; session.methodHandle?.restore(); delete session.grid.dataset.gemSortSortActive; + clearCurrentTrackMarkers(session.grid); updateSortHeader(session.grid); if (invalidate && session.grid.isConnected) { invalidateSortedGrid(session.grid, scrollToTop); @@ -3154,7 +3268,9 @@ } renumberCells(row, "gridcell"); - populateCell(cell, extractRowInfo(row)); + const info = extractRowInfo(row); + populateCell(cell, info); + return info; } function reconcileGrid(grid) { @@ -3218,11 +3334,15 @@ hiddenColumnIndexes, ), ); - grid - .querySelectorAll(ROW_SELECTOR) - .forEach((row) => - ensureRow(row, replacementColumnIndex, hiddenColumnIndexes), - ); + const rows = Array.from(grid.querySelectorAll(ROW_SELECTOR)); + const rowInfos = rows.map((row) => + ensureRow( + row, + replacementColumnIndex, + hiddenColumnIndexes, + ), + ); + syncCurrentTrackMarker(grid, rows, rowInfos); if (resizeObserver && !observedGrids.has(grid)) { observedGrids.add(grid); @@ -3288,6 +3408,15 @@ document.addEventListener("input", handlePlaylistFilterInput, true); browserRoot.addEventListener("resize", scheduleReconcile); + const player = getSpicetify()?.Player; + if (typeof player?.addEventListener === "function") { + playerSongChangeListener = () => scheduleReconcile(0); + player.addEventListener( + "songchange", + playerSongChangeListener, + ); + } + try { historyUnlisten = getSpicetify()?.Platform?.History?.listen?.(() => { @@ -3370,6 +3499,7 @@ delete grid.__streamRankDataHasMeaningfulDates; delete grid.dataset.gemSortGrid; delete grid.dataset.gemSortSortActive; + clearCurrentTrackMarkers(grid); if (grid.__streamRankDropGuard) { grid.removeEventListener( @@ -3420,6 +3550,14 @@ ); browserRoot.removeEventListener("resize", scheduleReconcile); + if (playerSongChangeListener) { + getSpicetify()?.Player?.removeEventListener?.( + "songchange", + playerSongChangeListener, + ); + playerSongChangeListener = null; + } + try { if (typeof historyUnlisten === "function") historyUnlisten(); } catch { @@ -3468,6 +3606,9 @@ renderedCells: document.querySelectorAll( ".spotify-gem-sort-cell", ).length, + highlightedRows: document.querySelectorAll( + "[data-gem-sort-current-track=\"true\"]", + ).length, playCountCacheEntries: playCountCache.size, artistCacheEntries: artistCache.size, albumCacheEntries: albumCache.size, @@ -3553,6 +3694,7 @@ extractTrackCounts, extractTrackInfoFromProps, findTopTrackRecord, + findCurrentTrackIndex, fingerprintQueryOptions, findColumnTypeIndex, findMethodOwner, @@ -3560,9 +3702,11 @@ formatPlayCount, getArtistSortConcurrency, getPlaybackContextUri, + getPlayerContextUri, getCapturedPageOffset, buildPlaylistTemplate, insertTemplateColumn, + isGemSortPlaybackContext, isPlaylistPath, nextMetricSortState, normalizeTrackItem, diff --git a/package.json b/package.json index 5ddce50..8cf84bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spicetify-gem-sort", - "version": "0.5.2", + "version": "0.5.3", "private": true, "description": "Spotify-reported play counts and primary-artist Top 10 ranks in Spicetify playlist rows.", "scripts": { diff --git a/test/gem-sort.test.cjs b/test/gem-sort.test.cjs index 1e250b5..c9351a5 100644 --- a/test/gem-sort.test.cjs +++ b/test/gem-sort.test.cjs @@ -15,6 +15,7 @@ const { extractTrackCounts, extractTrackInfoFromProps, findColumnTypeIndex, + findCurrentTrackIndex, findMethodOwner, findTopTrackRecord, fingerprintQueryOptions, @@ -22,7 +23,9 @@ const { getArtistSortConcurrency, getCapturedPageOffset, getPlaybackContextUri, + getPlayerContextUri, insertTemplateColumn, + isGemSortPlaybackContext, isPlaylistPath, nextMetricSortState, normalizeTrackName, @@ -48,6 +51,7 @@ test("runtime dataset keys match the Gem Sort CSS attribute namespace", () => { ); [ "gemSortGrid", + "gemSortCurrentTrack", "gemSortHiddenColumn", "gemSortSortActive", "gemSortSortKey", @@ -55,6 +59,7 @@ test("runtime dataset keys match the Gem Sort CSS attribute namespace", () => { ].forEach((key) => assert.match(source, new RegExp(`dataset\\.${key}`))); [ "data-gem-sort-grid", + "data-gem-sort-current-track", "data-gem-sort-hidden-column", "data-gem-sort-sort-key", ].forEach((attribute) => assert.match(source, new RegExp(attribute))); @@ -518,6 +523,76 @@ test("playback context URI detection handles Spotify's supported shapes", () => "spotify:playlist:2222222222222222222222", ); assert.equal(getPlaybackContextUri(null), null); + assert.equal( + getPlayerContextUri({ + context: { + uri: "spotify:internal:gem-sort:playlist-id:17", + }, + }), + "spotify:internal:gem-sort:playlist-id:17", + ); + assert.equal( + getPlayerContextUri({ + context_uri: "spotify:playlist:1111111111111111111111", + context: { + uri: "spotify:playlist:2222222222222222222222", + }, + }), + "spotify:playlist:1111111111111111111111", + ); +}); + +test("Gem Sort playback contexts are scoped to the source playlist", () => { + const playlist = "spotify:playlist:1111111111111111111111"; + + assert.equal( + isGemSortPlaybackContext( + "spotify:internal:gem-sort:1111111111111111111111:42", + playlist, + ), + true, + ); + assert.equal( + isGemSortPlaybackContext( + "spotify:internal:gem-sort:2222222222222222222222:42", + playlist, + ), + false, + ); + assert.equal( + isGemSortPlaybackContext(playlist, playlist), + false, + ); +}); + +test("current-row matching prefers occurrence UIDs for duplicate tracks", () => { + const rows = [ + { trackUri: "spotify:track:same", uid: "occurrence-b" }, + { trackUri: "spotify:track:other", uid: "occurrence-c" }, + { trackUri: "spotify:track:same", uid: "occurrence-a" }, + ]; + + assert.equal( + findCurrentTrackIndex(rows, { + uri: "spotify:track:same", + uid: "occurrence-a", + }), + 2, + ); + assert.equal( + findCurrentTrackIndex(rows, { + uri: "spotify:track:same", + uid: "offscreen-occurrence", + }), + -1, + ); + assert.equal( + findCurrentTrackIndex( + rows.map(({ trackUri }) => ({ trackUri })), + { uri: "spotify:track:same" }, + ), + 0, + ); }); test("method owner lookup reaches inherited Spotify API methods", () => { @@ -789,6 +864,7 @@ test("current Spotify row React props yield track, album, and primary artist", ( item: { type: "track", uri: "spotify:track:1111111111111111111111", + uid: "playlist-occurrence-1", name: "Current Track", duration: { milliseconds: 159_000, @@ -818,6 +894,7 @@ test("current Spotify row React props yield track, album, and primary artist", ( }; assert.deepEqual(extractTrackInfoFromProps(props), { + uid: "playlist-occurrence-1", trackUri: "spotify:track:1111111111111111111111", trackName: "Current Track", durationMs: 159_000, From dcd51cfe7f3fc764fafd33cdc8688087588e3928 Mon Sep 17 00:00:00 2001 From: oniivan Date: Tue, 28 Jul 2026 13:31:09 -0700 Subject: [PATCH 2/2] Fix circular profile card wrappers --- README.md | 2 ++ gem-sort.js | 13 ++++++++++++- package.json | 2 +- test/gem-sort.test.cjs | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38f3a9f..8571074 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ alone, and keeps the rest of Spotify's layout familiar. blocking reordering drops inside the temporarily sorted source playlist. - Retains Date added when the playlist has meaningful dates, but hides the empty field on radio, mixes, and other generated playlist views. +- Restores transparent, circular wrappers for artist and profile cards when + Spotify's generic square artwork background leaks through their corners. - Batches visible play-count lookups, deduplicates artist lookups, and reuses in-memory results within defined windows. No API key, Spotify developer app, or third-party service is required. diff --git a/gem-sort.js b/gem-sort.js index 46eacad..83626da 100644 --- a/gem-sort.js +++ b/gem-sort.js @@ -24,7 +24,7 @@ })(typeof window !== "undefined" ? window : null, function createStreamRankApi() { "use strict"; - const VERSION = "0.5.3"; + const VERSION = "0.5.4"; const GLOBAL_KEY = "__spotifyGemSort"; const STYLE_ID = "spotify-gem-sort-style"; const GRID_SELECTOR = @@ -1187,6 +1187,17 @@ } style.textContent = ` + /* + * Spotify 1.2.94 can leave the generic square card background visible + * behind circular artist and profile artwork. Scope the correction to + * wrappers Spotify explicitly marks as circular so album and playlist + * cards retain their native shape and shadow. + */ + .main-cardImage-imageWrapper.main-cardImage-circular { + background-color: transparent !important; + border-radius: 50%; + } + [data-gem-sort-grid="true"] .spotify-gem-sort-header, [data-gem-sort-grid="true"] .spotify-gem-sort-cell { box-sizing: border-box; diff --git a/package.json b/package.json index 8cf84bb..141d03b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spicetify-gem-sort", - "version": "0.5.3", + "version": "0.5.4", "private": true, "description": "Spotify-reported play counts and primary-artist Top 10 ranks in Spicetify playlist rows.", "scripts": { diff --git a/test/gem-sort.test.cjs b/test/gem-sort.test.cjs index c9351a5..85fa3b1 100644 --- a/test/gem-sort.test.cjs +++ b/test/gem-sort.test.cjs @@ -65,6 +65,23 @@ test("runtime dataset keys match the Gem Sort CSS attribute namespace", () => { ].forEach((attribute) => assert.match(source, new RegExp(attribute))); }); +test("the circular-card compatibility style cannot affect square artwork", () => { + const source = fs.readFileSync(require.resolve("../gem-sort.js"), "utf8"); + + assert.match( + source, + /\.main-cardImage-imageWrapper\.main-cardImage-circular\s*\{/, + ); + assert.match( + source, + /\.main-cardImage-imageWrapper\.main-cardImage-circular\s*\{[^}]*background-color:\s*transparent\s*!important;/s, + ); + assert.doesNotMatch( + source, + /(?