blmage / duolingo-tts-controls

A small browser extension providing playback controls for some of the challenges on Duolingo.
MIT License
21 stars 4 forks source link

Question: how to detect / control the sounds of stories? #108

Closed MarlonPassos-git closed 3 years ago

MarlonPassos-git commented 3 years ago

Hi, sorry for my ignorance, but I'm trying to create a script for duolingo to automatically play stories, but for this to work I should somehow have access to audio that by default is not shown in HTML. I saw that you managed to solve this problem but when I tried to read your code I didn't understand how you did it (I'm still a beginner), could you not explain more directly to me what I should research to be able to solve this problem. Sorry if this isn't the best place to ask questions, but it's the only one I found

blmage commented 3 years ago

No problem!

If what you would like is to have a button (for example) to read an entire story at once, you can:

  1. override the XMLHttpRequest.open() method (example),
  2. detect when a story is being loaded (the requested URL should start with https://stories.duolingo.com/api2/stories/),
  3. add a load listener to the request,
  4. parse the response and find the URLs of the sounds that are relevant in this case (using the "Network" tab from your browser's dev tools to inspect the responses and understand their structure can be helpful),
  5. use the Howler.js library (that is used by Duolingo and available from the global scope) to play the sounds when needed.

This is a bit different from what the extension does in that the extension applies to sounds that are loaded by Duolingo itself, but I think that using your own sounds is the way to go here.

blmage commented 3 years ago

It seemed like an interesting exercise, so I've taken a stab at it (who knows, this might end up in an extension!).

Here is a userscript that reads the entire story when its icon is clicked (it lacks some controls such as a button to stop the playback - you can fill in the gaps if necessary):

// ==UserScript==
// @name         Duo Story Playback
// @version      1.0.0
// @description  Reads an entire story when its icon is clicked.
// @author       blmage
// @match        https://*.duolingo.com/stories/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const isArray = Array.isArray;
    const isObject = x => ('object' === typeof x) && !!x && !isArray(x);
    const isString = x => ('string' === typeof x);

    let storyHowls = [];
    let playbackQueue = null;
    let isPlaybackReady = false;
    let isPlaybackRunning = false;
    let currentStoryIcon = null;

    setInterval(() => {
        const storyIcon = document.querySelector('._3kxdZ');

        if (storyIcon !== currentStoryIcon) {
            currentStoryIcon = storyIcon;

            if (storyIcon) {
                storyIcon.style.cursor = 'pointer';
                storyIcon.addEventListener('click', playStory);
            }
        }
    }, 100);

    const playStory = () => {
        if (isPlaybackReady && !isPlaybackRunning) {
            isPlaybackRunning = true;
            playbackQueue = storyHowls.slice();

            const playNextSound = () => {
                const nextSound = playbackQueue.shift();

                if (nextSound) {
                    nextSound.once('end', playNextSound);
                    nextSound.play();
                } else {
                    isPlaybackRunning = false;
                }
            };

            playNextSound();
        }
    };

    const preparePlaybackButton = sounds => {
        isPlaybackReady = false;
        storyHowls = sounds.map(url => new Howl({ src: [ url ] }));

        const loadPromises = storyHowls.map(howl => (
            new Promise((resolve, reject) => {
                howl.once('load', resolve);
                howl.once('loaderror', reject);
            })
        ));

        Promise.all(loadPromises)
            .then(() => {
                isPlaybackReady = true;
            }).catch(error => {
                alert(`Could not load all the sounds: ${error}.`);
            });
    };

    const originalXhrOpen = XMLHttpRequest.prototype.open;

    XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
        if (url.startsWith('https://stories.duolingo.com/api2/stories/')) {
            this.addEventListener('load', () => {
                try {
                    const data = isObject(this.response) ? this.response : JSON.parse(this.responseText);

                    if (isObject(data) && isArray(data.elements)) {
                        const storySounds = data.elements.map(element => {
                           if ('HEADER' === element.type) {
                               return element?.learningLanguageTitleContent?.audio?.url;
                           } else if ('LINE' === element.type) {
                               return element?.line?.content?.audio?.url;
                           }
                        }).filter(isString);

                        preparePlaybackButton(storySounds);
                    }
                } catch (error) {
                    logError(`Could not parse the story: ${error}.`);
                }
            });
        }

        return originalXhrOpen.call(this, method, url, async, user, password);
    };
})();