From ac52a7f108eafbb693c1964437268214b9f35a04 Mon Sep 17 00:00:00 2001 From: Neeraj Kumar Das Date: Mon, 28 Oct 2024 19:37:33 +0530 Subject: [PATCH 01/20] Make CueSync a web component - Refactored cuesync.js to define as a web component - Added Shadow DOM encapsulation and custom attributes - Updated initialization to register CueSync with customElements API - Introduced `theme` attribute with support for light, dark, and auto modes - Added language selection dropdown for handling multiple transcript languages - Provided layout options (stacked, paragraph) to toggle transcript display style - Added settings menu to allow user interaction with customization options --- js/src/cuesync.js | 779 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 649 insertions(+), 130 deletions(-) diff --git a/js/src/cuesync.js b/js/src/cuesync.js index 6780f17..934dacb 100644 --- a/js/src/cuesync.js +++ b/js/src/cuesync.js @@ -5,64 +5,423 @@ * -------------------------------------------------------------------------- */ -import BaseComponent from './helpers/base-component.js' +export default class CueSync extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }) -const NAME = 'cuesync' + this._timeMaxWidth = 0 + this._pendingRefresh = false + this._config = {} + this._languages = [] + } -class CueSync extends BaseComponent { - constructor(element, config) { - super(element) + static get observedAttributes() { + return ['transcript-path', 'media', 'layout', 'show-timestamp', 'auto-scroll', 'allow-settings', 'theme'] + } - this._element = element - this._config = this._getConfig(config) - this._autoScroll = true - this._timeMaxWidth = 0 - this.refresh() + static get styles() { + return ` + :host, + :host([theme=light]) { + color-scheme: light; + --cs-border-color: #e9e9e9; + --cs-toolbar-bg: #fff; + --cs-toolbar-color: #000; + --cs-container-bg: #fff; + --cs-container-color: #000; + --cs-transcript-hover-bg: #f2f2f2; + --cs-transcript-hover-color: #000; + --cs-transcript-active-bg: #def1ff; + --cs-transcript-active-color: #044ba7; + --cs-transcript-highlight-bg: transparent; + --cs-transcript-highlight-color: #044ba7; + --cs-timestamp-bg: #def1ff; + --cs-timestamp-color: #044ba7; + } + + /* Dark mode */ + :host([theme=dark]) { + color-scheme: dark; + --cs-border-color: #495057; + --cs-toolbar-bg: #16191d; + --cs-toolbar-color: #ced4da; + --cs-container-bg: #16191d; + --cs-container-color: #ced4da; + --cs-transcript-hover-bg: #252525; + --cs-transcript-hover-color: #ced4da; + --cs-transcript-active-bg: #032b48; + --cs-transcript-active-color: #def1ff; + --cs-transcript-highlight-bg: transparent; + --cs-transcript-highlight-color: #def1ff; + --cs-timestamp-bg: #032b48; + --cs-timestamp-color: #def1ff; + } + + /* Media query for users without theme attribute but with OS-level dark mode */ + @media (prefers-color-scheme: dark) { + :host { + color-scheme: dark; + --cs-border-color: #495057; + --cs-toolbar-bg: #16191d; + --cs-toolbar-color: #ced4da; + --cs-container-bg: #16191d; + --cs-container-color: #ced4da; + --cs-transcript-hover-bg: #252525; + --cs-transcript-hover-color: #ced4da; + --cs-transcript-active-bg: #032b48; + --cs-transcript-active-color: #def1ff; + --cs-transcript-highlight-bg: transparent; + --cs-transcript-highlight-color: #def1ff; + --cs-timestamp-bg: #032b48; + --cs-timestamp-color: #def1ff; + } + } + :host { + --cs-border-width: 1px; + --cs-border-style: solid; + --cs-border-radius: 10px; + --cs-toolbar-padding-x: 15px; + --cs-toolbar-padding-y: 15px; + --cs-container-padding-x: 15px; + --cs-container-padding-y: 5px; + --cs-transcript-padding-x: 5px; + --cs-transcript-padding-y: 5px; + --cs-transcript-border-radius: var(--cs-border-radius); + --cs-timestamp-padding-x: 5px; + --cs-timestamp-padding-y: 5px; + --cs-timestamp-border-radius: 5px; + --cs-timestamp-width: auto; + box-sizing: border-box; + display: block; + width: auto; + min-width: 0; + height: auto; + min-height: 0; + padding: 0; + margin: 0; + background: transparent; + border: 0; + } + + .wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + border: var(--cs-border-width) var(--cs-border-style) var(--cs-border-color); + border-radius: var(--cs-border-radius); + } + + #toolbar { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: var(--cs-toolbar-padding-y) var(--cs-toolbar-padding-x); + color: var(--cs-toolbar-color); + background-color: var(--cs-toolbar-bg); + } + + #toolbar h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + } + + #settings-toggle { + padding: 0; + font-size: 1.2rem; + cursor: pointer; + background: none; + border: none; + } + + #settings-toggle svg { + display: block; + width: 1em; + height: 1em; + vertical-align: -0.125em; + } + + #settings-menu { + position: absolute; + top: 100%; + right: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 10px; + color: var(--cs-toolbar-color); + background-color: var(--cs-toolbar-bg); + border-radius: var(--cs-border-radius); + box-shadow: 0 4px 32px 0 rgba(0, 0, 0, 0.1); + } + + #settings-menu[hidden] { + display: none; + } + + #transcript-container { + flex-grow: 1; + overflow-y: auto; + color: var(--cs-container-color); + background-color: var(--cs-container-bg); + } + + .transcript-line { + display: inline-flex; + flex-direction: column; + padding: var(--cs-transcript-padding-y) var(--cs-transcript-padding-x); + } + + .transcript-line-container { + display: flex; + gap: 5px; + align-items: center; + padding: var(--cs-container-padding-y) var(--cs-container-padding-x); + cursor: pointer; + border-radius: var(--cs-tanscript-line-border-radius); + } + + .transcript-line-container.paragraph { + display: inline-flex; + } + + .time { + display: inline-block; + flex-shrink: 0; + width: var(--cs-timestamp-width); + padding: var(--cs-timestamp-padding-y) var(--cs-timestamp-padding-x); + color: var(--cs-timestamp-color); + text-align: center; + white-space: nowrap; + background-color: var(--cs-timestamp-bg); + border-radius: var(--cs-timestamp-border-radius); + } + + #transcript-container.active-added .transcript-line-container { + color: var(--cs-transcript-highlight-color); + background-color: var(--cs-transcript-highlight-bg); + } + + #transcript-container .transcript-line-container.active { + color: var(--cs-transcript-active-color); + background-color: var(--cs-transcript-active-bg); + } + + #transcript-container .transcript-line-container.active ~ .transcript-line-container { + color: var(--cs-container-color); + background-color: var(--cs-container-bg); + } + + #transcript-container .transcript-line-container:not(.active):hover { + color: var(--cs-transcript-hover-color); + background-color: var(--cs-transcript-hover-bg); + } + + #language-options, + #layout-options, + #theme-options { + display: flex; + gap: 10px; + align-items: center; + } + ` } - static get NAME() { - return NAME + _getConfig() { + return { + transcriptPath: this.getAttribute('transcript-path')?.split(','), + media: document.querySelector(this.getAttribute('media')), + layout: this.getAttribute('layout') === 'paragraph' ? 'paragraph' : 'stacked', // Default to 'stacked' + showTimestamp: this.getAttribute('show-timestamp') !== 'false', // Default to true + autoScroll: this.getAttribute('auto-scroll') !== 'false', // Default to true + allowSettings: this.getAttribute('allow-settings') !== 'false', // Default to true + theme: this.getAttribute('theme') === 'light' || this.getAttribute('theme') === 'dark' ? this.getAttribute('theme') : 'auto' // Default to 'auto' + } } - async refresh() { - let transcripts = [] - let cuesCollection = [] + connectedCallback() { + this._config = this._getConfig() + this._renderComponent() + this._requestRefresh() + } - // Create an array of transcript file paths - const transcriptFilePaths = this._getTranscriptFilePaths() + attributeChangedCallback(name, oldValue, newValue) { + this._updateConfig(name, newValue) - // Create an array of transcript file contents - if (transcriptFilePaths.length) { - transcripts = await Promise.all( - transcriptFilePaths.map(t => this._fetchTranscript(t)) - ) - } else { - throw new Error('No transcript file paths found') + // Dispatch a custom event based on the attribute that was changed + this._dispatchCustomEvent(name, newValue) + + this._requestRefresh() + } + + _updateConfig(name, value) { + const updateMap = { + 'transcript-path': () => { + this._config.transcriptPath = value.split(',') + }, + media: () => { + this._config.media = document.querySelector(value) + }, + layout: () => { + this._config.layout = value === 'paragraph' ? 'paragraph' : 'stacked' + }, + 'show-timestamp': () => { + this._config.showTimestamp = value !== 'false' + }, + 'auto-scroll': () => { + this._config.autoScroll = value !== 'false' + }, + 'allow-settings': () => { + this._config.allowSettings = value !== 'false' + }, + theme: () => { + this._config.theme = value === 'light' || value === 'dark' ? value : 'auto' + } } - // Create an array of parsed transcripts - if (transcripts.length) { - cuesCollection = transcripts.map(t => this._parseTranscript(t)) - } else { - throw new Error('No transcript content retrieved') + if (updateMap[name]) { + updateMap[name]() } + } - // Create transcript lines and add them to the container - if (cuesCollection.length) { - this._createTranscriptLines(cuesCollection) + _dispatchCustomEvent(name, newValue) { + const eventName = `${name}-changed` // e.g., 'show-timestamp-changed' + const eventDetail = { newValue } // Include the new value in the event detail - if (this._timeMaxWidth) { - this._element.style.setProperty('--cs-time-width', `${this._timeMaxWidth}px`) - } + const event = new CustomEvent(eventName, { + detail: eventDetail, + bubbles: true, // Allow the event to bubble up the DOM + composed: true // Allow the event to pass through shadow DOM boundaries + }) - this._element.addEventListener('scroll', () => { - if (this._autoScroll) { - this._autoScroll = false - } + this.dispatchEvent(event) + } + + _renderComponent() { + const { allowSettings } = this._config + const settingsHTML = allowSettings ? + ` + ` : + '' + + this.shadowRoot.innerHTML = ` + +
+
+

Transcript

+ ${settingsHTML} +
+
+
+ ` + } + + _requestRefresh() { + if (!this._pendingRefresh) { + this._pendingRefresh = true + requestAnimationFrame(() => { + this._refresh() + this._pendingRefresh = false }) - } else { + } + } + + async _refresh() { + try { + this._timeMaxWidth = 0 + this._languages = [] + + const transcripts = await this._loadTranscripts() + const cuesCollection = this._parseTranscripts(transcripts) + + this._applyConfiguration(cuesCollection) + } catch (error) { + console.error(error.message) // eslint-disable-line no-console + throw error + } + } + + async _loadTranscripts() { + const transcriptFilePaths = this._getTranscriptFilePaths() + if (!transcriptFilePaths.length) { + throw new Error('No transcript file paths found') + } + + return Promise.all(transcriptFilePaths.map(t => this._fetchTranscript(t))) + } + + _parseTranscripts(transcripts) { + if (!transcripts.length) { + throw new Error('No transcript content retrieved') + } + + const cuesCollection = transcripts.map(t => this._parseTranscript(t)) + if (!cuesCollection.length) { throw new Error('No cues parsed from transcripts') } + + return cuesCollection + } + + _applyConfiguration(cuesCollection) { + const { allowSettings, media } = this._config + + if (allowSettings) { + this._setupSettings() + } + + this._createTranscriptLines(cuesCollection) + this._addMediaEventListener(media, cuesCollection) + + this._setTimeMaxWidth() + } + + _setupSettings() { + this._initializeLayoutOptions() + this._initializeTimestampToggle() + this._initializeAutoScrollToggle() + this._initializeThemes() + + if (this._languages.length > 1) { + this._createLanguageCheckboxes(this._languages) + } + + const settingsToggle = this.shadowRoot.querySelector('#settings-toggle') + const settingsMenu = this.shadowRoot.querySelector('#settings-menu') + + settingsToggle.addEventListener('click', () => { + settingsMenu.hidden = !settingsMenu.hidden + }) + } + + _setTimeMaxWidth() { + if (this._timeMaxWidth) { + this.shadowRoot.querySelector('#transcript-container').style.setProperty('--cs-timestamp-width', `${this._timeMaxWidth}px`) + } } _getTranscriptFilePaths() { @@ -94,16 +453,15 @@ class CueSync extends BaseComponent { } } - // Function to parse SRT or VTT text into cue objects _parseTranscript(transcriptText) { const cues = [] const lines = transcriptText.split('\n') let cue = null + cues.language = null for (let line of lines) { line = line.trim() if (!line) { - // Empty line if (cue) { cues.push(cue) cue = null @@ -112,16 +470,19 @@ class CueSync extends BaseComponent { continue } + if (line.toLowerCase().startsWith('language:')) { + cues.language = line.split(':')[1].trim() + this._languages.push(cues.language) + continue + } + if (!cue && /^\d+$/.test(line)) { - // This is a line number (SRT format) continue } else if (line.includes('-->')) { - // Parse cue timing (both SRT and VTT formats) const [startTime, endTime] = line.split(/ --> /) cue = new VTTCue(this._convertToSeconds(startTime), this._convertToSeconds(endTime), '') cue.startTimeRaw = this._minimalTime(startTime) } else if (cue) { - // Add cue text (both SRT and VTT formats) cue.text += `${line} ` } } @@ -135,171 +496,329 @@ class CueSync extends BaseComponent { _convertToSeconds(time) { const [hours, minutes, seconds] = time.split(/:|,/).map(Number.parseFloat) - return ((hours * 3600) + (minutes * 60) + seconds).toFixed(2) } _minimalTime(time) { const [hours, minutes, seconds] = time.split(/:|,/).map(Number.parseFloat) - return `${hours === 0 ? '' : `${hours} : `} ${minutes} : ${Math.trunc(seconds)}` } _createTranscriptLines(cuesCollection) { - const { media, displayTime } = this._config + const { media, layout, showTimestamp } = this._config if (!Array.isArray(cuesCollection) || cuesCollection.length === 0) { throw new Error('Invalid cuesCollection provided') } const cues = cuesCollection[0] + const container = this.shadowRoot.querySelector('#transcript-container') + container.innerHTML = '' for (const [index, cue] of cues.entries()) { const line = document.createElement('div') line.className = 'transcript-line' - // Create a document fragment to combine text safely const fragment = document.createDocumentFragment() - // Combine text from all cue arrays for (const cueArray of cuesCollection) { if (cueArray[index]) { - const textNode = document.createTextNode(`${cueArray[index].text.trim()}`) - fragment.append(textNode) - fragment.append(document.createElement('br')) + const span = document.createElement('span') + span.className = `transcript-${cueArray.language || 'default'}` + span.textContent = cueArray[index].text.trim() + fragment.append(span) } } - // Remove the trailing
if (fragment.lastChild?.nodeName === 'BR') { fragment.lastChild.remove() } line.append(fragment) - if (displayTime) { - const transcriptLineContainer = document.createElement('div') - transcriptLineContainer.className = 'transcript-line-container' - transcriptLineContainer.setAttribute('aria-label', cue.text.trim()) - transcriptLineContainer.setAttribute('role', 'button') - transcriptLineContainer.tabIndex = 0 + const transcriptLineContainer = document.createElement('div') + transcriptLineContainer.className = `transcript-line-container${layout === 'paragraph' ? ' paragraph' : ''}` + transcriptLineContainer.setAttribute('aria-label', cue.text.trim()) + transcriptLineContainer.setAttribute('role', 'button') + transcriptLineContainer.tabIndex = 0 - const timeContainer = document.createElement('span') - timeContainer.className = 'time' - timeContainer.textContent = cue.startTimeRaw + const timeContainer = document.createElement('span') + timeContainer.className = 'time' + timeContainer.textContent = cue.startTimeRaw + timeContainer.style.display = showTimestamp ? 'inline-block' : 'none' - transcriptLineContainer.append(timeContainer) - transcriptLineContainer.append(line) + transcriptLineContainer.append(timeContainer, line) + container.append(transcriptLineContainer) - this._element.append(transcriptLineContainer) + this._addTranscriptEventListeners(transcriptLineContainer, media, cue.startTime) - this._addTranscriptEventListeners(transcriptLineContainer, media, cue.startTime) + const styles = window.getComputedStyle(timeContainer) + const padding = Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight) + const border = Number.parseFloat(styles.borderLeftWidth) + Number.parseFloat(styles.borderRightWidth) - const timeWidth = timeContainer.getBoundingClientRect().width - if (timeWidth > this._timeMaxWidth) { - this._timeMaxWidth = timeWidth - } - } else { - line.setAttribute('aria-label', cue.text.trim()) - line.setAttribute('role', 'button') - line.tabIndex = 0 + const timeWidth = timeContainer.getBoundingClientRect().width - padding - border + if (timeWidth > this._timeMaxWidth) { + this._timeMaxWidth = timeWidth + } + } + } - this._element.append(line) + _initializeLayoutOptions() { + const stackedOption = this.shadowRoot.querySelector('input[type="radio"][value="stacked"]') + const paragraphOption = this.shadowRoot.querySelector('input[type="radio"][value="paragraph"]') - this._addTranscriptEventListeners(line, media, cue.startTime) - } + if (this._config.layout === 'paragraph') { + paragraphOption.checked = true + } else { + stackedOption.checked = true + } + + paragraphOption.addEventListener('change', () => { + this._toggleLayout('paragraph') + + this._dispatchCustomEvent('layout', 'paragraph') + }) + + stackedOption.addEventListener('change', () => { + this._toggleLayout('stacked') + + this._dispatchCustomEvent('layout', 'stacked') + }) + } + + _toggleLayout(style) { + const transcriptLineContainers = this.shadowRoot.querySelectorAll('.transcript-line-container') - // Update transcript highlighting based on media time - this._addMediaEventListener(line, cues, cue, index) + for (const container of transcriptLineContainers) { + container.classList.toggle('paragraph', style === 'paragraph') } + + this._config.layout = style } - _scroll(line) { - if (this._autoScroll) { - this._scrollToView(line) + _initializeThemes() { + const autoOption = this.shadowRoot.querySelector('input[type="radio"][value="auto"]') + const lightOption = this.shadowRoot.querySelector('input[type="radio"][value="light"]') + const darkOption = this.shadowRoot.querySelector('input[type="radio"][value="dark"]') + + if (this._config.theme === 'light') { + lightOption.checked = true + } else if (this._config.theme === 'dark') { + darkOption.checked = true } else { - const parentRect = this._element.getBoundingClientRect() - const elementRect = line.getBoundingClientRect() + autoOption.checked = true + } + + autoOption.addEventListener('change', () => { + this._setTheme('auto') + + this._dispatchCustomEvent('theme', 'auto') + }) + + lightOption.addEventListener('change', () => { + this._setTheme('light') + + this._dispatchCustomEvent('theme', 'light') + }) + + darkOption.addEventListener('change', () => { + this._setTheme('dark') + + this._dispatchCustomEvent('theme', 'dark') + }) + } - if (elementRect.top >= parentRect.top && elementRect.bottom <= parentRect.bottom) { - this._autoScroll = true + _setTheme(theme) { + this.setAttribute('theme', theme) + } + + _initializeTimestampToggle() { + const timestampCheckbox = this.shadowRoot.getElementById('timestamp-toggle') + timestampCheckbox.checked = this._config.showTimestamp + + timestampCheckbox.addEventListener('change', () => { + const timestamps = this.shadowRoot.querySelectorAll('.time') + for (const timeElement of timestamps) { + timeElement.style.display = timestampCheckbox.checked ? 'inline-block' : 'none' } + + this._config.showTimestamp = timestampCheckbox.checked + + this._dispatchCustomEvent('show-timestamp', timestampCheckbox.checked) + }) + } + + _initializeAutoScrollToggle() { + const autoScrollCheckbox = this.shadowRoot.getElementById('auto-scroll-toggle') + autoScrollCheckbox.checked = this._config.autoScroll + + autoScrollCheckbox.addEventListener('change', () => { + this._config.autoScroll = autoScrollCheckbox.checked + + this._dispatchCustomEvent('auto-scroll', autoScrollCheckbox.checked) + }) + } + + _createLanguageCheckboxes(languages) { + const languageSelectDiv = this.shadowRoot.getElementById('language-options') + languageSelectDiv.innerHTML = '' + + const label = document.createElement('label') + label.textContent = 'Languages:' + languageSelectDiv.append(label) + + for (const lang of languages) { + const checkboxLabel = document.createElement('label') + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.value = lang + checkbox.checked = true + + checkboxLabel.append(checkbox) + checkboxLabel.append(document.createTextNode(lang)) + + languageSelectDiv.append(checkboxLabel) + } + + const checkboxes = languageSelectDiv.querySelectorAll('input[type="checkbox"]') + for (const checkbox of checkboxes) { + checkbox.addEventListener('change', () => { + const selectedLanguages = Array.from(checkboxes) + .filter(input => input.checked) + .map(input => input.value) + + // If this is the last checkbox being unchecked, prevent it + if (selectedLanguages.length === 0) { + checkbox.checked = true // Re-check the last checkbox + return + } + + this._updateLayout(selectedLanguages) + }) + } + } + + _updateLayout(selectedLanguages) { + for (const span of this.shadowRoot.querySelectorAll('.transcript-line span')) { + const languageClass = span.classList[0] // e.g., 'transcript-en' + const language = languageClass.slice(11) // extract language code e.g., 'en' + + span.style.display = selectedLanguages.includes(language) ? '' : 'none' + } + } + + _scroll(line) { + const container = this.shadowRoot.querySelector('#transcript-container') + const { top: containerTop } = container.getBoundingClientRect() + const { top: elementTop, height: elementHeight } = line.getBoundingClientRect() + + // Calculate the offset for the second visible line + const secondLineOffset = elementHeight + + // Calculate the ideal top position for the active line to "lock" it in the second line + const lockPosition = containerTop + secondLineOffset + + if (elementTop !== lockPosition) { + this._scrollToLock(line, lockPosition) } } - _scrollToView(element) { - const parent = element.closest('.transcript-container') + _scrollToLock(element, lockPosition) { + const parent = element.closest('#transcript-container') if (!parent) { - console.error('Parent .transcript-container not found.') // eslint-disable-line no-console + console.error('Parent #transcript-container not found.') // eslint-disable-line no-console return } - const parentRect = parent.getBoundingClientRect() - const elementRect = element.getBoundingClientRect() + const { top: elementTop } = element.getBoundingClientRect() - // Check if the element is above or below the visible area - const isAbove = elementRect.top < parentRect.top - const isBelow = elementRect.bottom > parentRect.bottom + // Calculate how much to scroll to keep the element at the locked position + const offset = elementTop - lockPosition - if (isAbove) { - // Scroll up to make the element visible at the top - parent.scrollTo({ - top: parent.scrollTop + (elementRect.top - parentRect.top), + // Scroll the parent container by the calculated offset + if (offset !== 0) { + parent.scrollBy({ + top: offset, left: 0, - behavior: 'smooth' - }) - } else if (isBelow) { - // Scroll down to make the element visible at the bottom - parent.scrollTo({ - top: parent.scrollTop + (elementRect.bottom - parentRect.bottom), - left: 0, - behavior: 'smooth' + behavior: 'auto' }) } } - _addMediaEventListener(line, cues, cue, index) { - const { media, displayTime } = this._config + _addTranscriptEventListeners(element, media, time) { + const setMediaTime = () => { + media.currentTime = time + } + + const handleEvent = e => { + if (e.type === 'click' || (e.type === 'keypress' && e.key === 'Enter')) { + setMediaTime() + } + } + + element.addEventListener('click', handleEvent) + element.addEventListener('keypress', handleEvent) + } + + _addMediaEventListener(media, cuesCollection) { + if (!media || !cuesCollection.length) { + return + } - const updateClasses = (isActive, isPlayed) => { - const container = displayTime ? line.closest('.transcript-line-container') : line + const cues = cuesCollection[0] + const transcriptLines = Array.from(this.shadowRoot.querySelectorAll('.transcript-line')) + const transcriptContainer = this.shadowRoot.querySelector('#transcript-container') + let activeCueIndex = -1 // Pointer to track the active cue + const updateActiveLine = (index, isActive) => { + const line = transcriptLines[index] + const container = line.closest('.transcript-line-container') if (container) { container.classList.toggle('active', isActive) - container.classList.toggle('played', isPlayed) } } media.addEventListener('timeupdate', () => { const { currentTime } = media - const isActive = currentTime >= cue.startTime && (index === cues.length - 1 || currentTime < cue.endTime) - const isPlayed = currentTime >= cue.startTime - updateClasses(isActive, isPlayed) + // Identify the new active cue index + let newActiveIndex = activeCueIndex - if (isActive) { - this._scroll(line) + for (let i = 0; i < cues.length; i++) { + const { startTime, endTime } = cues[i] + if (currentTime >= startTime && (i === cues.length - 1 || currentTime < endTime)) { + newActiveIndex = i + break + } } - }) - } - _addTranscriptEventListeners(element, media, time) { - const setMediaTime = () => { - media.currentTime = time - } + // Update only if there's a change in the active cue + if (newActiveIndex !== activeCueIndex) { + if (activeCueIndex >= 0) { + updateActiveLine(activeCueIndex, false) + } - element.addEventListener('click', setMediaTime) + activeCueIndex = newActiveIndex - element.addEventListener('keypress', e => { - if (e.key === 'Enter') { - setMediaTime() + if (activeCueIndex >= 0) { + updateActiveLine(activeCueIndex, true) + + // Add the 'active-added' class once the media starts + if (!transcriptContainer.classList.contains('active-added')) { + transcriptContainer.classList.add('active-added') + } + + if (this._config.autoScroll) { + this._scroll(transcriptLines[activeCueIndex]) + } + } } }) } redrawTime() { - const timeElements = this._element.querySelectorAll('.time') + const timeElements = this.shadowRoot.querySelectorAll('.time') if (timeElements.length === 0) { return @@ -314,8 +833,8 @@ class CueSync extends BaseComponent { } } - this._element.style.setProperty('--cs-time-width', `${maxWidth}px`) + this.shadowRoot.querySelector('#transcript-container').style.setProperty('--cs-timestamp-width', `${maxWidth}px`) } } -export default CueSync +customElements.define('cue-sync', CueSync) From ae2c83a42f4450e299d5ef0ec291f6875cd2bafe Mon Sep 17 00:00:00 2001 From: Neeraj Kumar Das Date: Thu, 7 Nov 2024 19:09:38 +0530 Subject: [PATCH 02/20] Remove an event listener before adding it, to avoid having duplicate event listeners --- js/src/cuesync.js | 201 +++++++++++++++++++++++++--------------------- 1 file changed, 109 insertions(+), 92 deletions(-) diff --git a/js/src/cuesync.js b/js/src/cuesync.js index 934dacb..faef93d 100644 --- a/js/src/cuesync.js +++ b/js/src/cuesync.js @@ -85,6 +85,8 @@ export default class CueSync extends HTMLElement { --cs-toolbar-padding-y: 15px; --cs-container-padding-x: 15px; --cs-container-padding-y: 5px; + --cs-container-paragraph-padding-x: 5px; + --cs-container-paragraph-padding-y: 5px; --cs-transcript-padding-x: 5px; --cs-transcript-padding-y: 5px; --cs-transcript-border-radius: var(--cs-border-radius); @@ -188,6 +190,7 @@ export default class CueSync extends HTMLElement { .transcript-line-container.paragraph { display: inline-flex; + padding: var(--cs-container-paragraph-padding-y) var(--cs-container-paragraph-padding-x); } .time { @@ -256,7 +259,9 @@ export default class CueSync extends HTMLElement { // Dispatch a custom event based on the attribute that was changed this._dispatchCustomEvent(name, newValue) - this._requestRefresh() + if (!['theme', 'layout', 'show-timestamp', 'auto-scroll'].includes(name)) { + this._requestRefresh() + } } _updateConfig(name, value) { @@ -411,11 +416,15 @@ export default class CueSync extends HTMLElement { } const settingsToggle = this.shadowRoot.querySelector('#settings-toggle') + + settingsToggle.removeEventListener('click', this._toggleSettingsMenu) + settingsToggle.addEventListener('click', this._toggleSettingsMenu) + } + + _toggleSettingsMenu = () => { const settingsMenu = this.shadowRoot.querySelector('#settings-menu') - settingsToggle.addEventListener('click', () => { - settingsMenu.hidden = !settingsMenu.hidden - }) + settingsMenu.hidden = !settingsMenu.hidden } _setTimeMaxWidth() { @@ -573,27 +582,24 @@ export default class CueSync extends HTMLElement { stackedOption.checked = true } - paragraphOption.addEventListener('change', () => { - this._toggleLayout('paragraph') - - this._dispatchCustomEvent('layout', 'paragraph') - }) - - stackedOption.addEventListener('change', () => { - this._toggleLayout('stacked') + paragraphOption.removeEventListener('change', this._toggleLayout) + paragraphOption.addEventListener('change', this._toggleLayout) - this._dispatchCustomEvent('layout', 'stacked') - }) + stackedOption.removeEventListener('change', this._toggleLayout) + stackedOption.addEventListener('change', this._toggleLayout) } - _toggleLayout(style) { + _toggleLayout = () => { + const layout = this.shadowRoot.querySelector('input[name="layout"]:checked').value const transcriptLineContainers = this.shadowRoot.querySelectorAll('.transcript-line-container') for (const container of transcriptLineContainers) { - container.classList.toggle('paragraph', style === 'paragraph') + container.classList.toggle('paragraph', layout === 'paragraph') } - this._config.layout = style + this._config.layout = layout + + this._dispatchCustomEvent('layout', layout) } _initializeThemes() { @@ -609,54 +615,57 @@ export default class CueSync extends HTMLElement { autoOption.checked = true } - autoOption.addEventListener('change', () => { - this._setTheme('auto') - - this._dispatchCustomEvent('theme', 'auto') - }) - - lightOption.addEventListener('change', () => { - this._setTheme('light') + autoOption.removeEventListener('change', this._setTheme) + lightOption.removeEventListener('change', this._setTheme) + darkOption.removeEventListener('change', this._setTheme) - this._dispatchCustomEvent('theme', 'light') - }) - - darkOption.addEventListener('change', () => { - this._setTheme('dark') - - this._dispatchCustomEvent('theme', 'dark') - }) + autoOption.addEventListener('change', this._setTheme) + lightOption.addEventListener('change', this._setTheme) + darkOption.addEventListener('change', this._setTheme) } - _setTheme(theme) { + _setTheme = () => { + const theme = this.shadowRoot.querySelector('input[name="theme"]:checked').value + this.setAttribute('theme', theme) + + this._dispatchCustomEvent('theme', theme) } _initializeTimestampToggle() { const timestampCheckbox = this.shadowRoot.getElementById('timestamp-toggle') timestampCheckbox.checked = this._config.showTimestamp - timestampCheckbox.addEventListener('change', () => { - const timestamps = this.shadowRoot.querySelectorAll('.time') - for (const timeElement of timestamps) { - timeElement.style.display = timestampCheckbox.checked ? 'inline-block' : 'none' - } + timestampCheckbox.removeEventListener('change', this._toggleTimestamps) + timestampCheckbox.addEventListener('change', this._toggleTimestamps) + } - this._config.showTimestamp = timestampCheckbox.checked + _toggleTimestamps = () => { + const timestampCheckbox = this.shadowRoot.getElementById('timestamp-toggle') + const timestamps = this.shadowRoot.querySelectorAll('.time') - this._dispatchCustomEvent('show-timestamp', timestampCheckbox.checked) - }) + for (const timeElement of timestamps) { + timeElement.style.display = timestampCheckbox.checked ? 'inline-block' : 'none' + } + + this._config.showTimestamp = timestampCheckbox.checked + + this._dispatchCustomEvent('show-timestamp', timestampCheckbox.checked) } _initializeAutoScrollToggle() { const autoScrollCheckbox = this.shadowRoot.getElementById('auto-scroll-toggle') autoScrollCheckbox.checked = this._config.autoScroll - autoScrollCheckbox.addEventListener('change', () => { - this._config.autoScroll = autoScrollCheckbox.checked + autoScrollCheckbox.removeEventListener('change', this._toggleAutoScroll) + autoScrollCheckbox.addEventListener('change', this._toggleAutoScroll) + } - this._dispatchCustomEvent('auto-scroll', autoScrollCheckbox.checked) - }) + _toggleAutoScroll = () => { + const autoScrollCheckbox = this.shadowRoot.getElementById('auto-scroll-toggle') + this._config.autoScroll = autoScrollCheckbox.checked + + this._dispatchCustomEvent('auto-scroll', autoScrollCheckbox.checked) } _createLanguageCheckboxes(languages) { @@ -682,20 +691,27 @@ export default class CueSync extends HTMLElement { const checkboxes = languageSelectDiv.querySelectorAll('input[type="checkbox"]') for (const checkbox of checkboxes) { - checkbox.addEventListener('change', () => { - const selectedLanguages = Array.from(checkboxes) - .filter(input => input.checked) - .map(input => input.value) - - // If this is the last checkbox being unchecked, prevent it - if (selectedLanguages.length === 0) { - checkbox.checked = true // Re-check the last checkbox - return - } + checkbox.removeEventListener('change', this._handleCheckboxChange) + checkbox.addEventListener('change', this._handleCheckboxChange) + } + } - this._updateLayout(selectedLanguages) - }) + _handleCheckboxChange = event => { + const languageSelectDiv = this.shadowRoot.getElementById('language-options') + const checkboxes = languageSelectDiv.querySelectorAll('input[type="checkbox"]') + const checkbox = event.target + + const selectedLanguages = Array.from(checkboxes) + .filter(input => input.checked) + .map(input => input.value) + + // If this is the last checkbox being unchecked, prevent it + if (selectedLanguages.length === 0) { + checkbox.checked = true // Re-check the last checkbox + return } + + this._updateLayout(selectedLanguages) } _updateLayout(selectedLanguages) { @@ -719,18 +735,11 @@ export default class CueSync extends HTMLElement { const lockPosition = containerTop + secondLineOffset if (elementTop !== lockPosition) { - this._scrollToLock(line, lockPosition) + this._scrollToLock(line, lockPosition, container) } } - _scrollToLock(element, lockPosition) { - const parent = element.closest('#transcript-container') - - if (!parent) { - console.error('Parent #transcript-container not found.') // eslint-disable-line no-console - return - } - + _scrollToLock(element, lockPosition, parent) { const { top: elementTop } = element.getBoundingClientRect() // Calculate how much to scroll to keep the element at the locked position @@ -757,6 +766,9 @@ export default class CueSync extends HTMLElement { } } + element.removeEventListener('click', handleEvent) + element.removeEventListener('keypress', handleEvent) + element.addEventListener('click', handleEvent) element.addEventListener('keypress', handleEvent) } @@ -779,42 +791,47 @@ export default class CueSync extends HTMLElement { } } - media.addEventListener('timeupdate', () => { - const { currentTime } = media + if (!this._handleTimeUpdate) { + this._handleTimeUpdate = () => { + const { currentTime } = media - // Identify the new active cue index - let newActiveIndex = activeCueIndex + // Identify the new active cue index + let newActiveIndex = activeCueIndex - for (let i = 0; i < cues.length; i++) { - const { startTime, endTime } = cues[i] - if (currentTime >= startTime && (i === cues.length - 1 || currentTime < endTime)) { - newActiveIndex = i - break + for (let i = 0; i < cues.length; i++) { + const { startTime, endTime } = cues[i] + if (currentTime >= startTime && (i === cues.length - 1 || currentTime < endTime)) { + newActiveIndex = i + break + } } - } - // Update only if there's a change in the active cue - if (newActiveIndex !== activeCueIndex) { - if (activeCueIndex >= 0) { - updateActiveLine(activeCueIndex, false) - } + // Update only if there's a change in the active cue + if (newActiveIndex !== activeCueIndex) { + if (activeCueIndex >= 0) { + updateActiveLine(activeCueIndex, false) + } - activeCueIndex = newActiveIndex + activeCueIndex = newActiveIndex - if (activeCueIndex >= 0) { - updateActiveLine(activeCueIndex, true) + if (activeCueIndex >= 0) { + updateActiveLine(activeCueIndex, true) - // Add the 'active-added' class once the media starts - if (!transcriptContainer.classList.contains('active-added')) { - transcriptContainer.classList.add('active-added') - } + // Add the 'active-added' class once the media starts + if (!transcriptContainer.classList.contains('active-added')) { + transcriptContainer.classList.add('active-added') + } - if (this._config.autoScroll) { - this._scroll(transcriptLines[activeCueIndex]) + if (this._config.autoScroll) { + this._scroll(transcriptLines[activeCueIndex]) + } } } } - }) + } + + media.removeEventListener('timeupdate', this._handleTimeUpdate) + media.addEventListener('timeupdate', this._handleTimeUpdate) } redrawTime() { From f0115a5b8762a515c22ac5583d0998a5f75f7557 Mon Sep 17 00:00:00 2001 From: Neeraj Kumar Das Date: Tue, 10 Dec 2024 19:04:12 +0530 Subject: [PATCH 03/20] Hide the settings menu when clicked outside --- js/src/cuesync.js | 61 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/js/src/cuesync.js b/js/src/cuesync.js index faef93d..80a6e32 100644 --- a/js/src/cuesync.js +++ b/js/src/cuesync.js @@ -38,6 +38,7 @@ export default class CueSync extends HTMLElement { --cs-transcript-highlight-color: #044ba7; --cs-timestamp-bg: #def1ff; --cs-timestamp-color: #044ba7; + --cs-settings-shadow-opacity: 0.1; } /* Dark mode */ @@ -56,6 +57,7 @@ export default class CueSync extends HTMLElement { --cs-transcript-highlight-color: #def1ff; --cs-timestamp-bg: #032b48; --cs-timestamp-color: #def1ff; + --cs-settings-shadow-opacity: 1; } /* Media query for users without theme attribute but with OS-level dark mode */ @@ -75,6 +77,7 @@ export default class CueSync extends HTMLElement { --cs-transcript-highlight-color: #def1ff; --cs-timestamp-bg: #032b48; --cs-timestamp-color: #def1ff; + --cs-settings-shadow-opacity: 1; } } :host { @@ -82,7 +85,7 @@ export default class CueSync extends HTMLElement { --cs-border-style: solid; --cs-border-radius: 10px; --cs-toolbar-padding-x: 15px; - --cs-toolbar-padding-y: 15px; + --cs-toolbar-padding-y: 8px; --cs-container-padding-x: 15px; --cs-container-padding-y: 5px; --cs-container-paragraph-padding-x: 5px; @@ -134,11 +137,17 @@ export default class CueSync extends HTMLElement { } #settings-toggle { - padding: 0; + padding: 8px; font-size: 1.2rem; cursor: pointer; background: none; border: none; + border-radius: 50%; + } + + #settings-toggle:hover { + background: var(--cs-timestamp-bg); + color: var(--cs-timestamp-color); } #settings-toggle svg { @@ -154,12 +163,12 @@ export default class CueSync extends HTMLElement { right: 0; display: flex; flex-direction: column; - gap: 8px; + gap: 10px; padding: 20px 10px; color: var(--cs-toolbar-color); background-color: var(--cs-toolbar-bg); border-radius: var(--cs-border-radius); - box-shadow: 0 4px 32px 0 rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 32px 0 rgba(0, 0, 0, var(--cs-settings-shadow-opacity)); } #settings-menu[hidden] { @@ -229,7 +238,7 @@ export default class CueSync extends HTMLElement { #layout-options, #theme-options { display: flex; - gap: 10px; + gap: 8px; align-items: center; } ` @@ -318,6 +327,7 @@ export default class CueSync extends HTMLElement { diff --git a/site/layouts/partials/navbar.html b/site/layouts/partials/navbar.html index 3a1f666..3704d3a 100644 --- a/site/layouts/partials/navbar.html +++ b/site/layouts/partials/navbar.html @@ -12,22 +12,22 @@

CueSync