// Background script for the extension browser.storage.local.get("playlists").then(function (data) { if (!data.playlists) { console.log("pre-populating playlists"); browser.storage.local.set({ playlists: { "listening-1": [], "listening-2": [], "listening-3": [], "watching-1": [], "watching-2": [], }, }); } else { console.log("no need to pre-populate playlists"); } }); browser.storage.local.get("history").then(function (data) { if (!data.history) { console.log("pre-populating history"); browser.storage.local.set({ history: {}, }); } else { console.log("no need to pre-populate history"); } }); // Create context menu items when the extension is installed browser.runtime.onInstalled.addListener(() => { // Parent menu item browser.contextMenus.create({ id: "my-playlist-menu", title: "Add to Playlist", contexts: ["link"], targetUrlPatterns: ["https://*.youtube.com/watch*"], }); // Sub-menu items browser.contextMenus.create({ id: "listening-1", parentId: "my-playlist-menu", title: "Listening - 1", contexts: ["link"], }); browser.contextMenus.create({ id: "listening-2", parentId: "my-playlist-menu", title: "Listening - 2", contexts: ["link"], }); browser.contextMenus.create({ id: "listening-3", parentId: "my-playlist-menu", title: "Listening - 3", contexts: ["link"], }); // Separator browser.contextMenus.create({ id: "separator-1", parentId: "my-playlist-menu", type: "separator", contexts: ["link"], }); browser.contextMenus.create({ id: "watching-1", parentId: "my-playlist-menu", title: "Watching - 1", contexts: ["link"], }); browser.contextMenus.create({ id: "watching-2", parentId: "my-playlist-menu", title: "Watching - 2", contexts: ["link"], }); }); function findVideoInPlaylists(playlists, url) { // Extract the video id from URL query params let v; try { const urlObj = new URL(url); v = urlObj.searchParams.get("v"); } catch (e) { return false; // Invalid URL } // If no video id found, return false if (!v) { return false; } // Check each playlist in the current set for (const playlistName in playlists) { const videos = playlists[playlistName]; // Check each video in the array for (let i = 0; i < videos.length; i++) { try { const itemUrl = new URL(videos[i].url); const itemVParam = itemUrl.searchParams.get("v"); // If the "v" parameters match, return playlist info if (itemVParam === v) { const isLastVideo = i === videos.length - 1; return [playlistName, i, isLastVideo]; } } catch (e) { // Skip malformed URLs continue; } } } // No match found return false; } function findPlaylist(current, url) { const result = findVideoInPlaylists(current, url); // If found, return just the playlist name return result ? result[0] : false; } async function addLinkToPlaylist(plName, item) { const { playlists: currentPlaylists } = await browser.storage.local.get("playlists"); const alreadyHave = findPlaylist(currentPlaylists, item.linkUrl); if (alreadyHave) { console.log("already have that link in", alreadyHave); } else { const { [plName]: playlist, ...others } = currentPlaylists; await browser.storage.local.set({ playlists: { [plName]: [...playlist, { url: item.linkUrl, title: item.linkText }], ...others, }, }); } } // Context menu click handler browser.contextMenus.onClicked.addListener(async (item, _tab) => { addLinkToPlaylist(item.menuItemId, item); }); // Function to navigate a tab to a new URL function navigateTab(tabId, url) { return browser.tabs.update(tabId, { url: url }); } async function updateTracking(message) { // Get current playlists from storage const { playlists } = await browser.storage.local.get("playlists"); // Find the video in playlists const result = findVideoInPlaylists(playlists, message.url); // If the video is not present in any playlist, do nothing if (!result) { return; } const [playlistName, videoIndex, _] = result; // Only update for pause and ended events if (message.type === "pause" || message.type === "ended") { // Create a copy of the playlist for immutability const updatedPlaylists = { ...playlists }; const targetPlaylist = [...updatedPlaylists[playlistName]]; // Get the current video object const video = { ...targetPlaylist[videoIndex] }; // Update the video's status based on message type if (message.type === "pause") { video.status = message.timestamp; // Set status to current timestamp } else if (message.type === "ended") { video.status = "done"; // Set status to "done" } // Update the video in the playlist targetPlaylist[videoIndex] = video; updatedPlaylists[playlistName] = targetPlaylist; // Save the updated playlists back to storage await browser.storage.local.set({ playlists: updatedPlaylists }); } } async function updateHistory(message) { const { history: currentHistory } = await browser.storage.local.get("history"); const q = new URL(message.url); const v = q.searchParams.get("v"); if (currentHistory[v]) { const { [v]: existing, ...rest } = currentHistory; const shouldIgnore = message.type === "playing" && existing?.history?.length > 0 && (existing.history[existing.history.length - 1].action === "play" || existing.history[existing.history.length - 1].action === "playing") && message.timestamp === existing.history[existing.history.length - 1].position; if (!shouldIgnore) { await browser.storage.local.set({ history: { [v]: { ...existing, duration: !isNaN(existing.duration) && isFinite(existing.duration) ? Math.max(message.duration, existing.duration) : message.duration, history: [ ...existing.history, { action: message.type, position: message.timestamp, timestamp: Date.now(), }, ], }, ...rest, }, }); } } else { await browser.storage.local.set({ history: { [v]: { url: message.url, title: message.title, duration: message.duration, history: [ { action: message.type, position: message.timestamp, timestamp: Date.now(), }, ], }, ...currentHistory, }, }); } } function findNext(playlists, url) { const result = findVideoInPlaylists(playlists, url); if (!result) { return false; // Not found in any playlist } const [playlistName, videoIndex, isLastVideo] = result; if (isLastVideo) { return false; // Last video, no next video available } else { // Get next video in the playlist const nextVideo = playlists[playlistName][videoIndex + 1]; console.log("FOUND VIDEO", playlists[playlistName][videoIndex]); return nextVideo; } } async function navigateAndWait(tabId, url) { return new Promise((resolve) => { const listener = (details) => { if (details.tabId === tabId && details.frameId === 0) { browser.webNavigation.onCompleted.removeListener(listener); setTimeout(resolve, 500); } }; browser.webNavigation.onCompleted.addListener(listener); browser.tabs.update(tabId, { url }); }); } async function advance(tabId, url) { const { playlists } = await browser.storage.local.get("playlists"); const nextVideo = await findNext(playlists, url); if (nextVideo) { await navigateAndWait(tabId, nextVideo.url); await browser.tabs.sendMessage(tabId, { type: "autoplay" }); } } // Listen for messages from popup or content scripts browser.runtime.onMessage.addListener((message, sender, _sendResponse) => { console.log("MESSAGE", message, sender); switch (message.type) { case "play": case "playing": case "pause": updateTracking(message); updateHistory(message); break; case "ended": updateTracking(message); updateHistory(message); advance(sender.tab.id, message.url); break; } });