{"version":"1.0.1","templatekey":"pw-poodllaudio","name":"Player - Poodll Audio","instructions":"<p>Insert an audio player or audio link between the tags. The Poodll audio player will take over.</p>","content":"<div id='{{AUTOID}}_container' class=\"poodll_superplayer my-3\">\n  <div id=\"{{AUTOID}}\" class=\"rounded overflow-hidden shadow-sm border\">\n    <div id=\"{{AUTOID}}_image\" class=\"poodll_superplayer_image\"></div>\n    <div id=\"{{AUTOID}}_waveform\"></div>\n    <div id=\"{{AUTOID}}_captions\" class=\"poodll_superplayer_captions d-flex align-items-center justify-content-center text-center small p-2\"></div>\n    <div class=\"d-flex flex-wrap align-items-center gap-1 bg-dark text-white px-2 py-1\">\n      <button type=\"button\" id=\"{{AUTOID}}_restart\" class=\"btn btn-dark btn-sm px-1\"\n              aria-label=\"{{#str}} restart, filter_genericotwo {{/str}}\">\n        <i class=\"fa fa-step-backward\" aria-hidden=\"true\"></i>\n      </button>\n      <button type=\"button\" id=\"{{AUTOID}}_back\" class=\"btn btn-dark btn-sm px-1\"\n              aria-label=\"{{#str}} skipback, filter_genericotwo {{/str}}\">\n        <i class=\"fa fa-backward\" aria-hidden=\"true\"></i>\n      </button>\n      <button type=\"button\" id=\"{{AUTOID}}_playbutton\" class=\"btn btn-dark px-2\"\n              aria-label=\"{{#str}} play, filter_genericotwo {{/str}}\">\n        <i class=\"fa fa-play fa-lg\" aria-hidden=\"true\"></i>\n      </button>\n      <button type=\"button\" id=\"{{AUTOID}}_forward\" class=\"btn btn-dark btn-sm px-1\"\n              aria-label=\"{{#str}} skipforward, filter_genericotwo {{/str}}\">\n        <i class=\"fa fa-forward\" aria-hidden=\"true\"></i>\n      </button>\n      <button type=\"button\" id=\"{{AUTOID}}_toend\" class=\"btn btn-dark btn-sm px-1\"\n              aria-label=\"{{#str}} skiptoend, filter_genericotwo {{/str}}\">\n        <i class=\"fa fa-step-forward\" aria-hidden=\"true\"></i>\n      </button>\n      <div id=\"{{AUTOID}}_time\" class=\"small font-monospace ms-auto\"></div>\n      <div class=\"d-flex align-items-center gap-1\">\n        <button type=\"button\" id=\"{{AUTOID}}_speeddown\" class=\"btn btn-dark btn-sm px-1\"\n                aria-label=\"{{#str}} speeddown, filter_genericotwo {{/str}}\">\n          <i class=\"fa fa-minus\" aria-hidden=\"true\"></i>\n        </button>\n        <div id=\"{{AUTOID}}_rate\" class=\"small font-monospace\">x1.0</div>\n        <button type=\"button\" id=\"{{AUTOID}}_speedup\" class=\"btn btn-dark btn-sm px-1\"\n                aria-label=\"{{#str}} speedup, filter_genericotwo {{/str}}\">\n          <i class=\"fa fa-plus\" aria-hidden=\"true\"></i>\n        </button>\n      </div>\n    </div>\n  </div>\n</div>\n<div id=\"{{AUTOID}}_original\" class=\"poodllplayerwidgetnoshow\" style=\"display: none\">\n{{size}}\n","templateend":"</div>","variabledefaults":"size=big|medium|small ","jscontent":"require(['filter_genericotwo/soundtouch', 'filter_genericotwo/wavesurfer', 'filter_genericotwo/mediaparser'],\n    (soundtouch, WaveSurfer, parser) => {\n\n    const root = document.getElementById('{{AUTOID}}');\n    // The template runs this code twice, so we use a data attribute as a flag to prevent 2x running.\n    if (root.dataset.inited !== undefined) {\n        return;\n    }\n    root.dataset.inited = 'true';\n\n    const imageDisplay = document.getElementById('{{AUTOID}}_image');\n    const captionsDisplay = document.getElementById('{{AUTOID}}_captions');\n    const timeDisplay = document.getElementById('{{AUTOID}}_time');\n    const rateDisplay = document.getElementById('{{AUTOID}}_rate');\n    const playButton = document.getElementById('{{AUTOID}}_playbutton');\n    const playIcon = playButton.querySelector('.fa');\n\n    const secondsToTime = (seconds) => {\n        if (!Number.isFinite(seconds)) {\n            return '00:00:00';\n        }\n        const date = new Date(0);\n        date.setSeconds(seconds);\n        return date.toISOString().substring(11, 19);\n    };\n\n    // Parse a VTT file by loading it into a dummy audio element's caption track.\n    const loadVTT = (vttUrl, callback) => {\n        const dummyPlayer = document.createElement('audio');\n        dummyPlayer.crossOrigin = 'anonymous';\n        const track = document.createElement('track');\n        track.kind = 'captions';\n        track.label = 'English';\n        track.srclang = 'en';\n        track.src = vttUrl;\n        dummyPlayer.appendChild(track);\n        const textTrack = dummyPlayer.textTracks[0];\n        textTrack.mode = 'showing';\n        track.addEventListener('load', () => {\n            callback(textTrack.cues);\n        }, {once: true});\n    };\n\n    const app = {\n        captions: [],\n        seekingPos: null,\n        soundtouchNode: null,\n\n        updateCaption: () => {\n            const currentTime = app.wavesurfer.getCurrentTime();\n            const duration = app.wavesurfer.getDuration();\n            const matches = Array.from(app.captions).filter(\n                (cue) => currentTime > cue.startTime && currentTime < cue.endTime\n            );\n            captionsDisplay.textContent = matches.length ? matches[0].text : '';\n            timeDisplay.textContent = secondsToTime(Math.floor(currentTime)) + ' / ' + secondsToTime(Math.floor(duration));\n        },\n\n        initSoundTouch: () => {\n            app.st = new soundtouch.SoundTouch(app.wavesurfer.backend.ac.sampleRate);\n\n            const buffer = app.wavesurfer.backend.buffer;\n            const channels = buffer.numberOfChannels;\n            // Copy the channel data: Firefox detaches the arrays returned by\n            // getChannelData once playback starts, turning every read into NaN.\n            const left = new Float32Array(buffer.getChannelData(0));\n            const right = channels > 1 ? new Float32Array(buffer.getChannelData(1)) : left;\n            app.length = buffer.length;\n            app.seekingPos = null;\n            let seekingDiff = 0;\n\n            app.source = {\n                extract: (target, numFrames, position) => {\n                    if (app.seekingPos !== null) {\n                        seekingDiff = app.seekingPos - position;\n                        app.seekingPos = null;\n                    }\n                    position += seekingDiff;\n                    for (let i = 0; i < numFrames; i++) {\n                        target[i * 2] = left[i + position];\n                        target[i * 2 + 1] = right[i + position];\n                    }\n                    return Math.max(0, Math.min(numFrames, app.length - position));\n                },\n            };\n        },\n    };\n\n    // Fetch the media details from the wrapped content.\n    const mediaDetails = parser.parse('{{AUTOID}}_original', 'audio');\n\n    // Poster image.\n    if (mediaDetails.imgurl) {\n        imageDisplay.style.backgroundImage = 'url(' + mediaDetails.imgurl + ')';\n    } else {\n        imageDisplay.classList.add('d-none');\n    }\n\n    // Player size from the {{size}} variable, medium if unrecognised.\n    const sizes = {big: 550, medium: 400, small: 300};\n    root.style.maxWidth = (sizes['{{size}}'] || sizes.medium) + 'px';\n\n    app.wavesurfer = WaveSurfer.create({\n        container: '#{{AUTOID}}_waveform',\n        height: 75,\n        cursorColor: 'red',\n        barHeight: 4,\n        barWidth: 4,\n        fillParent: true,\n        responsive: true,\n    });\n    app.wavesurfer.load(mediaDetails.mediaurl);\n\n    // Captions.\n    if (mediaDetails.subtitlesurl) {\n        loadVTT(mediaDetails.subtitlesurl, (captions) => {\n            app.captions = captions;\n        });\n        // Right-to-left languages.\n        if (['ar-AE', 'ar-SA', 'fa-IR', 'he-IL'].includes(mediaDetails.lang)) {\n            captionsDisplay.dir = 'rtl';\n        }\n    } else {\n        captionsDisplay.classList.add('d-none');\n    }\n\n    app.wavesurfer.on('ready', () => {\n        app.updateCaption();\n        app.initSoundTouch();\n    });\n\n    // Fully dispose of the soundtouch node. Disconnecting is not enough: the\n    // analyser keeps feeding it, so a merely disconnected ScriptProcessor keeps\n    // processing (and consuming the source) in the background forever.\n    const killSoundTouchNode = () => {\n        if (app.soundtouchNode) {\n            app.soundtouchNode.onaudioprocess = null;\n            app.soundtouchNode.disconnect();\n            app.soundtouchNode = null;\n        }\n        if (app.st) {\n            app.st.clear();\n        }\n    };\n\n    // Route audio through soundtouch when the rate is not 1, so pitch stays natural.\n    app.wavesurfer.on('play', () => {\n        app.seekingPos = Math.trunc(app.wavesurfer.backend.getPlayedPercents() * app.length);\n        app.st.tempo = app.wavesurfer.getPlaybackRate();\n        if (app.st.tempo === 1) {\n            app.wavesurfer.backend.disconnectFilters();\n            killSoundTouchNode();\n        } else {\n            // A fresh filter each time: seekingPos aligns it to the current position.\n            killSoundTouchNode();\n            const filter = new soundtouch.SimpleFilter(app.source, app.st);\n            app.soundtouchNode = soundtouch.getWebAudioNode(app.wavesurfer.backend.ac, filter);\n            app.wavesurfer.backend.setFilter(app.soundtouchNode);\n        }\n    });\n\n    app.wavesurfer.on('pause', () => {\n        killSoundTouchNode();\n    });\n\n    app.wavesurfer.on('finish', () => {\n        killSoundTouchNode();\n        playIcon.classList.remove('fa-pause');\n        playIcon.classList.add('fa-play');\n    });\n\n    app.wavesurfer.on('audioprocess', app.updateCaption);\n\n    app.wavesurfer.on('seek', () => {\n        app.seekingPos = Math.trunc(app.wavesurfer.backend.getPlayedPercents() * app.length);\n        app.updateCaption();\n    });\n\n    // Transport buttons.\n    document.getElementById('{{AUTOID}}_restart').addEventListener('click', () => {\n        app.wavesurfer.seekTo(0);\n        app.updateCaption();\n    });\n    document.getElementById('{{AUTOID}}_back').addEventListener('click', () => {\n        app.wavesurfer.skipBackward();\n        app.updateCaption();\n    });\n    playButton.addEventListener('click', () => {\n        if (!app.wavesurfer.isPlaying()) {\n            app.wavesurfer.play();\n            playIcon.classList.remove('fa-play');\n            playIcon.classList.add('fa-pause');\n        } else {\n            app.wavesurfer.pause();\n            playIcon.classList.remove('fa-pause');\n            playIcon.classList.add('fa-play');\n        }\n    });\n    document.getElementById('{{AUTOID}}_forward').addEventListener('click', () => {\n        app.wavesurfer.skipForward();\n        app.updateCaption();\n    });\n    document.getElementById('{{AUTOID}}_toend').addEventListener('click', () => {\n        app.wavesurfer.seekTo(1);\n        app.updateCaption();\n    });\n\n    // Speed buttons: 0.6 to 2.0 in 0.1 steps.\n    const showRate = () => {\n        rateDisplay.textContent = 'x' + app.wavesurfer.getPlaybackRate().toFixed(1);\n    };\n    document.getElementById('{{AUTOID}}_speedup').addEventListener('click', () => {\n        const currentRate = app.wavesurfer.getPlaybackRate();\n        if (currentRate < 2) {\n            // Round to one decimal so rates stay exact (1 must equal 1 to disengage soundtouch).\n            app.wavesurfer.setPlaybackRate(Math.round((currentRate + 0.1) * 10) / 10);\n            showRate();\n        }\n    });\n    document.getElementById('{{AUTOID}}_speeddown').addEventListener('click', () => {\n        const currentRate = app.wavesurfer.getPlaybackRate();\n        if (currentRate > 0.6) {\n            app.wavesurfer.setPlaybackRate(Math.round((currentRate - 0.1) * 10) / 10);\n            showRate();\n        }\n    });\n});\n","importcss":"","customcss":".poodll_superplayer_image {\n    height: 300px;\n    background-size: contain;\n    background-position: center;\n    background-repeat: no-repeat;\n}\n.poodll_superplayer_captions {\n    min-height: 50px;\n}\n","dataset":"","datasetvars":"","allowedcontexts":"","allowedcontextids":""}