DEV Community

AnhChienVu
AnhChienVu

Posted on

Create a Pull Request for a new Feature of ImprovedTub

After 2 weeks contributing to the open-source project ImprovedTub, I am excited to share my Pull Request for the new feature.

To recap the work I've over the past 2 weeks, you can refer to the following blog posts:

In this blog, I will walk you through the code, steps and key learnings I've gained from this experience.

Implementing the Details Switch

Upon reviewing the source code, I discovered that all the sub-menu features represented in the extension were listed under the menu/skeleton-parts folder:

Image description

UI's Extension:
Image description

Since I was implementing the feature to display the details of the currently playing video, along with its associated channel information, I decided to introduce a new switch within the channel.js file. This file is responsible for defining the structure of the UI of Channel component, and in this case, it manages the behavior of the Channel button:

extension.skeleton.main.layers.section.channel = {
    component: 'button',
    variant: 'channel',
    category: true,
    on: {
        click: {
            // Other buttons ...
            ,channel_details_button: {
                component: 'switch',
                text: 'Details',
                value: false, // Default state is off
                on: {
                    change: async function (event) {
                        const apiKey = "YOUR_API_KEY";
                        const switchElement = event.target.closest('.satus-switch');
                        const isChecked = switchElement && switchElement.dataset.value === 'true';

                        // Store the switch state in chrome.storage.local
                        chrome.storage.local.set( { switchState: isChecked } );

                        try {
                            const videoId = await getCurrentVideoId();
                            const videoInfo = await getVideoInfo(apiKey, videoId);

                            const channelId = videoInfo.snippet.channelId
                            const channelInfo = await getChannelInfo(apiKey, channelId);

                            chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
                                //console.log("Sending message to content.js");
                                chrome.tabs.sendMessage(tabs[0].id, {
                                    action: isChecked ? 'append-channel-info' : 'remove-channel-info',
                                    channelName: channelInfo.channelName,
                                    uploadTime: new Date(videoInfo.snippet.publishedAt).toLocaleString(),
                                    videoCount: channelInfo.videoCount,
                                    customUrl: channelInfo.customUrl
                                })
                            });
                        } catch (error){
                            console.error(error);
                        }
                }
          }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the partial snippet code above, you will notice that I used a placeholder for the YOUTUBE_API_KEY. As mentioned in my Progress blog, I was unable to implement .env variable within a Chrome Extension. Therefore, to ensure the feature works correctly, the maintainer will need to generate an API key here. During development, I used my own API key for testing purposes; however, I could not include it in the source code for security reasons.

Next, I needed to track the state of the switch. To achieve this, I set its default value to false via the value attribute. I then implemented the necessary logic to track its state within the on { change:..} event listener. Since this function require fetching video data from the API, it needed to be asynchronous. To ensure the switch's state remains persistent, I saved it to Chrome's local storage using the following method:

chrome.storage.local.set({})
Enter fullscreen mode Exit fullscreen mode

To adhere the DRY (Dont' Repeat Yourself) principle, I modularized the code into smaller functions, such as getCurrentVideoId(), getVideoInfo(), and getChannelInfo(), each responsible for fetching specific video data. Once all the required information is gathered, I passed it to the content.js file (which I will explain further below). The content.js file checks if the switch is on; if so, it displays the details, otherwise, it remains hidden.

These are all support function to get the Video data:

async function getCurrentVideoId() {
    return new Promise((resolve, reject) => {
        chrome.storage.local.get('videoId', (result) => {
            //console.log('Retrieved video ID from storage:', result.videoId); // Debugging log
            if (result.videoId) {
                resolve(result.videoId);
            } else {
                reject('Video ID not found');
            }
        });
    });
}

async function getVideoInfo(apiKey, videoId) {
    const response = await fetch(`https://www.googleapis.com/youtube/v3/videos?id=${videoId}&key=${apiKey}&part=snippet,contentDetails,statistics,status`);
    const data = await response.json();
    const video = data?.items[0];

    return video;
}

async function getChannelInfo(apiKey, channelId) {
    const response = await fetch(`https://www.googleapis.com/youtube/v3/channels?id=${channelId}&key=${apiKey}&part=snippet,contentDetails,statistics,status`);
    const data = await response.json();
    const channel = data.items[0];
    const customUrl = channel.snippet.customUrl;
    const channelName = channel.snippet.title;
    const videoCount = channel.statistics.videoCount;

    return {channelName, videoCount, customUrl};
}
Enter fullscreen mode Exit fullscreen mode

After adding Details switch, the UI will look as this:
Image description

Coding content.js file

As mentioned in my Progress post, the content.js file is responsible for managing the UI of the webpage. Specially, this file handles the logic to track the state of the switch, which is sent from the channel.js as well as backend.js. The goal is to ensure the extension can maintain and reflect the switch's state across various scenarios. For instance, if a user turns on the switch while on a different webpage (not Youtube), the extension should retain this state when the user navigates to the YouTube page and display the details block accordingly. Similarly, if the switch is turned off, it should remain off upon navigating to YouTube.

To achieve this, all the primary logic is implemented after the DOM is fully loaded. Once the DOM is ready, I listen for changes in the video URL to ensure the extension correctly updates the displayed details. This enables the extension to send the updated Video ID whenever a user switches to a new video:

let previousVideoId = getVideoIdFromUrl();

    // Listen for changes in the video URL and send the updated video ID
    const observer = new MutationObserver(() => {
        const currentVideoId = getVideoIdFromUrl();
        if (currentVideoId !== previousVideoId) {
            previousVideoId = currentVideoId;

            sendVideoIdToBackground();

            checkSwitchStateAndFetchData();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });
Enter fullscreen mode Exit fullscreen mode

However, this approach only addresses the scenario where the video ID in the URL changes. It does not handle the case where the video remains the same, but the user adjusts the switch from a different tab. To resolve this, I devised another solution: listen for visibility changes to handle page navigation. This means that when the users navigate to another page and toggle the switch, the extension will ensure that when they return to the YouTube page, the details switch will still function correctly - either displaying or hiding the details area based on the switch's state.

// Listen for visibility changes to handle page navigation
    document.addEventListener('visibilitychange', function() {
        if (document.visibilityState === 'visible') {
            checkSwitchStateAndFetchData();
        }
    });
Enter fullscreen mode Exit fullscreen mode

I will need to combine both of these solution to fix all the edge cases that may happen with the switch function, and to DRY the code, I modularized the function checkSwitchStateAndFetchData() as a support function:

// Check if the switch is on and fetch new data. Otherwise, remove the details block
function checkSwitchStateAndFetchData() {
    chrome.runtime.sendMessage({ action: 'check-switch-state' }, function(response) {
        if (response.isSwitchOn) {
            chrome.runtime.sendMessage({ action: 'fetch-new-data' });
        } else {
            // Remove existing video details if the switch is off
            const targetElement = document.querySelector('.channel-info')
            if (targetElement) {
                targetElement.remove();
            }
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Since content.js needs to communicate with backend.js through the messaging system, it listens for messages from the backend to determine when to display the details area. To ensure the details are updated when the user switches to another video, the extension will remove the existing details before adding the new ones.

backend.js sends 2 actions to content.js to notify it of the switch's state:

  • append-channel-info: This action is sent when the switch is turned on, signaling that the details should be displayed.

  • remove-channel-info: This action is sent when the switch is turned off, indicating that the details should be hidden.

chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) {
        //console.log('Received message from background:', message); // Debugging log
        if (message.action === 'append-channel-info') {
            //console.log('Switch on Details mode'); 
            const {channelName, uploadTime, videoCount, customUrl} = message;
            const channelInfo = createChannelInfo(channelName, uploadTime, videoCount, customUrl);
            const targetElement = document.querySelector('ytd-video-owner-renderer.style-scope.ytd-watch-metadata');
            if (targetElement) {
                // Removing existing channel info if present
                const existingChannelInfo = targetElement.querySelector('.channel-info');
                if(existingChannelInfo) {
                    existingChannelInfo.remove();
                }
                targetElement.appendChild(channelInfo);
            } else {
                console.error('Target element not found');
            }
        } else if (message.action === 'remove-channel-info') {
            //console.log('Switch off Details mode');
            const targetElement = document.querySelector('.channel-info');

            if(targetElement) {
                targetElement.remove();
            } else {
                console.error('Channel info element not found');
            }
        }
    });
Enter fullscreen mode Exit fullscreen mode

Exploring backend.js file

I'd like to once again thank the maintainer for providing clear comments throughout the code, which made it easy to identify where the messages sent from content.js are handled. In this section, I added 3 new action cases: store-video-id, check-switch-state, and fetch-new-data.

  • store-video-id: Upon receiving this action from content.js, the backend is responsible for storing the video ID in local storage. Once this is done, the backend sends a response message back to content.js, confirming that the request has been fulfilled. Notably, the return true statement is crucial here, as it ensures that sendResponse is called asynchronously. Without this, sendResponse would not function as expected.
case 'store-video-id':
//console.log('Storing video ID:', message.videoId); // Debugging log
if (message.videoId) {
    chrome.storage.local.set({ videoId: message.videoId }, function() {
    sendResponse({ status: 'success' });
} else {
    sendResponse({ status: 'error', message: 'No video ID provided' });
}
return true; // Indicates that sendResponse will be called asynchronously
Enter fullscreen mode Exit fullscreen mode
  • check-switch-state: As discussed earlier, tracking the switch state is important. In this case, the backend retrieves the current state of the switch from local storage (where it was set by content.js) and sends it back to content.js.
case 'check-switch-state':
    chrome.storage.local.get('switchState', function(result) {
    sendResponse( {isSwitchOn: result.switchState});
    }); 
    return true;
Enter fullscreen mode Exit fullscreen mode
  • fetch-new-data: This action is triggered when content.js detects that the user has switched to a new video. The backend then fetches the relevant data for the new video. The logic here mirrors the process used in content.js to fetch video data, ensuring consistency across both components.
case 'fetch-new-data':
            chrome.storage.local.get('videoId', async function(result) {
                const videoId = result.videoId;
                const apiKey = "AIzaSyA0r8em0ndGCnx6vZu1Tv6T0iyLW4nB1jI"; // Replace with your YouTube Data API key
                try {
                    const videoInfo = await getVideoInfo(apiKey, videoId);
                    const channelId = videoInfo.snippet.channelId;
                    const channelInfo = await getChannelInfo(apiKey, channelId);

                    chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
                        //console.log("Sending message to content.js");
                        chrome.tabs.sendMessage(tabs[0].id, {
                            action: 'append-channel-info',
                            channelName: channelInfo.channelName,
                            uploadTime: new Date(videoInfo.snippet.publishedAt).toLocaleString(),
                            videoCount: channelInfo.videoCount,
                            customUrl: channelInfo.customUrl
                        });
                    });
                } catch (error) {
                    console.error(error);
                }
            });
            return true;    
Enter fullscreen mode Exit fullscreen mode

Finally

There are all steps that I worked through to implement this feature, and here is the final outpu

Image description

In the area marked in red, I display the release time and date of the video, as well as the total number of videos posted by the channel. Additionally, I used an SVG icon to represent the "All Videos" link. When the user clicks on this icon, they are redirected to the video's channel. Another icon, representing "More Information," directs the user to a page where they can input a video link, after which all the video details are populated.

Top comments (0)