Source: lyrics.js

"use strict";
// SPDX-License-Identifier: GPL-3.0-or-later
// myMPD (c) 2018-2024 Juergen Mang <mail@jcgames.de>
// https://github.com/jcorporation/mympd

/** @module lyrics_js */

/**
 * Gets the lyrics
 * @param {string} uri song uri
 * @param {HTMLElement} el container element to show the lyrics
 * @returns {void}
 */
function getLyrics(uri, el) {
    if (isValidUri(uri) === false ||
        isStreamUri(uri) === true)
    {
        el.textContent = tn('No lyrics found');
        return;
    }
    setUpdateView(el);
    sendAPI("MYMPD_API_LYRICS_GET", {
        "uri": uri
    }, function(obj) {
        if (obj.error) {
            el.textContent = tn(obj.error.message);
        }
        else if (obj.result.message) {
            el.textContent = tn(obj.result.message);
        }
        else if (obj.result.returnedEntities === 0) {
            el.textContent = tn('No lyrics found');
        }
        else {
            createLyricsTabs(el, obj);
        }
        unsetUpdateView(el);
    }, true);
}

/**
 * Parses the MYMPD_API_LYRICS_GET jsonrpc response
 * @param {HTMLElement} el container element to show the lyrics
 * @param {object} obj jsonrpc response
 * @returns {void}
 */
function createLyricsTabs(el, obj) {
    const lyricsTabs = elCreateEmpty('div', {"class": [ "lyricsTabs", "btn-toolbar"]});
    const lyrics = elCreateEmpty('div', {"class": ["lyricsTextContainer", "mt-3"]});
    const currentLyrics = el.parentNode.getAttribute('id') === 'currentLyrics' ? true : false;
    showSyncedLyrics = false;
    for (let i = 0; i < obj.result.returnedEntities; i++) {
        let ht = obj.result.data[i].desc;
        if (ht !== '' && obj.result.data[i].lang !== '') {
            ht += ' (' + obj.result.data[i].lang + ')';
        }
        else if (obj.result.data[i].lang !== '') {
            ht = obj.result.data[i].lang;
        }
        else {
            ht = i;
        }
        lyricsTabs.appendChild(
            elCreateText('button', {"data-num": i, "class": ["btn", "btn-sm", "btn-outline-secondary", "me-2", "lyricsChangeButton", "text-truncate"],
                "title": (obj.result.data[i].synced === true ? tn('Synced lyrics') : tn('Unsynced lyrics')) + ': ' + ht}, ht)
        );
        if (i === 0) {
            lyricsTabs.lastChild.classList.add('active');
        }

        const div = elCreateEmpty('div', {"class": ["lyricsText"]});
        if (i > 0) {
            div.classList.add('d-none');
        }
        if (currentLyrics === false) {
            //full height for lyrics in song details modal
            div.classList.add('fullHeight');
        }
        if (obj.result.data[i].synced === true) {
            div.classList.add('lyricsSyncedText');
            parseSyncedLyrics(div, obj.result.data[i].text, currentLyrics);
        }
        else {
            parseUnsyncedLyrics(div, obj.result.data[i].text);
        }
        lyrics.appendChild(div);

        if (obj.result.data[i].synced === true) {
            showSyncedLyrics = true;
        }
    }
    const lyricsHeader = elCreateEmpty('div', {"class": ["lyricsHeader", "btn-toolbar", "mt-2"]});
    if (currentLyrics === true) {
        //buttons for lyris in playback view
        lyricsHeader.appendChild(
            elCreateText('button', {"data-title-phrase": "Toggle autoscrolling", "class": ["btn", "btn-sm", "me-2", "active", "d-none", "mi"], "id": "lyricsScroll"}, 'autorenew')
        );
        lyricsHeader.appendChild(
            elCreateText('button', {"data-title-phrase": "Resize", "class": ["btn", "btn-sm", "me-2", "active", "mi"], "id": "lyricsResize"}, 'aspect_ratio')
        );
    }
    elClear(el);
    if (obj.result.returnedEntities > 1) {
        //more then one result - show tabs
        lyricsHeader.appendChild(lyricsTabs);
        el.appendChild(lyricsHeader);
        el.appendChild(lyrics);
        el.querySelector('.lyricsTabs').addEventListener('click', function(event) {
            if (event.target.nodeName === 'BUTTON') {
                event.target.parentNode.querySelector('.active').classList.remove('active');
                event.target.classList.add('active');
                const nr = Number(event.target.getAttribute('data-num'));
                const tEls = el.querySelectorAll('.lyricsText');
                for (let i = 0, j = tEls.length; i < j; i++) {
                    if (i === nr) {
                        elShow(tEls[i]);
                    }
                    else {
                        elHide(tEls[i]);
                    }
                }
            }
        }, false);
    }
    else {
        el.appendChild(lyricsHeader);
        el.appendChild(lyrics);
    }
    if (currentLyrics === true) {
        if (showSyncedLyrics === true) {
            const ls = elGetById('lyricsScroll');
            if (ls !== null) {
                //synced lyrics scrolling button
                elShow(ls);
                ls.addEventListener('click', function(event) {
                    toggleBtn(event.target, undefined);
                    scrollSyncedLyrics = event.target.classList.contains('active');
                }, false);
                //seek to position on click
                const textEls = el.querySelectorAll('.lyricsSyncedText');
                for (let i = 0, j = textEls.length; i < j; i++) {
                    textEls[i].addEventListener('click', function(event) {
                        const sec = event.target.getAttribute('data-sec');
                        if (sec !== null) {
                            sendAPI("MYMPD_API_PLAYER_SEEK_CURRENT", {
                                "seek": Number(sec),
                                "relative": false
                            }, null, false);
                        }
                    }, false);
                }
            }
        }
        //resize button
        const lr = elGetById('lyricsResize');
        if (lr !== null) {
            lr.addEventListener('click', function(event) {
                toggleBtn(event.target, undefined);
                const mh = event.target.classList.contains('active') ? '16rem' : 'unset';
                const lt = document.querySelectorAll('.lyricsText');
                for (const l of lt) {
                    l.style.maxHeight = mh;
                }
            }, false);
        }
    }
}

