diff --git a/userscript/sc-gate-dl.test.ts b/userscript/sc-gate-dl.test.ts new file mode 100644 index 0000000..d06922d --- /dev/null +++ b/userscript/sc-gate-dl.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; + +type UpdateFeedPlaybackOrigin = ( + currentOrigin: string | null, + feedCardUrl: string | null, + outsidePlaybackSelection: boolean, +) => string | null; + +const source = await Bun.file( + new URL('./sc-gate-dl.user.js', import.meta.url), +).text(); +const start = source.indexOf('\tfunction updateFeedPlaybackOrigin('); +const end = source.indexOf('\n\n\tlet lastRecordedPlayingUrl', start); +if (start < 0 || end < 0) throw new Error('Playback-origin helper not found'); +const helperSource = source.slice(start, end).trim(); +const updateFeedPlaybackOrigin = Function( + `"use strict"; ${helperSource}; return updateFeedPlaybackOrigin;`, +)() as UpdateFeedPlaybackOrigin; + +describe('feed playback origin', () => { + test('clears feed provenance when another track is selected outside the feed', () => { + expect( + updateFeedPlaybackOrigin('https://soundcloud.com/feed/track', null, true), + ).toBeNull(); + }); + + test('preserves feed provenance through unrelated page clicks', () => { + const origin = 'https://soundcloud.com/feed/track'; + expect(updateFeedPlaybackOrigin(origin, null, false)).toBe(origin); + }); +}); diff --git a/userscript/sc-gate-dl.user.js b/userscript/sc-gate-dl.user.js index 8434972..454809e 100644 --- a/userscript/sc-gate-dl.user.js +++ b/userscript/sc-gate-dl.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name sc-gate-dl // @namespace https://github.com/D3SOX/sc-gate-dl -// @version 1.10.4 +// @version 1.10.9 // @description Add sc-gate-dl download controls and remember your position in the SoundCloud feed // @author D3SOX // @match https://soundcloud.com/* @@ -81,6 +81,7 @@ 'about', 'stations', 'feed', + 'tags', ]); const TRAILING_SEGMENTS = new Set([ @@ -379,6 +380,14 @@ /** Feed/search cards only — track pages use the current URL. */ function trackUrlFromCard(el) { + const trackItem = el?.closest?.('.trackItem'); + const trackItemTitle = trackItem?.querySelector( + 'a.trackItem__trackTitle[href]', + ); + if (trackItemTitle instanceof HTMLAnchorElement) { + const trackItemUrl = normalizeTrackUrl(trackItemTitle.href); + if (trackItemUrl) return trackItemUrl; + } const closest = el?.closest?.( '.sound, .soundList__item, .userStreamItem, .searchItem__trackItem, .listenContext', ); @@ -428,7 +437,13 @@ const normalized = normalizeTrackUrl(trackUrl); if (!normalized) return null; return ( - feedCards().find((card) => trackUrlFromCard(card) === normalized) || null + feedCards().find( + (card) => + trackUrlFromCard(card) === normalized || + Array.from(card.querySelectorAll('a.trackItem__trackTitle[href]')).some( + (link) => normalizeTrackUrl(link.href) === normalized, + ), + ) || null ); } @@ -498,22 +513,14 @@ } } - function resetFeedCheckpoints() { - try { - if (typeof GM_deleteValue === 'function') { - GM_deleteValue(FEED_CHECKPOINT_KEY); - } else if (typeof GM_setValue === 'function') { - GM_setValue(FEED_CHECKPOINT_KEY, null); - } - } catch { - // ignore - } - try { - localStorage.removeItem(FEED_CHECKPOINT_KEY); - } catch { - // ignore - } - setFeedStatus('Positions reset. The next played track will seed both.'); + function resetFeedCheckpoint(direction) { + if (direction !== 'newer' && direction !== 'older') return; + const checkpoints = loadFeedCheckpoints() ?? { newer: null, older: null }; + checkpoints[direction] = null; + persistFeedCheckpoints(checkpoints); + setFeedStatus( + `${direction === 'newer' ? 'Up' : 'Down'} position reset. The next eligible played track will seed it.`, + ); updateFeedNavigator(); } @@ -537,8 +544,14 @@ : trackLabelFromUrl(url); } - function shouldAdvanceCheckpoint(saved, card, candidateTimestamp, direction) { - if (!saved || saved.url === trackUrlFromCard(card)) return true; + function shouldAdvanceCheckpoint( + saved, + card, + candidateTimestamp, + direction, + candidateUrl, + ) { + if (!saved || saved.url === candidateUrl) return true; if (saved.feedTimestamp != null && candidateTimestamp != null) { return direction === 'newer' ? candidateTimestamp >= saved.feedTimestamp @@ -555,12 +568,14 @@ : candidateIndex >= savedIndex; } - function saveFeedCheckpointFromCard(card) { + function saveFeedCheckpointFromCard(card, playedUrl = null) { if (!isFeedPage() || !(card instanceof Element)) return; const feedCard = card.matches('.sound') ? card : card.querySelector('.sound') || card; - const url = trackUrlFromCard(feedCard); + const url = + (playedUrl ? normalizeTrackUrl(playedUrl) : null) || + trackUrlFromCard(feedCard); if (!url) return; const timestamp = feedTimestamp(feedCard); const label = checkpointLabelFromCard(feedCard, url); @@ -574,7 +589,11 @@ let changed = false; for (const direction of ['newer', 'older']) { const saved = checkpoints[direction]; - if (!shouldAdvanceCheckpoint(saved, feedCard, timestamp, direction)) continue; + if ( + !shouldAdvanceCheckpoint(saved, feedCard, timestamp, direction, url) + ) { + continue; + } if (saved?.url === url) { if (saved.label !== label) { checkpoints[direction] = { ...saved, label }; @@ -591,10 +610,25 @@ } } + function updateFeedPlaybackOrigin( + currentOrigin, + feedCardUrl, + outsidePlaybackSelection, + ) { + if (feedCardUrl) return feedCardUrl; + return outsidePlaybackSelection ? null : currentOrigin; + } + let lastRecordedPlayingUrl = null; + let feedPlaybackOriginUrl = null; function recordPlayingFeedTrack() { - if (!isFeedPage()) return; + if (!isFeedPage()) { + feedPlaybackOriginUrl = null; + lastRecordedPlayingUrl = null; + return; + } + if (!feedPlaybackOriginUrl) return; const playing = document.querySelector( '.playControls .playControl.playing, .playControls__play.playing, .playControls button[title^="Pause"], .playControls button[aria-label^="Pause"]', ); @@ -610,8 +644,9 @@ if (!url || url === lastRecordedPlayingUrl) return; const card = url ? findFeedCard(url) : null; if (card) { - saveFeedCheckpointFromCard(card); + saveFeedCheckpointFromCard(card, url); lastRecordedPlayingUrl = url; + feedPlaybackOriginUrl = url; } } @@ -633,17 +668,31 @@ const resumeOlder = nav.querySelector('.sc-gate-dl-feed-resume-older'); const cancel = nav.querySelector('.sc-gate-dl-feed-cancel'); const find = nav.querySelector('.sc-gate-dl-feed-find'); - const updateResumeButton = (button, checkpoint, label) => { - if (!(button instanceof HTMLButtonElement)) return; - button.hidden = feedSearchActive; - button.disabled = !checkpoint; - button.textContent = checkpoint + const resetNewer = nav.querySelector('.sc-gate-dl-feed-reset-newer'); + const resetOlder = nav.querySelector('.sc-gate-dl-feed-reset-older'); + const updateResumeLink = (link, checkpoint, label) => { + if (!(link instanceof HTMLAnchorElement)) return; + link.hidden = feedSearchActive; + link.textContent = checkpoint ? `Resume ${label}: ${checkpoint.label}` : 'No listened track saved yet'; - button.title = checkpoint?.url || ''; + link.title = checkpoint?.url || ''; + if (checkpoint) { + link.removeAttribute('aria-disabled'); + link.href = checkpoint.url; + } else { + link.setAttribute('aria-disabled', 'true'); + link.removeAttribute('href'); + } }; - updateResumeButton(resumeNewer, checkpoints?.newer, 'farthest up'); - updateResumeButton(resumeOlder, checkpoints?.older, 'farthest down'); + updateResumeLink(resumeNewer, checkpoints?.newer, 'farthest up'); + updateResumeLink(resumeOlder, checkpoints?.older, 'farthest down'); + if (resetNewer instanceof HTMLButtonElement) { + resetNewer.disabled = !checkpoints?.newer; + } + if (resetOlder instanceof HTMLButtonElement) { + resetOlder.disabled = !checkpoints?.older; + } if (cancel instanceof HTMLButtonElement) cancel.hidden = !feedSearchActive; if (find instanceof HTMLButtonElement) find.disabled = feedSearchActive; } @@ -786,11 +835,14 @@ Feed position - - + + - +