From b2645d00ae62a22d1ce700ba0ab4612370f1ae20 Mon Sep 17 00:00:00 2001 From: charles Date: Tue, 26 Sep 2023 20:40:16 -0600 Subject: [PATCH] feat: include speed and playbackRate in share url (#132) --- src/actions/init.js | 77 ++++---- src/actions/overload.js | 2 +- src/data/song-state.js | 36 ++++ src/events/listeners/action-listeners.js | 6 +- src/events/listeners/header-listeners.js | 30 +-- src/events/listeners/listeners.js | 2 - src/models/alert.js | 26 ++- src/models/seek/seek-icon.js | 17 +- src/models/snip/current-snip.js | 6 +- src/models/snip/snip.js | 11 +- src/models/video.js | 137 -------------- src/models/video/video-override.js | 59 ++++++ src/models/video/video.js | 94 ++++++++++ src/observers/current-time.js | 205 --------------------- src/observers/current-time/current-time.js | 26 +++ src/observers/current-time/song-tracker.js | 121 ++++++++++++ src/observers/now-playing.js | 49 ++--- 17 files changed, 455 insertions(+), 449 deletions(-) create mode 100644 src/data/song-state.js delete mode 100644 src/models/video.js create mode 100644 src/models/video/video-override.js create mode 100644 src/models/video/video.js delete mode 100644 src/observers/current-time.js create mode 100644 src/observers/current-time/current-time.js create mode 100644 src/observers/current-time/song-tracker.js diff --git a/src/actions/init.js b/src/actions/init.js index e2b16d9f..7c5849a0 100644 --- a/src/actions/init.js +++ b/src/actions/init.js @@ -1,92 +1,77 @@ -import { spotifyVideo } from './overload.js' - -import Alert from '../models/alert.js' import { store } from '../stores/data.js' -import TrackList from '../models/tracklist/track-list.js' +import { spotifyVideo } from './overload.js' import CurrentSnip from '../models/snip/current-snip.js' +import TrackList from '../models/tracklist/track-list.js' import NowPlayingIcons from '../models/now-playing-icons.js' import TrackListObserver from '../observers/track-list.js' import NowPlayingObserver from '../observers/now-playing.js' -import CurrentTimeObserver from '../observers/current-time.js' +import CurrentTimeObserver from '../observers/current-time/current-time.js' class App { - #video - #store - #snip - #alert - #intervalId - #active = true - #nowPlayingIcons - #currentTimeObserver - #nowPlayingObserver - #trackListObserver - constructor({ video, store }) { - this.#store = store - this.#video = video + this._store = store + this._video = video + this._active = true + this._intervalId = null this.#init() } #init() { - this.#snip = new CurrentSnip() - - this.#alert = new Alert() + this._snip = new CurrentSnip() - this.#nowPlayingIcons = new NowPlayingIcons(this.#snip) - this.#nowPlayingObserver = new NowPlayingObserver({ snip: this.#snip, video: this.#video }) - this.#trackListObserver = new TrackListObserver(new TrackList(this.#store)) - this.#currentTimeObserver = new CurrentTimeObserver({ video: this.#video, snip: this.#snip }) - - this.#snip.updateView() + this._nowPlayingIcons = new NowPlayingIcons(this._snip) + this._currentTimeObserver = new CurrentTimeObserver(this._video) + this._trackListObserver = new TrackListObserver(new TrackList(this._store)) + this._nowPlayingObserver = new NowPlayingObserver({ snip: this._snip, video: this._video }) + this._snip.updateView() this.#resetInterval() - this.#reInit() } #resetInterval() { - if (!this.#intervalId) return + if (!this._intervalId) return - clearInterval(this.#intervalId) - this.#intervalId = null + clearInterval(this._intervalId) + this._intervalId = null } disconnect() { - this.#active = false - this.#video.reset() + this._active = false + this._video.reset() - this.#trackListObserver.disconnect() - this.#currentTimeObserver.disconnect() - this.#nowPlayingObserver.disconnect() + this._trackListObserver.disconnect() + this._currentTimeObserver.disconnect() + this._nowPlayingObserver.disconnect() this.#resetInterval() } async connect() { - this.#active = true + this._active = true - this.#trackListObserver.observe() - this.#currentTimeObserver.observe() - this.#nowPlayingObserver.observe() + this._trackListObserver.observe() + this._currentTimeObserver.observe() + this._nowPlayingObserver.observe() this.#resetInterval() - await this.#video.activate() + await this._video.activate() this.#reInit() } #reInit() { - this.#intervalId = setInterval(async () => { - if (!this.#active) return - if (!this.#intervalId) return + this._intervalId = setInterval(async () => { + if (!this._active) return + if (!this._intervalId) return const chorus = document.getElementById('chorus') if (!chorus) { - this.#nowPlayingIcons.init() - await this.#video.activate() + this._nowPlayingIcons.init() + await this._video.activate() } }, 3000) } diff --git a/src/actions/overload.js b/src/actions/overload.js index a90d41aa..3e099477 100644 --- a/src/actions/overload.js +++ b/src/actions/overload.js @@ -1,4 +1,4 @@ -import VideoElement from '../models/video.js' +import VideoElement from '../models/video/video.js' class SpotifyVideo { #video diff --git a/src/data/song-state.js b/src/data/song-state.js new file mode 100644 index 00000000..8f015c05 --- /dev/null +++ b/src/data/song-state.js @@ -0,0 +1,36 @@ +import { currentData } from './current.js' +import { currentSongInfo } from '../utils/song.js' + +const sharedSnipValues = () => { + if (!location?.search) return + + const params = new URLSearchParams(location.search) + const values = params?.get('ch') + if (!values) return + + const [startTime, endTime, playbackRate, preservesPitch] = values.split('-') + + return { + endTime: parseInt(endTime, 10), + startTime: parseInt(startTime, 10), + preservesPitch: parseInt(preservesPitch, 10) == 1, + playbackRate: parseInt(parseFloat(playbackRate) / 100), + } +} + +export const songState = async () => { + const state = await currentData.readTrack() + const sharedSnipState = sharedSnipValues() + + if (!sharedSnipState) return { ...state, isShared: false } + + const { trackId } = currentSongInfo() + const locationId = location?.pathname?.split('/')?.at(-1) + + return { + trackId, + isShared: trackId == locationId, + ...state, + ...sharedSnipState + } +} diff --git a/src/events/listeners/action-listeners.js b/src/events/listeners/action-listeners.js index 38c16f1e..5ae6b300 100644 --- a/src/events/listeners/action-listeners.js +++ b/src/events/listeners/action-listeners.js @@ -3,9 +3,12 @@ import Listeners from './listeners.js' export default class ActionListeners extends Listeners { constructor() { super() + this._setup = false } init() { + if (this._setup) return + this.#saveSeekListener() this.#saveTrackListener() this.#saveSpeedListener() @@ -13,6 +16,8 @@ export default class ActionListeners extends Listeners { this.#shareTrackListener() this.#deleteTrackListener() this.#resetSpeedListener() + + this._setup = true } #resetSpeedListener() { @@ -61,7 +66,6 @@ export default class ActionListeners extends Listeners { #shareTrackListener() { const shareButton = document.getElementById('chorus-snip-share-button') - shareButton?.removeEventListener('click', () => this.#handleShare()) shareButton?.addEventListener('click', () => this.#handleShare()) } } diff --git a/src/events/listeners/header-listeners.js b/src/events/listeners/header-listeners.js index 7d76a8e7..f1a1cbf5 100644 --- a/src/events/listeners/header-listeners.js +++ b/src/events/listeners/header-listeners.js @@ -3,26 +3,30 @@ import Listeners from './listeners.js' export default class HeaderListeners extends Listeners { constructor() { super() - this.viewInFocus = null - this.VIEWS = ['snip', 'speed', 'seek'] + this._setup = false + this._viewInFocus = null + this._VIEWS = ['snip', 'speed', 'seek'] } init() { + if (this._setup) return + this.#snipViewToggle() this.#seekViewToggle() this.#speedViewToggle() this.#closeModalListener() - this.currentView = 'snip' + this._currentView = 'snip' + this._setup = true } async hide() { - if (this.viewInFocus == 'speed') { + if (this._viewInFocus == 'speed') { this._speed.clearCurrentSpeed() await this._speed.reset() } - this.currentView = 'snip' + this._currentView = 'snip' this._hide() } @@ -30,15 +34,15 @@ export default class HeaderListeners extends Listeners { const seekButton = document.getElementById('chorus-seek-button') seekButton?.addEventListener('click', async () => { - this.currentView = 'seek' + this._currentView = 'seek' await this._seek.init() }) } - set currentView(selectedView) { - this.viewInFocus = selectedView + set _currentView(selectedView) { + this._viewInFocus = selectedView - this.VIEWS.forEach(view => { + this._VIEWS.forEach(view => { const viewButton = document.getElementById(`chorus-${view}-button`) const viewInFocusContainer = document.getElementById(`chorus-${view}-controls`) if (!viewButton || !viewInFocusContainer) return @@ -50,21 +54,19 @@ export default class HeaderListeners extends Listeners { #snipViewToggle() { const snipButton = document.getElementById('chorus-snip-button') - snipButton?.addEventListener('click', () => { this.currentView = 'snip' }) + snipButton?.addEventListener('click', () => { this._currentView = 'snip' }) } #speedViewToggle() { const speedButton = document.getElementById('chorus-speed-button') speedButton?.addEventListener('click', async () => { - this.currentView = 'speed' + this._currentView = 'speed' await this._speed.init() }) } #closeModalListener() { const closeButton = document.getElementById('chorus-modal-close-button') - closeButton?.addEventListener('click', async () => { - await this.hide() - }) + closeButton?.addEventListener('click', async () => { await this.hide() }) } } diff --git a/src/events/listeners/listeners.js b/src/events/listeners/listeners.js index 2b25bfc5..6cbb5e91 100644 --- a/src/events/listeners/listeners.js +++ b/src/events/listeners/listeners.js @@ -10,9 +10,7 @@ export default class Listeners { } _hide() { - this._snip.isEditing = false const mainElement = document.getElementById('chorus-main') - mainElement.style.display = 'none' } } diff --git a/src/models/alert.js b/src/models/alert.js index 54864ed5..52637541 100644 --- a/src/models/alert.js +++ b/src/models/alert.js @@ -2,27 +2,35 @@ import { createAlert } from '../components/alert.js' import { parseNodeString } from '../utils/parser.js' export default class Alert { - constructor() { - this.init() - } - - init() { + displayAlert() { this.#setupAlert() + const alertMessage = this.#chorusAlert.querySelector('[id="chorus-alert-message"]') + + alertMessage.textContent = `Snip copied to clipboard.` + this.#chorusAlert.style.display = 'flex' + setTimeout(() => { + this.#chorusAlert.style.display = 'none' + }, 3000) } - #handleAlert({ e, target }) { - e.stopPropagation() + #handleAlert(target) { const container = target.parentElement container.style.display = 'none' } + get #chorusAlert() { + return document.getElementById('chorus-alert') + } + #setupAlert() { + if (this.#chorusAlert) return + const alertEl = parseNodeString(createAlert()) document.body.appendChild(alertEl) const closeAlertButton = document.getElementById('chorus-alert-close-button') - closeAlertButton?.addEventListener('click', (e) => { - this.#handleAlert({ e, target: closeAlertButton }) + closeAlertButton?.addEventListener('click', () => { + this.#handleAlert(closeAlertButton) }) } } diff --git a/src/models/seek/seek-icon.js b/src/models/seek/seek-icon.js index 99da8ad5..ba7d7f6c 100644 --- a/src/models/seek/seek-icon.js +++ b/src/models/seek/seek-icon.js @@ -1,6 +1,7 @@ import { createSeekIcon } from '../../components/seek/seek-icon.js' import { currentData } from '../../data/current.js' +import { songState } from '../../data/song-state.js' import { parseNodeString } from '../../utils/parser.js' import { spotifyVideo } from '../../actions/overload.js' @@ -99,13 +100,23 @@ export default class SeekIcons { } } - #handleSeekButton(e) { + async #calculateCurrentTime({ role, seekTime }) { + const { startTime, endTime } = await songState() + const currentTime = this.#video.currentTime + const newTimeFF = Math.min(parseInt(currentTime + seekTime, 10), parseInt(endTime, 10)) + const newStartTime = currentTime < parseInt(startTime) ? 0 : startTime + const newTimeRW = Math.max(parseInt(currentTime - seekTime, 10), parseInt(newStartTime, 10)) + + return role == 'ff' ? newTimeFF : newTimeRW + } + + async #handleSeekButton(e) { const button = e.target const role = button.getAttribute('role') const seekTime = parseInt(button.firstElementChild.textContent, 10) - const currentTime = this.#video.currentTime - this.#video.currentTime = role == 'ff' ? (currentTime + seekTime) : (currentTime - seekTime) + const newTime = await this.#calculateCurrentTime({ role, seekTime }) + this.#video.currentTime = { source: 'chorus', value: newTime } } #setupListeners() { diff --git a/src/models/snip/current-snip.js b/src/models/snip/current-snip.js index a2f1cf48..05483bd1 100644 --- a/src/models/snip/current-snip.js +++ b/src/models/snip/current-snip.js @@ -51,9 +51,11 @@ export default class CurrentSnip extends Snip { share() { const { url } = currentSongInfo() - const { startTime, endTime } = this.read() + const { startTime, endTime, playbackRate = '1.00', preservesPitch = true } = this.read() + const pitch = preservesPitch ? 1 : 0 + const rate = parseFloat(playbackRate) * 100 - const shareURL = `${url}?startTime=${startTime}&endTime=${endTime}` + const shareURL = `${url}?ch=${startTime}-${endTime}-${rate}-${pitch}` copyToClipBoard(shareURL) super._displayAlert() diff --git a/src/models/snip/snip.js b/src/models/snip/snip.js index 63593e4c..b1892369 100644 --- a/src/models/snip/snip.js +++ b/src/models/snip/snip.js @@ -1,15 +1,17 @@ import { store } from '../../stores/data.js' + +import Alert from '../alert.js' import SliderControls from '../slider/slider-controls.js' export default class Snip { constructor() { this._store = store + this._alert = new Alert() this._controls = new SliderControls() } init() { this._controls.init() - this.isEditing = true } read() { @@ -53,12 +55,7 @@ export default class Snip { } _displayAlert() { - const alertBox = document.getElementById('chorus-alert') - const alertMessage = alertBox.querySelector('[id="chorus-alert-message"]') - - alertMessage.textContent = `Snip copied to clipboard.` - alertBox.style.display = 'flex' - setTimeout(() => { alertBox.style.display = 'none' }, 3000) + this._alert.displayAlert() } _setTrackInfo({ title, artists }) { diff --git a/src/models/video.js b/src/models/video.js deleted file mode 100644 index dbafee6d..00000000 --- a/src/models/video.js +++ /dev/null @@ -1,137 +0,0 @@ -import { currentData } from '../data/current.js' - -export default class VideoElement { - #video - #active = sessionStorage.getItem('enabled') == 'true' - - constructor(video) { - this.#video = video - this.#listenForTrackChange() - this.#setPlaybackRateProtection() - } - - set active(value) { - this.#active = value - } - - async activate() { - await this.#handleTrackChange() - } - - reset() { - this.clearCurrentSpeed() - this.playbackRate = 1 - this.preservesPitch = true - } - - get element() { - return this.#video - } - - set currentTime(value) { - if (this.#video) { - this.#video.currentTime = value - } - } - - get currentTime() { - return this.#video?.currentTime - } - - clearCurrentSpeed() { - this.#video.removeAttribute('currentSpeed') - } - - set preservesPitch(value) { - if (this.#video) { - this.#video.preservesPitch = value - } - } - - get currentSpeed() { - return this.#video.getAttribute('currentSpeed') - } - - set currentSpeed(value) { - this.#video.setAttribute('currentSpeed', value) - } - - get preservesPitch() { - return this.#video ? this.#video.preservesPitch : true - } - - set playbackRate(value) { - if (this.#active) { - this.#video.playbackRate = { source: 'chorus', value: value } - } else { - this.#video.playbackRate = value - } - } - - get playbackRate() { - return this.#video ? this.#video.playbackRate : 1 - } - - async #handleTrackChange() { - if (!this.#active) return - - const { preferredRate, preferredPitch } = await currentData.getPlaybackValues() - - this.currentSpeed = preferredRate - this.#video.playbackRate = preferredRate - this.#video.preservesPitch = preferredPitch - } - - #listenForTrackChange() { - this.#video.addEventListener('loadeddata', async () => { - await this.#handleTrackChange() - }) - } - - #setPlaybackRateProtection() { - const self = this - - if (this.#video instanceof HTMLMediaElement) { - const playbackRateDescriptor = Object.getOwnPropertyDescriptor( - HTMLMediaElement.prototype, - 'playbackRate' - ) - - async function handlePlaybackRateSetting(value) { - if (value?.source !== 'chorus') { - if (self.currentSpeed) return self.currentSpeed - - const { preferredRate, preferredPitch } = await currentData.getPlaybackValues() - self.preservesPitch = preferredPitch - - return isValidPlaybackRate(preferredRate) ? preferredRate : 1 - } - return isValidPlaybackRate(value.value) ? value.value : 1 - } - - function isValidPlaybackRate(rate) { - const numRate = Number(rate) - return !isNaN(numRate) && isFinite(numRate) - } - - if (!playbackRateDescriptor._isOverridden) { - Object.defineProperty(HTMLMediaElement.prototype, 'playbackRate', { - async set(value) { - const newRate = self.#active ? await handlePlaybackRateSetting(value) : value - playbackRateDescriptor.set.call(this, newRate) - }, - get: playbackRateDescriptor.get, - enumerable: playbackRateDescriptor.enumerable, - configurable: playbackRateDescriptor.configurable - }) - - Object.defineProperty(playbackRateDescriptor, '_isOverridden', { - value: true, - writable: false, - enumerable: false, - configurable: false - }) - } - } - } -} diff --git a/src/models/video/video-override.js b/src/models/video/video-override.js new file mode 100644 index 00000000..086c61d8 --- /dev/null +++ b/src/models/video/video-override.js @@ -0,0 +1,59 @@ +import { currentData } from '../../data/current.js' + +export default class VideoOverride { + constructor(video) { + this._video = video + this.#overrideMediaProperty('currentTime', this.#handleCurrentTimeSetting) + this.#overrideMediaProperty('playbackRate', this.#handlePlaybackRateSetting) + } + + async #overrideMediaProperty(propertyName, handler) { + const self = this + + const descriptor = Object.getOwnPropertyDescriptor( + HTMLMediaElement.prototype, + propertyName + ) + + if (!descriptor || descriptor._isOverridden) return + + Object.defineProperty(HTMLMediaElement.prototype, propertyName, { + async set(value) { + const newValue = self._video.active ? await handler.call(this, value) : value + descriptor.set.call(this, newValue) + }, + get: descriptor.get, + enumerable: descriptor.enumerable, + configurable: descriptor.configurable + }) + + Object.defineProperty(descriptor, '_isOverridden', { + value: true, + writable: false, + enumerable: false, + configurable: false + }) + } + + #isValidPlaybackRate(rate) { + const numRate = Number(rate) + return !isNaN(numRate) && isFinite(numRate) + } + + #handlePlaybackRateSetting = async value => { + if (value?.source !== 'chorus') { + if (this._video.currentSpeed) return this._video.currentSpeed + + const { preferredRate, preferredPitch } = await currentData.getPlaybackValues() + this._video.preservesPitch = preferredPitch + + return this.#isValidPlaybackRate(preferredRate) ? preferredRate : 1 + } + + return this.#isValidPlaybackRate(value.value) ? value.value : 1 + } + + #handleCurrentTimeSetting = value => { + return value?.value ?? value + } +} diff --git a/src/models/video/video.js b/src/models/video/video.js new file mode 100644 index 00000000..b374f2d4 --- /dev/null +++ b/src/models/video/video.js @@ -0,0 +1,94 @@ +import { currentData } from '../../data/current.js' +import VideoOverride from './video-override.js' + +export default class VideoElement { + constructor(video) { + this._video = video + this.#listenForTrackChange() + this._active = sessionStorage.getItem('enabled') == 'true' + + this._videoOverride = new VideoOverride(this) + } + + set active(value) { + this._active = value + } + + get active() { + return this._active + } + + async activate() { + await this.#handleTrackChange() + } + + reset() { + this.clearCurrentSpeed() + this.playbackRate = 1 + this.preservesPitch = true + } + + get element() { + return this._video + } + + set currentTime(value) { + if (this._video) { + this._video.currentTime = value + } + } + + get currentTime() { + return this._video?.currentTime + } + + clearCurrentSpeed() { + this._video.removeAttribute('currentSpeed') + } + + set preservesPitch(value) { + if (this._video) { + this._video.preservesPitch = value + } + } + + get currentSpeed() { + return this._video.getAttribute('currentSpeed') + } + + set currentSpeed(value) { + this._video.setAttribute('currentSpeed', value) + } + + get preservesPitch() { + return this._video ? this._video.preservesPitch : true + } + + set playbackRate(value) { + if (this._active) { + this._video.playbackRate = { source: 'chorus', value: value } + } else { + this._video.playbackRate = value + } + } + + get playbackRate() { + return this._video ? this._video.playbackRate : 1 + } + + async #handleTrackChange() { + if (!this.active) return + + const { preferredRate, preferredPitch } = await currentData.getPlaybackValues() + + this.currentSpeed = preferredRate + this._video.playbackRate = preferredRate + this._video.preservesPitch = preferredPitch + } + + #listenForTrackChange() { + this._video.addEventListener('loadeddata', async () => { + await this.#handleTrackChange() + }) + } +} diff --git a/src/observers/current-time.js b/src/observers/current-time.js deleted file mode 100644 index 03479408..00000000 --- a/src/observers/current-time.js +++ /dev/null @@ -1,205 +0,0 @@ -import SeekIcons from '../models/seek/seek-icon.js' - -import { currentSongInfo } from '../utils/song.js' -import { timeToSeconds, secondsToTime } from '../utils/time.js' - -export default class CurrentTimeObserver { - #snip - #video - #observer - #seekIcons - - constructor({ video, snip }) { - this.#snip = snip - this.#video = video - this.#seekIcons = new SeekIcons() - - this.#init() - this.observe() - } - - #init() { - const { isSnip, isShared, startTime } = this.#songState - - if (isShared) { - this.#video.currentTime = startTime - this.#playTrack() - } - - if (!isSnip) return - - const currentTime = timeToSeconds(this.#playbackPosition?.textContent || '0:00') - - if (currentTime > 0) { - this.#video.currentTime = currentTime - } - } - - #playTrack() { - const playButton = document.querySelector('[data-testid="play-button"]') - playButton.click() - } - - #handleClick = e => { - e.preventDefault() - if (!this.#muted) this.#muteButton.click() - } - - #setListeners() { - this.#nextButton?.addEventListener('click', this.#handleClick) - this.#previousButton?.addEventListener('click', this.#handleClick) - } - - #clearListeners() { - this.#nextButton?.removeEventListener('click', this.#handleClick) - this.#previousButton?.removeEventListener('click', this.#handleClick) - } - - get #playbackPosition() { - return document.querySelector('[data-testid="playback-position"]') - } - - get #previousButton() { - return document.querySelector('[data-testid="control-button-skip-back"]') - } - - get #nextButton() { - return document.querySelector('[data-testid="control-button-skip-forward"]') - } - - get #muteButton() { - return document.querySelector('[data-testid="volume-bar-toggle-mute-button"]') - } - - get #muted() { - return this.#muteButton?.getAttribute('aria-label') == 'Unmute' - } - - get #sharedSnipValues() { - if (!location?.search) return - - const params = new URLSearchParams(location.search) - - if (!params.get('startTime') || !params.get('endTime')) return - - return { - endTime: parseInt(params.get('endTime'), 10), - startTime: parseInt(params.get('startTime'), 10) - } - } - - get #songState() { - const state = this.#snip.read() - const sharedSnipState = this.#sharedSnipValues - - if (!sharedSnipState) return state - - const { trackId } = currentSongInfo() - - return { - trackId, - isShared: true, - ...state, - ...sharedSnipState - } - } - - #clearSearchParams() { - history.pushState(null, '', location.pathname) - } - - get #isSkippable() { - const { isSkipped, endTime } = this.#songState - return isSkipped || endTime == 0 - } - - get #isLooping() { - const repeatButton = document.querySelector('[data-testid="control-button-repeat"]') - return repeatButton?.getAttribute('aria-label') === 'Disable repeat' - } - - get #atSongEnd() { - const { endTime } = this.#songState - return endTime == parseInt(this.#video.currentTime) - } - - get #atSnipEnd() { - const { isSnip, endTime } = this.#songState - return isSnip && endTime == parseInt(this.#video.currentTime) - } - - get #atSnipStart() { - const { isSnip, startTime } = this.#songState - if (!isSnip) return false - - return isSnip && parseInt(startTime) >= this.#video.currentTime - } - - #handleNext() { - if (location?.search) this.#clearSearchParams() - - this.#video.currentTime = 0 - this.#nextButton.click() - this.#video.element.load() - } - - observe() { - this.#setListeners() - this.#seekIcons.init() - - this.#observer = setInterval(() => { - const { trackId, isSkipped, isShared, isSnip, startTime, endTime } = this.#songState - - if (!isSkipped && !isSnip) { - this.#muted && this.#muteButton.click() - return - } - - if (isShared && trackId !== location.pathname.split('track/').at(1)) { - this.#video.currentTime = startTime - this.#playTrack() - } - - const currentDisplayTime = secondsToTime(parseInt(this.#video.currentTime ?? 0, 10)) - this.#playbackPosition.textContent = currentDisplayTime - - if (this.#video.paused) return - - if (isSkipped && !this.#muted) { - this.#muteButton.click() - } - - if (this.#isSkippable) { - this.#handleNext() - } else if (this.#atSnipStart) { - if (!this.#muted) this.#muteButton.click() - this.#video.currentTime = startTime - } else if (this.#atSnipEnd) { - if (this.#snip.isEditing) { - // no-op - } else if (this.#isLooping) { - if (!this.#muted) this.#muteButton.click() - this.#video.currentTime = startTime - } else { - this.#handleNext() - } - } else if (this.#atSongEnd || this.#video.currentTime >= endTime) { - if (this.#snip.isEditing) { - // no-op - } else { - if (!this.#muted) this.#muteButton.click() - this.#handleNext() - } - } else if (this.#muted) this.#muteButton.click() - }, 1000) - } - - disconnect() { - if (!this.#observer) return - - clearInterval(this.#observer) - this.#clearListeners() - this.#seekIcons.removeIcons() - this.#observer = null - } -} diff --git a/src/observers/current-time/current-time.js b/src/observers/current-time/current-time.js new file mode 100644 index 00000000..c230cc2f --- /dev/null +++ b/src/observers/current-time/current-time.js @@ -0,0 +1,26 @@ +import SongTracker from './song-tracker.js' +import SeekIcons from '../../models/seek/seek-icon.js' + +export default class CurrentTimeObserver { + constructor(video) { + this._video = video + this._observer = null + this._seekIcons = new SeekIcons() + this._songTracker = new SongTracker() + + this.observe() + } + + observe() { + this._seekIcons.init() + this._observer = this._songTracker.initInterval() + } + + disconnect() { + if (!this._observer) return + + clearInterval(this._observer) + this._seekIcons.removeIcons() + this._observer = null + } +} diff --git a/src/observers/current-time/song-tracker.js b/src/observers/current-time/song-tracker.js new file mode 100644 index 00000000..3892ab91 --- /dev/null +++ b/src/observers/current-time/song-tracker.js @@ -0,0 +1,121 @@ +import { songState } from '../../data/song-state.js' +import { spotifyVideo } from '../../actions/overload.js' + +export default class SongTracker { + constructor() { + this._startedAtZero = false + this._video = spotifyVideo.element + this.initState() + } + + get #isPaused() { + return this._video.element.paused || !this._video.element.playing + } + + #playTrack() { + const playButton = document.querySelector('[data-testid="play-button"]') + if (this.#isPaused) playButton?.click() + } + + async #getSongState() { + return await songState() + } + + async initState() { + const { isSkipped, playbackRate, preservesPitch } = await this.#getSongState() + this._video.playbackRate = playbackRate + this._video.preservesPitch = preservesPitch + !isSkipped && this.#playTrack() + } + + initInterval() { + return setInterval(async () => { + if (this.#isEditing) return + const songStateData = await this.#getSongState() + + this.#handleSkippedSong(songStateData) + if (!songStateData.isSkipped) { + this.#handleSongStart(songStateData) + this.#handleSongEnd(songStateData) + if (this.#isMuted) this.#toggleMuteIfMuted() + } else { + if (!this.#isMuted) this.#toggleMuteIfMuted() + } + }, 1000) + } + + #handleSkippedSong({ isSkipped }) { + if (isSkipped) { + this.#muteAndSkip() + } + } + + #setVideoTime(timeObject) { + this._video.element.currentTime = timeObject + } + + #atSongStart(startTime) { + return parseInt(startTime, 10) > parseInt(this._video.currentTime, 10) + } + + #handleSongStart({ startTime }) { + if (this.#atSongStart(startTime)) { + this.#setVideoTime({ source: 'chorus', value: startTime }) + } + } + + #atSongEnd(endTime) { + return parseInt(this._video.currentTime, 10) >= parseInt(endTime, 10) + } + + #handleSongEnd({ isSnip, endTime, startTime }) { + if (this.#atSongEnd(endTime)) { + if (isSnip && this.#isLooping) { + this.#setVideoTime({ source: 'chorus', value: startTime }) + } else { + this.#muteAndSkip() + } + } + } + + #toggleMuteIfMuted() { + if (this.#isMuted) { + this.#muteButton.click() + } + } + + #muteAndSkip() { + if (!this.#isMuted) { + this.#muteButton.click() + } + this.#nextButton.click() + } + + // ============= TODO: move below into utils? ========= + + get #isEditing() { + const mainElement = document.getElementById('chorus-main') + if (!mainElement) return + + return main.style.display == 'block' + } + + get #nextButton() { + return document.querySelector('[data-testid="control-button-skip-forward"]') + } + + get #muteButton() { + return document.querySelector('[data-testid="volume-bar-toggle-mute-button"]') + } + + get #isMuted() { + return this.#muteButton?.getAttribute('aria-label') == 'Unmute' + } + + get #isLooping() { + const repeatButton = document.querySelector('[data-testid="control-button-repeat"]') + return repeatButton?.getAttribute('aria-label') === 'Disable repeat' + } + + // =============== TODO ================================ +} diff --git a/src/observers/now-playing.js b/src/observers/now-playing.js index 5c9ccdd8..f44da0fc 100644 --- a/src/observers/now-playing.js +++ b/src/observers/now-playing.js @@ -1,28 +1,25 @@ import Chorus from '../models/chorus.js' import SeekIcons from '../models/seek/seek-icon.js' -export default class NowPlayingObserver { - #snip - #video - #chorus - #observer - #seekIcons +import { songState } from '../data/song-state.js' +export default class NowPlayingObserver { constructor({ snip, video }) { - this.#snip = snip - this.#video = video - this.#chorus = new Chorus() - this.#seekIcons = new SeekIcons() + this._snip = snip + this._video = video + this._observer = null + this._chorus = new Chorus() + this._seekIcons = new SeekIcons() this.observe() } observe() { - const target = document.querySelector('[data-testid="now-playing-widget"]') const config = { subtree: true, childList: true, attributes: true } + const target = document.querySelector('[data-testid="now-playing-widget"]') - this.#observer = new MutationObserver(this.#handler) - this.#observer.observe(target, config) + this._observer = new MutationObserver(this.#mutationHandler) + this._observer.observe(target, config) this.#toggleSnipUI() } @@ -34,14 +31,23 @@ export default class NowPlayingObserver { ) } - #handler = async mutationsList => { + #mutationHandler = async mutationsList => { for (const mutation of mutationsList) { if (this.#isAnchor(mutation)) { - if (this.#chorus.isShowing) this.#snip.init() + const { isShared, isSkipped, startTime, endTime } = await songState() - this.#snip.updateView() - await this.#seekIcons.setSeekLabels() - await this.#video.activate() + if (!isShared && location?.search) history.pushState(null, '', location.pathname) + if (this._chorus.isShowing) this._snip.init() + this._snip.updateView() + await this._seekIcons.setSeekLabels() + await this._video.activate() + + if (isSkipped) { + document.querySelector('[data-testid="control-button-skip-forward"]')?.click() + this._video.currentTime = { source: 'chorus', value: endTime + 1 } + } else { + this._video.element.currentTime = { source: 'chorus', value: startTime } + } } } } @@ -50,8 +56,7 @@ export default class NowPlayingObserver { const snipUI = document.getElementById('chorus') if (!snipUI) return - snipUI.style.display = this.#observer ? 'flex' : 'none' - + snipUI.style.display = this._observer ? 'flex' : 'none' const chorusMain = document.getElementById('chorus-main') if (!chorusMain) return @@ -59,8 +64,8 @@ export default class NowPlayingObserver { } disconnect() { - this.#observer?.disconnect() - this.#observer = null + this._observer?.disconnect() + this._observer = null this.#toggleSnipUI() } }