diff --git a/__TEST__/hyperaudio-lite.test.js b/__TEST__/hyperaudio-lite.test.js index 7fdda67..0d3e55a 100644 --- a/__TEST__/hyperaudio-lite.test.js +++ b/__TEST__/hyperaudio-lite.test.js @@ -11,6 +11,18 @@ const { HyperaudioLite } = require("../js/hyperaudio-lite"); let wordArr = []; let ht = null; + + +test("initialization with parameters", () => { + const customHt = new HyperaudioLite("hypertranscript", "hyperplayer", true, true, true, true, true); + + expect(customHt.minimizedMode).toBe(true); + expect(customHt.autoscroll).toBe(true); + expect(customHt.doubleClick).toBe(true); + expect(customHt.webMonetization).toBe(true); + expect(customHt.playOnClick).toBe(true); +}); + function createWordArrayResult(words) { for (let i = 0; i < words.length; ++i) { const m = parseInt(words[i].getAttribute("data-m")); @@ -59,7 +71,8 @@ document.body.innerHTML = window.HTMLMediaElement.prototype.play = () => { /* does nothing */ -}; +} + test("instantiation - options false", () => { let minimizedMode = false; @@ -77,6 +90,8 @@ test("instantiation - options false", () => { ); }); + + test("createWordArray", () => { const words = document.querySelectorAll("[data-m]"); const expectedResult = createWordArrayResult(words); @@ -131,11 +146,6 @@ test("instantiation - doubleClick true", () => { ); }); -test("transcript - doubleClick on word", () => { - simulateClick(document.getElementsByTagName("span")[4], "dblclick"); - expect(ht.player.currentTime).toStrictEqual(4.75); -}); - test("instantiation - webMonetization true", () => { let minimizedMode = false; let autoScroll = false; @@ -152,6 +162,11 @@ test("instantiation - webMonetization true", () => { ); }); +test("transcript - doubleClick on word", () => { + simulateClick(document.getElementsByTagName("span")[4], "dblclick"); + expect(ht.player.currentTime).toStrictEqual(4.75); +}); + // This test always passes - fix it /*test("transcript - payment pointer inserted", () => { simulateClick(document.getElementsByTagName("span")[4], "click"); @@ -177,3 +192,120 @@ test("transcript - check that active is set on paragraph", () => { simulateClick(document.getElementsByTagName("span")[4], "dblclick"); expect(document.querySelector('p.active')).toBe(document.getElementsByTagName('p')[0]); }); + +test("setupTranscriptHash with no hash", () => { + window.location.hash = ""; + ht.setupTranscriptHash(); + expect(ht.hashArray).toEqual([]); +}); + +test("setupTranscriptHash with valid hash", () => { + window.location.hash = "#hypertranscript=10,20"; + ht.setupTranscriptHash(); + expect(ht.hashArray).toEqual(["10", "20"]); +}); + +test("getSelectionRange with no selection", () => { + window.getSelection().removeAllRanges(); + expect(ht.getSelectionRange()).toBeNull(); +}); + +test("getSelectionRange with valid selection", () => { + const firstSpan = document.querySelector('span[data-m="880"]'); + const lastSpan = document.querySelector('span[data-m="4750"]'); + const range = document.createRange(); + range.setStartBefore(firstSpan); + range.setEndAfter(lastSpan); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + + expect(ht.getSelectionRange()).toBe("0.88,5.21"); +}); + +test("clearActiveClasses removes all active classes", () => { + const spans = document.querySelectorAll('span'); + spans.forEach(span => span.classList.add('active')); + + ht.clearActiveClasses(); + + spans.forEach(span => { + expect(span.classList.contains('active')).toBe(false); + }); +}); + +test("scrollToParagraph updates parentElementIndex", () => { + ht.parentElementIndex = 0; + ht.scrollToParagraph(1, 6); + expect(ht.parentElementIndex).toBe(1); +}); + +test("checkPaymentPointer returns correct payment pointer", () => { + const p1 = document.getElementById('p1'); + expect(ht.checkPaymentPointer(p1)).toBe("payment-pointer"); +}); + +test("checkPaymentPointer returns null for element without payment pointer", () => { + const p2 = document.querySelectorAll('p')[1]; + expect(ht.checkPaymentPointer(p2)).toBeNull(); +}); + +test("updateTranscriptVisualState marks words as read", () => { + ht.updateTranscriptVisualState(5); + const spans = document.querySelectorAll('span'); + expect(spans[0].classList.contains('read')).toBe(true); + expect(spans[4].classList.contains('read')).toBe(true); + expect(spans[5].classList.contains('unread')).toBe(true); +}); + +test("setPlayHead updates currentTime and plays if playOnClick is true", () => { + ht.playOnClick = true; + ht.myPlayer = { setTime: jest.fn(), play: jest.fn(), paused: true }; + + const event = { target: document.querySelector('span[data-m="3950"]') }; + ht.setPlayHead(event); + + expect(ht.myPlayer.setTime).toHaveBeenCalledWith(3.95); + expect(ht.myPlayer.play).toHaveBeenCalled(); +}); + +test("preparePlayHead sets paused to false and calls checkPlayHead", () => { + ht.checkPlayHead = jest.fn(); + ht.preparePlayHead(); + + expect(ht.myPlayer.paused).toBe(false); + expect(ht.checkPlayHead).toHaveBeenCalled(); +}); + +test("pausePlayHead clears timer and sets paused to true", () => { + jest.useFakeTimers(); + ht.timer = setTimeout(() => {}, 1000); + ht.pausePlayHead(); + + expect(ht.myPlayer.paused).toBe(true); + expect(ht.timer).toBeFalsy(); + jest.useRealTimers(); +}); + +// This test requires jest.useFakeTimers() to work properly +test("checkStatus schedules next check", () => { + jest.useFakeTimers(); + ht.myPlayer = { + paused: false, + getTime: jest.fn().mockResolvedValue(5) + }; + ht.updateTranscriptVisualState = jest.fn().mockReturnValue({ currentWordIndex: 4, currentParentElementIndex: 0 }); + ht.scrollToParagraph = jest.fn(); + ht.checkPlayHead = jest.fn(); + + ht.checkStatus(); + + jest.runAllTimers(); + + expect(ht.checkPlayHead).toHaveBeenCalled(); + + jest.useRealTimers(); +}); + + + + diff --git a/js/hyperaudio-lite.js b/js/hyperaudio-lite.js index f427a92..8677c17 100644 --- a/js/hyperaudio-lite.js +++ b/js/hyperaudio-lite.js @@ -1,317 +1,346 @@ /*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */ -/*! Version 2.2.3 */ +/*! Version 2.3.0 */ 'use strict'; -function nativePlayer(instance) { - this.player = instance.player; - this.player.addEventListener('pause', instance.pausePlayHead, false); - this.player.addEventListener('play', instance.preparePlayHead, false); - this.paused = true; +// Base player class to handle common player functionality +class BasePlayer { + constructor(instance) { + this.player = this.initPlayer(instance); // Initialize the player + this.paused = true; // Set initial paused state + if (this.player) { + this.attachEventListeners(instance); // Attach event listeners for play and pause + } + } - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.currentTime); - }); + // Method to initialize the player - to be implemented by subclasses + initPlayer(instance) { + throw new Error('initPlayer method should be implemented by subclasses'); + } + + // Method to attach common event listeners + attachEventListeners(instance) { + this.player.addEventListener('pause', instance.pausePlayHead.bind(instance), false); + this.player.addEventListener('play', instance.preparePlayHead.bind(instance), false); + } + + // Method to get the current time of the player + getTime() { + return Promise.resolve(this.player.currentTime); } - this.setTime = (seconds) => { + // Method to set the current time of the player + setTime(seconds) { this.player.currentTime = seconds; } - this.play = () => { + // Method to play the media + play() { this.player.play(); this.paused = false; } - this.pause = () => { + // Method to pause the media + pause() { this.player.pause(); this.paused = true; } } -function soundcloudPlayer(instance) { - this.player = SC.Widget(instance.player.id); - this.player.bind(SC.Widget.Events.PAUSE, instance.pausePlayHead); - this.player.bind(SC.Widget.Events.PLAY, instance.preparePlayHead); - this.paused = true; +// Class for native HTML5 player +class NativePlayer extends BasePlayer { + // Initialize the native HTML5 player + initPlayer(instance) { + return instance.player; + } +} - this.getTime = () => { - return new Promise((resolve) => { - this.player.getPosition(ms => { - resolve(ms / 1000); - }); - }); +// Class for SoundCloud player +class SoundCloudPlayer extends BasePlayer { + // Initialize the SoundCloud player + initPlayer(instance) { + return SC.Widget(instance.player.id); } - this.setTime = (seconds) => { - this.player.seekTo(seconds * 1000); + // Attach event listeners specific to SoundCloud player + attachEventListeners(instance) { + this.player.bind(SC.Widget.Events.PAUSE, instance.pausePlayHead.bind(instance)); + this.player.bind(SC.Widget.Events.PLAY, instance.preparePlayHead.bind(instance)); } - this.play = () => { - this.player.play(); - this.paused = false; + // Get the current time of the SoundCloud player + getTime() { + return new Promise(resolve => { + this.player.getPosition(ms => resolve(ms / 1000)); + }); } - this.pause = () => { - this.player.pause(); - this.paused = true; + // Set the current time of the SoundCloud player + setTime(seconds) { + this.player.seekTo(seconds * 1000); } } -function videojsPlayer(instance) { - this.player = videojs.getPlayer(instance.player.id); - this.player.addEventListener('pause', instance.pausePlayHead, false); - this.player.addEventListener('play', instance.preparePlayHead, false); - this.paused = true; - - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.currentTime()); - }); +// Class for VideoJS player +class VideoJSPlayer extends BasePlayer { + // Initialize the VideoJS player + initPlayer(instance) { + return videojs.getPlayer(instance.player.id); } - this.setTime = (seconds) => { - this.player.currentTime(seconds); + // Get the current time of the VideoJS player + getTime() { + return Promise.resolve(this.player.currentTime()); } - this.play = () => { - this.player.play(); - this.paused = false; - } - - this.pause = () => { - this.player.pause(); - this.paused = true; + // Set the current time of the VideoJS player + setTime(seconds) { + this.player.currentTime(seconds); } } -function vimeoPlayer(instance) { - const iframe = document.querySelector('iframe'); - this.player = new Vimeo.Player(iframe); - this.player.setCurrentTime(0); - this.paused = true; - this.player.ready().then(instance.checkPlayHead); - this.player.on('play',instance.preparePlayHead); - this.player.on('pause',instance.pausePlayHead); - - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.getCurrentTime()); - }); +// Class for Vimeo player +class VimeoPlayer extends BasePlayer { + // Initialize the Vimeo player + initPlayer(instance) { + const iframe = document.querySelector('iframe'); + return new Vimeo.Player(iframe); } - this.setTime = (seconds) => { - this.player.setCurrentTime(seconds); + // Attach event listeners specific to Vimeo player + attachEventListeners(instance) { + this.player.ready().then(instance.checkPlayHead.bind(instance)); + this.player.on('play', instance.preparePlayHead.bind(instance)); + this.player.on('pause', instance.pausePlayHead.bind(instance)); } - this.play = () => { - this.player.play(); - this.paused = false; + // Get the current time of the Vimeo player + getTime() { + return this.player.getCurrentTime(); } - this.pause = () => { - this.player.pause(); - this.paused = true; + // Set the current time of the Vimeo player + setTime(seconds) { + this.player.setCurrentTime(seconds); } } -function youtubePlayer(instance) { - const tag = document.createElement('script'); - tag.id = 'iframe-demo'; - tag.src = 'https://www.youtube.com/iframe_api'; - const firstScriptTag = document.getElementsByTagName('script')[0]; - firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); - this.paused = true; - - const previousYTEvent = window.onYouTubeIframeAPIReady; - window.onYouTubeIframeAPIReady = () => { - if (typeof previousYTEvent !== 'undefined') { // used for multiple YouTube players - previousYTEvent(); +// Class for YouTube player +class YouTubePlayer extends BasePlayer { + // Initialize the YouTube player by loading YouTube IFrame API + initPlayer(instance) { + // Defer attaching event listeners until the player is ready + this.isReady = false; + + // Load the YouTube IFrame API script + if (!document.getElementById('iframe-demo')) { + const tag = document.createElement('script'); + tag.id = 'iframe-demo'; + tag.src = 'https://www.youtube.com/iframe_api'; + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); } + // Set the global callback for the YouTube IFrame API + window.onYouTubeIframeAPIReady = this.onYouTubeIframeAPIReady.bind(this, instance); + } + + // Callback when YouTube IFrame API is ready + onYouTubeIframeAPIReady(instance) { this.player = new YT.Player(instance.player.id, { events: { - onStateChange: onPlayerStateChange, - }, + onStateChange: this.onPlayerStateChange.bind(this, instance), + onReady: this.onPlayerReady.bind(this) + } }); - }; + } - let onPlayerStateChange = event => { - if (event.data === 1) { - // playing + // Event handler when the YouTube player is ready + onPlayerReady() { + this.isReady = true; + } + + // Handle YouTube player state changes (play, pause) + onPlayerStateChange(instance, event) { + if (event.data === YT.PlayerState.PLAYING) { // Playing instance.preparePlayHead(); this.paused = false; - } else if (event.data === 2) { - // paused + } else if (event.data === YT.PlayerState.PAUSED) { // Paused instance.pausePlayHead(); this.paused = true; } - }; - - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.getCurrentTime()); - }); - } - - this.setTime = (seconds) => { - this.player.seekTo(seconds, true); - } - - this.play = () => { - this.player.playVideo(); } - this.pause = () => { - this.player.pauseVideo(); + // Get the current time of the YouTube player + getTime() { + if (this.isReady) { + return Promise.resolve(this.player.getCurrentTime()); + } else { + return Promise.resolve(0); // Return 0 if the player is not ready + } } -} - -// Note – The Spotify Player is in beta. -// The API limits us to: -// 1. A seek accuracy of nearest second -// 2. An update frequency of one second (although a workaround is provided) -// 3. Playing a file without previous iteraction will always play from start -// ie – a shared selection will highlight but not start at the start of -// that selection. - -function spotifyPlayer(instance) { - this.currentTime = 0; - this.paused = true; - this.player = null; - - window.onSpotifyIframeApiReady = IFrameAPI => { - const element = document.getElementById(instance.player.id); - - const extractEpisodeID = (url) => { - const match = url.match(/episode\/(.+)$/); - return match ? match[1] : null; + // Set the current time of the YouTube player + setTime(seconds) { + if (this.isReady) { + this.player.seekTo(seconds, true); } + } - const subSample = (sampleInterval) => { - this.currentTime += sampleInterval; + // Play the YouTube video + play() { + if (this.isReady) { + this.player.playVideo(); } + } - const srcValue = element.getAttribute('src'); - const episodeID = extractEpisodeID(srcValue); - - const options = { - uri: `spotify:episode:${episodeID}`, + // Pause the YouTube video + pause() { + if (this.isReady) { + this.player.pauseVideo(); } + } +} - const callback = player => { - this.player = player; - player.addListener('playback_update', e => { - if (e.data.isPaused !== true) { - this.currentTime = e.data.position / 1000; - let currentSample = 0; - let totalSample = 0; - let sampleInterval = 0.25; - - while (totalSample < 1){ - currentSample += sampleInterval; - setTimeout(subSample, currentSample*1000, sampleInterval); - totalSample = currentSample + sampleInterval; +// Class for Spotify player +class SpotifyPlayer extends BasePlayer { + // Initialize the Spotify player by setting up the Spotify IFrame API + initPlayer(instance) { + window.onSpotifyIframeApiReady = IFrameAPI => { + const element = document.getElementById(instance.player.id); + const srcValue = element.getAttribute('src'); + const episodeID = this.extractEpisodeID(srcValue); + + const options = { uri: `spotify:episode:${episodeID}` }; + const callback = player => { + this.player = player; + player.addListener('playback_update', e => { + if (e.data.isPaused !== true) { + this.currentTime = e.data.position / 1000; + instance.preparePlayHead(); + this.paused = false; + } else { + instance.pausePlayHead(); + this.paused = true; } + }); - instance.preparePlayHead(); - this.paused = false; - } else { - instance.pausePlayHead(); - this.paused = true; - } - }); + player.addListener('ready', () => { + player.togglePlay(); // Priming the playhead + instance.checkPlayHead(); + }); + }; - player.addListener('ready', () => { - // With the Spotify API we need to play before we seek. - // Although togglePlay should autoplay it doesn't, - // but lets us prime the playhead. - player.togglePlay(); - instance.checkPlayHead(); - }); + IFrameAPI.createController(element, options, callback); }; - - IFrameAPI.createController(element, options, callback); } - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.currentTime); - }); + // Extract episode ID from the Spotify URL + extractEpisodeID(url) { + const match = url.match(/episode\/(.+)$/); + return match ? match[1] : null; } - this.setTime = (seconds) => { + // Get the current time of the Spotify player + getTime() { + return Promise.resolve(this.currentTime); + } + + // Set the current time of the Spotify player + setTime(seconds) { this.player.seek(seconds); } - this.play = () => { + // Play the Spotify track + play() { this.player.play(); this.paused = false; } - this.pause = () => { + // Pause the Spotify track + pause() { this.player.togglePlay(); this.paused = true; } } +// Mapping player types to their respective classes const hyperaudioPlayerOptions = { - "native": nativePlayer, - "soundcloud": soundcloudPlayer, - "youtube": youtubePlayer, - "videojs": videojsPlayer, - "vimeo": vimeoPlayer, - "spotify": spotifyPlayer -} - + "native": NativePlayer, + "soundcloud": SoundCloudPlayer, + "youtube": YouTubePlayer, + "videojs": VideoJSPlayer, + "vimeo": VimeoPlayer, + "spotify": SpotifyPlayer +}; + +// Factory function to create player instances function hyperaudioPlayer(playerType, instance) { - if (playerType !== null && playerType !== undefined) { - return new playerType(instance); + if (playerType) { + return new hyperaudioPlayerOptions[playerType](instance); } else { - console.warn("HYPERAUDIO LITE WARNING: data-player-type attribute should be set on player if not native, eg SoundCloud, YouTube, Vimeo, VideoJS"); + console.warn("HYPERAUDIO LITE WARNING: data-player-type attribute should be set on player if not native, e.g., SoundCloud, YouTube, Vimeo, VideoJS"); } } +// Main class for HyperaudioLite functionality class HyperaudioLite { constructor(transcriptId, mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) { this.transcript = document.getElementById(transcriptId); this.init(mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick); - } - init = (mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) => { + // Ensure correct binding for class methods + this.preparePlayHead = this.preparePlayHead.bind(this); + this.pausePlayHead = this.pausePlayHead.bind(this); + this.setPlayHead = this.setPlayHead.bind(this); + this.checkPlayHead = this.checkPlayHead.bind(this); + this.clearTimer = this.clearTimer.bind(this); + } + + // Initialize the HyperaudioLite instance + init(mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) { + this.setupTranscriptHash(); + this.setupPopover(); + this.setupPlayer(mediaElementId); + this.setupTranscriptWords(); + this.setupEventListeners(doubleClick, playOnClick); + this.setupInitialPlayHead(); + this.minimizedMode = minimizedMode; + this.autoscroll = autoscroll; + this.webMonetization = webMonetization; + } + // Setup hash for transcript selection + setupTranscriptHash() { const windowHash = window.location.hash; const hashVar = windowHash.substring(1, windowHash.indexOf('=')); - if (hashVar === this.transcript.id) { this.hashArray = windowHash.substring(this.transcript.id.length + 2).split(','); } else { this.hashArray = []; } + } + // Setup the popover for text selection + setupPopover() { this.transcript.addEventListener('mouseup', () => { - const selection = window.getSelection(); const popover = document.getElementById('popover'); let selectionText; - - if (selection.toString().length > 0) { + if (selection.toString().length > 0) { selectionText = selection.toString().replaceAll("'", "`"); - const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); - + popover.style.left = `${rect.left + window.scrollX}px`; popover.style.top = `${rect.bottom + window.scrollY}px`; popover.style.display = 'block'; const mediaFragment = this.getSelectionMediaFragment(); - if (mediaFragment !== null) { + if (mediaFragment) { document.location.hash = mediaFragment; } } else { @@ -321,7 +350,7 @@ class HyperaudioLite { const popoverBtn = document.getElementById('popover-btn'); popoverBtn.addEventListener('click', (e) => { popover.style.display = 'none'; - let cbText = selectionText + " " + document.location; + let cbText = `${selectionText} ${document.location}`; navigator.clipboard.writeText(cbText); const dialog = document.getElementById("clipboard-dialog"); @@ -329,96 +358,73 @@ class HyperaudioLite { dialog.showModal(); const confirmButton = document.getElementById("clipboard-confirm"); - confirmButton.addEventListener("click", () => { - dialog.close(); - }); - + confirmButton.addEventListener("click", () => dialog.close()); + e.preventDefault(); return false; }); - }); + } - this.minimizedMode = minimizedMode; - this.textShot = ''; - this.wordIndex = 0; - - this.autoscroll = autoscroll; - this.scrollerContainer = this.transcript; - this.scrollerOffset = 0; - this.scrollerDuration = 800; - this.scrollerDelay = 0; - - this.doubleClick = doubleClick; - this.webMonetization = webMonetization; - this.playOnClick = playOnClick; - this.highlightedText = false; - this.start = null; - - this.myPlayer = null; - this.playerPaused = true; - - if (this.autoscroll === true) { - this.scroller = window.Velocity || window.jQuery.Velocity; - } - - //Create the array of timed elements (wordArr) - - const words = this.transcript.querySelectorAll('[data-m]'); - this.wordArr = this.createWordArray(words); - this.parentTag = words[0].parentElement.tagName; - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + // Setup the media player + setupPlayer(mediaElementId) { this.player = document.getElementById(mediaElementId); - - // Grab the media source and type from the first section if it exists - // and add it to the media element. - const mediaSrc = this.transcript.querySelector('[data-media-src]'); - - if (mediaSrc !== null && mediaSrc !== undefined) { + if (mediaSrc) { this.player.src = mediaSrc.getAttribute('data-media-src'); } - if (this.player.tagName == 'VIDEO' || this.player.tagName == 'AUDIO') { - //native HTML media elements + if (this.player.tagName === 'VIDEO' || this.player.tagName === 'AUDIO') { this.playerType = 'native'; } else { - //assume it is a SoundCloud or YouTube iframe this.playerType = this.player.getAttribute('data-player-type'); } - this.myPlayer = hyperaudioPlayer(hyperaudioPlayerOptions[this.playerType], this); - this.parentElementIndex = 0; - words[0].classList.add('active'); - //this.parentElements[0].classList.add('active'); - let playHeadEvent = 'click'; + this.myPlayer = hyperaudioPlayer(this.playerType, this); + } - if (this.doubleClick === true) { - playHeadEvent = 'dblclick'; - } + // Setup the transcript words + setupTranscriptWords() { + const words = this.transcript.querySelectorAll('[data-m]'); + this.wordArr = this.createWordArray(words); + this.parentTag = words[0].parentElement.tagName; + this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + } - this.transcript.addEventListener(playHeadEvent, this.setPlayHead, false); - this.transcript.addEventListener(playHeadEvent, this.checkPlayHead, false); + // Setup event listeners for interactions + setupEventListeners(doubleClick, playOnClick) { + this.minimizedMode = false; + this.autoscroll = false; + this.doubleClick = doubleClick; + this.webMonetization = false; + this.playOnClick = playOnClick; + this.highlightedText = false; + this.start = null; - this.start = this.hashArray[0]; + if (this.autoscroll) { + this.scroller = window.Velocity || window.jQuery.Velocity; + } - //check for URL based start and stop times + const playHeadEvent = doubleClick ? 'dblclick' : 'click'; + this.transcript.addEventListener(playHeadEvent, this.setPlayHead.bind(this), false); + this.transcript.addEventListener(playHeadEvent, this.checkPlayHead.bind(this), false); + } + // Setup initial playhead position based on URL hash + setupInitialPlayHead() { + this.start = this.hashArray[0]; if (!isNaN(parseFloat(this.start))) { this.highlightedText = true; - let indices = this.updateTranscriptVisualState(this.start); - let index = indices.currentWordIndex; - - if (index > 0) { - this.scrollToParagraph(indices.currentParentElementIndex, index); + if (indices.currentWordIndex > 0) { + this.scrollToParagraph(indices.currentParentElementIndex, indices.currentWordIndex); } } this.end = this.hashArray[1]; - //TODO convert to binary search for below for quicker startup if (this.start && this.end) { + const words = this.transcript.querySelectorAll('[data-m]'); for (let i = 1; i < words.length; i++) { let startTime = parseInt(words[i].getAttribute('data-m')) / 1000; let wordStart = (Math.round(startTime * 100) / 100).toFixed(2); @@ -427,30 +433,23 @@ class HyperaudioLite { } } } - }; // end init - - createWordArray = words => { - let wordArr = []; + } - words.forEach((word, i) => { + // Create an array of words with metadata from the transcript + createWordArray(words) { + return Array.from(words).map(word => { const m = parseInt(word.getAttribute('data-m')); let p = word.parentNode; while (p !== document) { - if ( - p.tagName.toLowerCase() === 'p' || - p.tagName.toLowerCase() === 'figure' || - p.tagName.toLowerCase() === 'ul' - ) { + if (['p', 'figure', 'ul'].includes(p.tagName.toLowerCase())) { break; } p = p.parentNode; } - wordArr[i] = { n: words[i], m: m, p: p }; - wordArr[i].n.classList.add('unread'); + word.classList.add('unread'); + return { n: word, m, p }; }); - - return wordArr; - }; + } getSelectionRange = () => { const selection = window.getSelection(); @@ -463,11 +462,11 @@ class HyperaudioLite { while (node && node.nodeType !== Node.ELEMENT_NODE) { node = node.parentNode; } - return node.closest('span[data-m][data-d]'); + return node.closest('[data-m]'); } // Get all relevant spans - const allSpans = Array.from(document.querySelectorAll('span[data-m][data-d]')); + const allSpans = Array.from(this.transcript.querySelectorAll('[data-m]')); // Find the first and last span that contain selected text let startSpan = null; @@ -496,7 +495,16 @@ class HyperaudioLite { let startTime = parseInt(startSpan.dataset.m) / 1000; // Calculate end time - let endTime = (parseInt(endSpan.dataset.m) + parseInt(endSpan.dataset.d)) / 1000; + + let duration = 0; + if (endSpan.dataset.d) { + duration = parseInt(endSpan.dataset.d); + } else { + // when no duration exists default to 1 second + duration = 1000; + } + + let endTime = (parseInt(endSpan.dataset.m) + duration) / 1000; // Format to seconds at 2 decimal place precision let startTimeFormatted = (Math.round(startTime * 100) / 100).toFixed(2); @@ -514,187 +522,153 @@ class HyperaudioLite { return (this.transcript.id + '=' +range); } - setPlayHead = e => { - const target = e.target ? e.target : e.srcElement; - - // cancel highlight playback + // Set the playhead position in the media player based on the transcript + setPlayHead(e) { + const target = e.target || e.srcElement; this.highlightedText = false; + this.clearActiveClasses(); - // clear elements with class='active' - let activeElements = Array.from(this.transcript.getElementsByClassName('active')); - - activeElements.forEach(e => { - e.classList.remove('active'); - }); - - if (this.myPlayer.paused === true && target.getAttribute('data-m') !== null) { + if (this.myPlayer.paused && target.dataset.m) { target.classList.add('active'); target.parentNode.classList.add('active'); } - const timeSecs = parseInt(target.getAttribute('data-m')) / 1000; + const timeSecs = parseInt(target.dataset.m) / 1000; this.updateTranscriptVisualState(timeSecs); - if (!isNaN(parseFloat(timeSecs))) { + if (!isNaN(timeSecs)) { this.end = null; this.myPlayer.setTime(timeSecs); - if (this.playOnClick === true) { + if (this.playOnClick) { this.myPlayer.play(); } } - }; + } - clearTimer = () => { - if (this.timer) clearTimeout(this.timer); - }; + // Clear the active classes from the transcript + clearActiveClasses() { + const activeElements = Array.from(this.transcript.getElementsByClassName('active')); + activeElements.forEach(e => e.classList.remove('active')); + } - preparePlayHead = () => { + // Prepare the playhead for playback + preparePlayHead() { this.myPlayer.paused = false; this.checkPlayHead(); } - pausePlayHead = () => { + // Pause the playhead + pausePlayHead() { this.clearTimer(); this.myPlayer.paused = true; } - checkPlayHead = () => { - + // Check the playhead position and update the transcript + checkPlayHead() { this.clearTimer(); - (async (instance) => { - instance.currentTime = await instance.myPlayer.getTime(); - - if (instance.highlightedText === true) { - instance.currentTime = instance.start; - instance.myPlayer.setTime(instance.currentTime); - instance.highlightedText = false; + (async () => { + this.currentTime = await this.myPlayer.getTime(); + if (this.highlightedText) { + this.currentTime = this.start; + this.myPlayer.setTime(this.currentTime); + this.highlightedText = false; } - // no need to check status if the currentTime hasn't changed - - instance.checkStatus(); - - })(this); + this.checkStatus(); + })(); } - scrollToParagraph = (currentParentElementIndex, index) => { - let newPara = false; - let scrollNode = this.wordArr[index - 1].n.parentNode; - - if (scrollNode !== null && scrollNode.tagName != 'P') { - // it's not inside a para so just use the element - scrollNode = this.wordArr[index - 1].n; + // Clear the timer for the playhead + clearTimer() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; } + } - if (currentParentElementIndex != this.parentElementIndex) { - - if (typeof this.scroller !== 'undefined' && this.autoscroll === true) { - if (scrollNode !== null) { - if (typeof this.scrollerContainer !== 'undefined' && this.scrollerContainer !== null) { - this.scroller(scrollNode, 'scroll', { - container: this.scrollerContainer, - duration: this.scrollerDuration, - delay: this.scrollerDelay, - offset: this.scrollerOffset, - }); - } else { - this.scroller(scrollNode, 'scroll', { - duration: this.scrollerDuration, - delay: this.scrollerDelay, - offset: this.scrollerOffset, - }); - } + // Scroll to the paragraph containing the current word + scrollToParagraph(currentParentElementIndex, index) { + const scrollNode = this.wordArr[index - 1].n.closest('p') || this.wordArr[index - 1].n; + + if (currentParentElementIndex !== this.parentElementIndex) { + if (this.autoscroll && typeof this.scroller !== 'undefined') { + if (scrollNode) { + this.scroller(scrollNode, 'scroll', { + container: this.scrollerContainer, + duration: this.scrollerDuration, + delay: this.scrollerDelay, + offset: this.scrollerOffset, + }); } else { - // the wordlst needs refreshing - let words = this.transcript.querySelectorAll('[data-m]'); - this.wordArr = this.createWordArray(words); + this.wordArr = this.createWordArray(this.transcript.querySelectorAll('[data-m]')); this.parentElements = this.transcript.getElementsByTagName(this.parentTag); } } - - newPara = true; this.parentElementIndex = currentParentElementIndex; } - return(newPara); } - checkStatus = () => { - //check for end time of shared piece - - let interval = 0; - - if (this.myPlayer.paused === false) { - + // Check the status of the playhead and update the transcript + checkStatus() { + if (!this.myPlayer.paused) { if (this.end && parseInt(this.end) < parseInt(this.currentTime)) { this.myPlayer.pause(); this.end = null; } else { - let newPara = false; - //interval = 0; // used to establish next checkPlayHead - - let indices = this.updateTranscriptVisualState(this.currentTime); - let index = indices.currentWordIndex; - + const indices = this.updateTranscriptVisualState(this.currentTime); + const index = indices.currentWordIndex; if (index > 0) { - newPara = this.scrollToParagraph(indices.currentParentElementIndex, index); + this.scrollToParagraph(indices.currentParentElementIndex, index); } - //minimizedMode is still experimental - it changes document.title upon every new word if (this.minimizedMode) { - const elements = transcript.querySelectorAll('[data-m]'); + const elements = this.transcript.querySelectorAll('[data-m]'); let currentWord = ''; let lastWordIndex = this.wordIndex; for (let i = 0; i < elements.length; i++) { - if ((' ' + elements[i].className + ' ').indexOf(' active ') > -1) { + if (elements[i].classList.contains('active')) { currentWord = elements[i].innerHTML; this.wordIndex = i; } } - let textShot = ''; - - if (this.wordIndex != lastWordIndex) { - textShot = textShot + currentWord; + if (this.wordIndex !== lastWordIndex) { + document.title = currentWord; } + } - if (textShot.length > 16 || newPara === true) { - document.title = textShot; - textShot = ''; - newPara = false; + if (this.webMonetization === true) { + //check for payment pointer + let activeElements = this.transcript.getElementsByClassName('active'); + let paymentPointer = this.checkPaymentPointer(activeElements[activeElements.length - 1]); + + if (paymentPointer !== null) { + let metaElements = document.getElementsByTagName('meta'); + let wmMeta = document.querySelector("meta[name='monetization']"); + if (wmMeta === null) { + wmMeta = document.createElement('meta'); + wmMeta.name = 'monetization'; + wmMeta.content = paymentPointer; + document.getElementsByTagName('head')[0].appendChild(wmMeta); + } else { + wmMeta.name = 'monetization'; + wmMeta.content = paymentPointer; + } } } + let interval = 0; if (this.wordArr[index]) { - interval = parseInt(this.wordArr[index].n.getAttribute('data-m') - this.currentTime * 1000); - } - } - if (this.webMonetization === true) { - //check for payment pointer - let activeElements = this.transcript.getElementsByClassName('active'); - let paymentPointer = this.checkPaymentPointer(activeElements[activeElements.length - 1]); - - if (paymentPointer !== null) { - let metaElements = document.getElementsByTagName('meta'); - let wmMeta = document.querySelector("meta[name='monetization']"); - if (wmMeta === null) { - wmMeta = document.createElement('meta'); - wmMeta.name = 'monetization'; - wmMeta.content = paymentPointer; - document.getElementsByTagName('head')[0].appendChild(wmMeta); - } else { - wmMeta.name = 'monetization'; - wmMeta.content = paymentPointer; - } + interval = this.wordArr[index].n.getAttribute('data-m') - this.currentTime * 1000; } + + this.timer = setTimeout(() => this.checkPlayHead(), interval + 1); } - this.timer = setTimeout(() => { - this.checkPlayHead(); - }, interval + 1); // +1 to avoid rounding issues (better to be over than under) } else { this.clearTimer(); } - }; + } checkPaymentPointer = element => { let paymentPointer = null; @@ -718,39 +692,34 @@ class HyperaudioLite { return this.checkPaymentPointer(parent); } } - }; - - updateTranscriptVisualState = (currentTime) => { + } + // Update the visual state of the transcript based on the current time + updateTranscriptVisualState(currentTime) { let index = 0; let words = this.wordArr.length - 1; - // Binary search https://en.wikipedia.org/wiki/Binary_search_algorithm while (index <= words) { - const guessIndex = index + ((words - index) >> 1); // >> 1 has the effect of halving and rounding down - const difference = this.wordArr[guessIndex].m / 1000 - currentTime; // wordArr[guessIndex].m represents start time of word + const guessIndex = index + ((words - index) >> 1); + const difference = this.wordArr[guessIndex].m / 1000 - currentTime; if (difference < 0) { - // comes before the element index = guessIndex + 1; } else if (difference > 0) { - // comes after the element words = guessIndex - 1; } else { - // equals the element index = guessIndex; break; } } this.wordArr.forEach((word, i) => { - let classList = word.n.classList; - let parentClassList = word.n.parentNode.classList; + const classList = word.n.classList; + const parentClassList = word.n.parentNode.classList; if (i < index) { classList.add('read'); - classList.remove('unread'); - classList.remove('active'); + classList.remove('unread', 'active'); parentClassList.remove('active'); } else { classList.add('unread'); @@ -758,66 +727,26 @@ class HyperaudioLite { } }); - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); - - //remove active class from all paras - Array.from(this.parentElements).forEach(el => { - if (el.classList.contains('active')) { - el.classList.remove('active'); - } - }); - - // set current word and para to active + Array.from(this.parentElements).forEach(el => el.classList.remove('active')); if (index > 0) { - if (this.myPlayer.paused === false) { + if (!this.myPlayer.paused) { this.wordArr[index - 1].n.classList.add('active'); } + this.wordArr[index - 1].n.parentNode.classList.add('active'); + } - if (this.wordArr[index - 1].n.parentNode !== null) { - this.wordArr[index - 1].n.parentNode.classList.add('active'); - } - } - - // Establish current paragraph index - let currentParentElementIndex; - - Array.from(this.parentElements).every((el, i) => { - if (el.classList.contains('active')) { - currentParentElementIndex = i; - return false; - } - return true; - }); + const currentParentElementIndex = Array.from(this.parentElements).findIndex(el => el.classList.contains('active')); - let indices = { + return { currentWordIndex: index, - currentParentElementIndex: currentParentElementIndex, + currentParentElementIndex }; - - return indices; - }; - - setScrollParameters = (duration, delay, offset, container) => { - this.scrollerContainer = container; - this.scrollerDuration = duration; - this.scrollerDelay = delay; - this.scrollerOffset = offset; - }; - - toggleAutoScroll = () => { - this.autoscroll = !this.autoscroll; - }; - - setAutoScroll = state => { - this.autoscroll = state; - }; + } } -// required for testing +// Export for testing or module usage if (typeof module !== 'undefined' && module.exports) { module.exports = { HyperaudioLite }; -} - -//export default HyperaudioLite; \ No newline at end of file +} \ No newline at end of file diff --git a/package.json b/package.json index a306bb0..5c658bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "Hyperaudio-Lite", - "version": "2.1.3", + "version": "2.3.0", + "license": "MIT", "devDependencies": { "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0"