Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ 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
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.
Expand Down Expand Up @@ -77,8 +82,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.

Expand Down
177 changes: 166 additions & 11 deletions gem-sort.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
})(typeof window !== "undefined" ? window : null, function createStreamRankApi() {
"use strict";

const VERSION = "0.5.2";
const VERSION = "0.5.4";
const GLOBAL_KEY = "__spotifyGemSort";
const STYLE_ID = "spotify-gem-sort-style";
const GRID_SELECTOR =
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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") {
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1122,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;
Expand Down Expand Up @@ -1211,6 +1287,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;
}
`;
}

Expand Down Expand Up @@ -1496,6 +1581,7 @@
if (!trackUri) return null;

return {
uid: null,
trackUri,
trackName: trackLink?.textContent?.trim() || "",
durationMs: null,
Expand All @@ -1513,6 +1599,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 = {
Expand Down Expand Up @@ -2851,6 +2975,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);
Expand Down Expand Up @@ -3154,7 +3279,9 @@
}

renumberCells(row, "gridcell");
populateCell(cell, extractRowInfo(row));
const info = extractRowInfo(row);
populateCell(cell, info);
return info;
}

function reconcileGrid(grid) {
Expand Down Expand Up @@ -3218,11 +3345,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);
Expand Down Expand Up @@ -3288,6 +3419,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?.(() => {
Expand Down Expand Up @@ -3370,6 +3510,7 @@
delete grid.__streamRankDataHasMeaningfulDates;
delete grid.dataset.gemSortGrid;
delete grid.dataset.gemSortSortActive;
clearCurrentTrackMarkers(grid);

if (grid.__streamRankDropGuard) {
grid.removeEventListener(
Expand Down Expand Up @@ -3420,6 +3561,14 @@
);
browserRoot.removeEventListener("resize", scheduleReconcile);

if (playerSongChangeListener) {
getSpicetify()?.Player?.removeEventListener?.(
"songchange",
playerSongChangeListener,
);
playerSongChangeListener = null;
}

try {
if (typeof historyUnlisten === "function") historyUnlisten();
} catch {
Expand Down Expand Up @@ -3468,6 +3617,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,
Expand Down Expand Up @@ -3553,16 +3705,19 @@
extractTrackCounts,
extractTrackInfoFromProps,
findTopTrackRecord,
findCurrentTrackIndex,
fingerprintQueryOptions,
findColumnTypeIndex,
findMethodOwner,
findTrackItem,
formatPlayCount,
getArtistSortConcurrency,
getPlaybackContextUri,
getPlayerContextUri,
getCapturedPageOffset,
buildPlaylistTemplate,
insertTemplateColumn,
isGemSortPlaybackContext,
isPlaylistPath,
nextMetricSortState,
normalizeTrackItem,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "spicetify-gem-sort",
"version": "0.5.2",
"version": "0.5.4",
"private": true,
"description": "Spotify-reported play counts and primary-artist Top 10 ranks in Spicetify playlist rows.",
"scripts": {
Expand Down
Loading