/**
 * Parses unsynced lyrics
 * @param {HTMLElement} parent element to append the lyrics
 * @param {string} text the lyrics
 * @returns {void}
 */
function parseUnsyncedLyrics(parent, text) {
    for (const line of text.replace(/\r/g, '').split('\n')) {
        parent.appendChild(
            document.createTextNode(line)
        );
        parent.appendChild(
            elCreateEmpty('br', {})
        );
    }
}

/**
 * Parses synced lyrics (lrc format)
 * @param {HTMLElement} parent element to append the lyrics
 * @param {string} lyrics the lyrics
 * @param {boolean} currentLyrics true = lyrics in playback view, false lyrics in song details modal
 * @returns {void}
 */
function parseSyncedLyrics(parent, lyrics, currentLyrics) {
    for (const line of lyrics.replace(/\r/g, '').split('\n')) {
        //line must start with timestamp
        const elements = line.match(/^\[(\d+):(\d+)\.\d+\](.*)$/);
        if (elements) {
            const ts = [Number(elements[1]) * 60 + Number(elements[2])];
            const text = [];
            //support of timestamps per word
            const words = elements[3].split(/(<\d+:\d+\.\d+>)/);
            for (const word of words) {
                let timestamp;
                if ((timestamp = word.match(/^<(\d+):(\d+)\.\d+>$/)) !== null) {
                    ts.push(Number(timestamp[1]) * 60 + Number(timestamp[2]));
                }
                else {
                    text.push(word);
                }
            }
            if (text.length === 0) {
                //handle empty lines
                text.push(' ');
            }
            const p = elCreateEmpty('p', {});
            for (let i = 0, j = ts.length; i < j; i++) {
                const span = elCreateText('span', {"data-sec": ts[i], "title": fmtSongDuration(ts[i])}, text[i]);
                if (currentLyrics === true) {
                    span.classList.add('clickable');
                }
                p.appendChild(span);
            }
            parent.appendChild(p);
        }
    }
}