document.addEventListener("alpine:init", () => { //TODO // - preserve playlist order (sort) // - "play/resume playlist" button (pick up where you left off) // X track timestamp to truly resume where you left off // X periodically remove watched videos // X or move to an "old" category, and add a screen to see the list // - consider separating context menu items (rather than having a sub-menu) // X view playback history (?) // - include currently open tabs in export // - support playlist management (add, remove, rename playlists) // - support input text box for adding to playlist (how to handle title?) // - or raw json editing // X option (probably a button) to add current page (url, title) to playlist // X align with addLinkToPlaylist in background.js (no repeated videos) // - button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version) // - long-term: replace youtube page? rss feeds? need server? // X add personal rating feature ("enjoyed", "this was important", etc) Alpine.data("playlistManager", () => ({ playlists: {}, currentIndices: {}, history: {}, sortedHistory: [], openMenus: new Set(), // Track which menus are open currentView: "playlists", // Track current view: 'playlists', 'history', or 'playlist' currentPlaylistName: "", // Track which playlist is being viewed playlistsForDisplay: [], // Computed array for display currentPlaylistVideos: [], // Videos for current playlist view currentTab: null, // Current active tab info isCurrentTabYoutube: false, // Whether current tab is YouTube addCurrentPageButtonText: "Add Current Page", // Button text init() { this.loadPlaylists(); this.loadHistory(); this.getCurrentTab(); // Add document click handler to close menus document.addEventListener("click", (e) => { // If click is not on a more button or menu, close all menus if (!e.target.closest(".more-menu-container")) { this.closeAllMenus(); } }); }, async loadPlaylists() { try { const result = await browser.storage.local.get("playlists"); console.log("LOAD RESULT", result.playlists); this.playlists = result.playlists || {}; if (result.playlists) { this.currentIndices = Object.keys(result.playlists).reduce( (acc, pln) => { const ind = result.playlists[pln].findIndex( (v) => v.status !== "done", ); if (ind === -1) { acc[pln] = result.playlists[pln].length; } else { acc[pln] = ind; } return acc; }, {}, ); } else { this.currentIndices = {}; } } catch (error) { console.error("Error loading playlists:", error); } this.updatePlaylistsForDisplay(); }, updatePlaylistsForDisplay() { this.playlistsForDisplay = Object.entries(this.playlists).map( ([playlistName, videos]) => { const currentIndex = this.currentIndices[playlistName] || 0; const shouldShowTruncation = currentIndex > 0; const truncationText = shouldShowTruncation ? `(${currentIndex} previous video${currentIndex === 1 ? "" : "s"})` : ""; // Get visible videos from current index onwards with original indices const visibleVideos = videos .slice(currentIndex) .map((video, index) => ({ ...video, originalIndex: currentIndex + index, doneButtonText: video.status === "done" ? "Remove Done Status" : "Mark as Done", })); return { name: playlistName, videos: videos, visibleVideos: visibleVideos, shouldShowTruncation: shouldShowTruncation, truncationText: truncationText, }; }, ); }, updateCurrentPlaylistVideos() { if ( !this.currentPlaylistName || !this.playlists[this.currentPlaylistName] ) { this.currentPlaylistVideos = []; } else { this.currentPlaylistVideos = this.playlists[this.currentPlaylistName].map((video) => ({ ...video, doneButtonText: video.status === "done" ? "Remove Done Status" : "Mark as Done", })); } }, async loadHistory() { try { const result = await browser.storage.local.get("history"); console.log("LOAD HISTORY RESULT", result.history); this.history = result.history || {}; this.sortHistoryByRecentInteraction(); } catch (error) { console.error("Error loading history:", error); } }, async getCurrentTab() { try { const tabs = await browser.tabs.query({ active: true, currentWindow: true, }); if (tabs.length > 0) { this.currentTab = tabs[0]; const url = new URL(this.currentTab.url); this.isCurrentTabYoutube = url.hostname === "www.youtube.com"; this.updateAddCurrentPageButtonText(); } } catch (error) { console.error("Error getting current tab:", error); this.currentTab = null; this.isCurrentTabYoutube = false; this.updateAddCurrentPageButtonText(); } }, updateAddCurrentPageButtonText() { if (!this.currentTab) { this.addCurrentPageButtonText = "Unable to get current page"; } else if (!this.isCurrentTabYoutube) { this.addCurrentPageButtonText = "Add Current Page (YouTube only)"; } else { this.addCurrentPageButtonText = "Add Current Page to Playlist"; } }, sortHistoryByRecentInteraction() { // Convert history object to array with video ID and sort by most recent interaction this.sortedHistory = Object.entries(this.history) .map(([videoId, videoData]) => { const lastInteraction = videoData.history.length > 0 ? Math.max(...videoData.history.map((event) => event.timestamp)) : 0; // Pre-process events with formatted data for CSP compliance const processedEvents = videoData.history .slice() .reverse() .map((event, index) => ({ ...event, formattedAction: this.formatActionName(event.action), formattedPosition: `at ${this.formatVideoPosition(event.position)}`, formattedTimestamp: this.formatTimestamp(event.timestamp), uniqueKey: `${videoId}-${event.timestamp}-${index}`, })); return { videoId, formattedVideoId: `(${videoId})`, ...videoData, lastInteraction, processedEvents, tags: videoData.tags || [], // Ensure tags array exists }; }) .sort((a, b) => b.lastInteraction - a.lastInteraction); }, formatTimestamp(timestamp) { const date = new Date(timestamp); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`; if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; return ( date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) ); }, formatVideoPosition(seconds) { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; }, async toggleTag(videoId, tag) { // Create a deep copy of history to avoid proxy issues const history = JSON.parse(JSON.stringify(this.history)); if (!history[videoId]) { console.error(`Video ${videoId} not found in history`); return; } const videoData = history[videoId]; if (!videoData.tags) { videoData.tags = []; } const tagIndex = videoData.tags.indexOf(tag); if (tagIndex === -1) { // Add tag videoData.tags.push(tag); } else { // Remove tag videoData.tags.splice(tagIndex, 1); } // Save to storage and update local state try { await browser.storage.local.set({ history: history }); this.history = history; this.sortHistoryByRecentInteraction(); // Refresh display } catch (error) { console.error("Error saving tag changes:", error); } }, isTagActive(videoId, tag) { const videoData = this.history[videoId]; return videoData && videoData.tags && videoData.tags.includes(tag); }, formatActionName(action) { const actionMap = { play: "Started", playing: "Playing", pause: "Paused", ended: "Finished", }; return actionMap[action] || action; }, formatPlaylistName(name) { // Convert "listening-1" to "Listening - 1" return name .split("-") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" - "); }, openVideo(url) { browser.tabs.create({ url }); }, async removeVideo(playlistName, index) { const playlists = JSON.parse(JSON.stringify(this.playlists)); // Make a copy of the current playlist const playlist = [...playlists[playlistName]]; // Remove the video at the specified index playlist.splice(index, 1); // Create an updated playlists object with the remaining playlists unchanged const updatedPlaylists = { ...playlists, [playlistName]: playlist, }; // Update the playlists in storage try { await browser.storage.local.set({ playlists: updatedPlaylists }); this.playlists = updatedPlaylists; this.updatePlaylistsForDisplay(); this.updateCurrentPlaylistVideos(); } catch (error) { console.error("Error removing video:", error); } }, async moveVideoUp(playlistName, index) { // Can't move the first item up if (index <= 0) return; const playlists = JSON.parse(JSON.stringify(this.playlists)); // Make a copy of the current playlist const playlist = [...playlists[playlistName]]; // Swap the video with the one above it [playlist[index], playlist[index - 1]] = [ playlist[index - 1], playlist[index], ]; // Create an updated playlists object const updatedPlaylists = { ...playlists, [playlistName]: playlist, }; // Update the playlists in storage try { await browser.storage.local.set({ playlists: updatedPlaylists }); this.playlists = updatedPlaylists; this.updatePlaylistsForDisplay(); this.updateCurrentPlaylistVideos(); } catch (error) { console.error("Error moving video up:", error); } }, async moveVideoDown(playlistName, index) { const playlists = JSON.parse(JSON.stringify(this.playlists)); const playlist = [...playlists[playlistName]]; // Can't move the last item down if (index >= playlist.length - 1) return; // Swap the video with the one below it [playlist[index], playlist[index + 1]] = [ playlist[index + 1], playlist[index], ]; // Create an updated playlists object const updatedPlaylists = { ...playlists, [playlistName]: playlist, }; // Update the playlists in storage try { await browser.storage.local.set({ playlists: updatedPlaylists }); this.playlists = updatedPlaylists; this.updatePlaylistsForDisplay(); this.updateCurrentPlaylistVideos(); } catch (error) { console.error("Error moving video down:", error); } }, async toggleVideoDoneStatus(playlistName, index) { const playlists = JSON.parse(JSON.stringify(this.playlists)); const playlist = [...playlists[playlistName]]; const video = {...playlist[index]}; // Toggle the done status if (video.status === "done") { // Remove status property (undefined status means not done) delete video.status; } else { // Set status to done video.status = "done"; } // Update the video in the playlist playlist[index] = video; // Create an updated playlists object const updatedPlaylists = { ...playlists, [playlistName]: playlist, }; // Update the playlists in storage try { await browser.storage.local.set({ playlists: updatedPlaylists }); this.playlists = updatedPlaylists; this.updatePlaylistsForDisplay(); this.updateCurrentPlaylistVideos(); } catch (error) { console.error("Error toggling video done status:", error); } }, async addCurrentPageToPlaylist() { if ( !this.currentTab || !this.isCurrentTabYoutube || !this.currentPlaylistName ) { return; } // Create video object using current tab info const video = { url: this.currentTab.url, title: this.currentTab.title, }; // Use shared utility function to add video (handles duplicate checking) const wasAdded = await PlaylistUtils.addVideoToPlaylist( this.currentPlaylistName, video, ); if (wasAdded) { // Refresh displays only if video was actually added this.loadPlaylists(); // This will update both display arrays } else { // Could show user feedback that video already exists console.log("Video already exists in a playlist"); } }, async exportPlaylists() { try { // Get current playlists const playlistResult = await browser.storage.local.get("playlists"); const playlists = playlistResult.playlists || {}; // Get playback history const historyResult = await browser.storage.local.get("history"); const playbackHistory = historyResult.history || {}; // Create export data object const exportData = { playlists, playbackHistory, exportDate: new Date().toISOString(), }; // Convert to JSON const jsonString = JSON.stringify(exportData, null, 2); // Create download const blob = new Blob([jsonString], { type: "application/json" }); const url = URL.createObjectURL(blob); // Trigger download const a = document.createElement("a"); a.href = url; a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); // Clean up setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); } catch (error) { console.error("Error exporting playlists:", error); } }, videotitle: { ["@click"]() { console.log("TITLE CLICK", this.$el); }, }, videoPlayLink: { ["@click.prevent"]() { browser.tabs.update({ url: this.$el.href }); }, }, isCurrentVideo(playlistName, index) { const currentIndex = this.currentIndices[playlistName]; return currentIndex === index; }, isDoneVideo(playlistName, index) { const currentIndex = this.currentIndices[playlistName]; return index < currentIndex; }, isVideoDone(playlistName, index) { const video = this.playlists[playlistName] && this.playlists[playlistName][index]; return video && video.status === "done"; }, videoItemClass: { [":class"]() { const playlistName = this.$el.dataset.playlistName; const index = parseInt(this.$el.dataset.playlistIndex); const video = this.playlists[playlistName][index]; return { "current-video": this.isCurrentVideo(playlistName, index), "done-video": this.isDoneVideo(playlistName, index) && video.status === "done", }; }, }, removeVideoButton: { ["@click"]() { this.removeVideo( this.$el.dataset.playlistName, this.$el.dataset.playlistIndex, ); this.closeAllMenus(); }, }, toggleVideoDoneButton: { ["@click"]() { this.toggleVideoDoneStatus( this.$el.dataset.playlistName, parseInt(this.$el.dataset.playlistIndex), ); this.closeAllMenus(); }, }, moveUpButton: { ["@click"]() { this.moveVideoUp( this.$el.dataset.playlistName, parseInt(this.$el.dataset.playlistIndex), ); }, [":disabled"]() { return parseInt(this.$el.dataset.playlistIndex) === 0; }, }, moveDownButton: { ["@click"]() { this.moveVideoDown( this.$el.dataset.playlistName, parseInt(this.$el.dataset.playlistIndex), ); }, [":disabled"]() { return ( parseInt(this.$el.dataset.playlistIndex) === this.playlists[this.$el.dataset.playlistName].length - 1 ); }, }, exportButton: { ["@click"]() { this.exportPlaylists(); }, }, importButton: { ["@click"]() { document.getElementById("import-file-input").click(); }, }, importFileInput: { ["@change"]() { this.importFile(this.$event); }, }, importFile(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const importedData = JSON.parse(e.target.result); this.validateAndImportPlaylists(importedData); } catch (error) { console.error("Error parsing JSON file:", error); alert( "Invalid JSON file. Please select a valid playlist export file.", ); } finally { // Reset the file input so the same file can be selected again event.target.value = ""; } }; reader.readAsText(file); }, validateAndImportPlaylists(data) { // Validate the playlists structure if (!data.playlists || typeof data.playlists !== "object") { alert("Invalid file format: Missing or invalid 'playlists' property"); return; } // Validate each playlist const validPlaylists = {}; let hasErrors = false; for (const [playlistName, videos] of Object.entries(data.playlists)) { // Check if videos is an array if (!Array.isArray(videos)) { console.error( `Playlist '${playlistName}' does not contain a valid array of videos`, ); hasErrors = true; continue; } // Validate each video in the playlist const validVideos = videos.filter((video) => { if (!video || typeof video !== "object") { console.error(`Invalid video object in '${playlistName}'`); return false; } if ( !video.url || typeof video.url !== "string" || !video.title || typeof video.title !== "string" ) { console.error( `Video in '${playlistName}' missing required properties (url, title)`, ); return false; } return true; }); // Add the validated playlist if it has valid videos if (validVideos.length > 0) { validPlaylists[playlistName] = validVideos; } } if (Object.keys(validPlaylists).length === 0) { alert("No valid playlists found in the import file"); return; } if (hasErrors) { const confirmImport = confirm( "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?", ); if (!confirmImport) return; } // Update storage and state this.updatePlaylists(validPlaylists); }, async updatePlaylists(playlists) { try { await browser.storage.local.set({ playlists }); this.playlists = playlists; this.updatePlaylistsForDisplay(); this.updateCurrentPlaylistVideos(); alert("Playlists imported successfully!"); } catch (error) { console.error("Error updating playlists:", error); alert("Error importing playlists: " + error.message); } }, getMenuId(playlistName, index) { return `${playlistName}-${index}`; }, isMenuOpen(playlistName, index) { return this.openMenus.has(this.getMenuId(playlistName, index)); }, toggleMenu(playlistName, index) { const menuId = this.getMenuId(playlistName, index); if (this.openMenus.has(menuId)) { this.openMenus.delete(menuId); } else { // Close all other menus first this.openMenus.clear(); this.openMenus.add(menuId); } }, closeAllMenus() { this.openMenus.clear(); }, moreMenuButton: { ["@click.stop"]() { this.toggleMenu( this.$el.dataset.playlistName, parseInt(this.$el.dataset.playlistIndex), ); }, }, moreMenu: { ["x-show"]() { return this.isMenuOpen( this.$el.dataset.playlistName, parseInt(this.$el.dataset.playlistIndex), ); }, }, // View navigation methods showHistory() { this.currentView = "history"; this.loadHistory(); // Refresh history data when switching to history view }, showPlaylists() { this.currentView = "playlists"; }, showPlaylist(playlistName) { this.currentView = "playlist"; this.currentPlaylistName = playlistName; this.updateCurrentPlaylistVideos(); }, // Methods for truncated display shouldShowTruncation(playlistName) { const currentIndex = this.currentIndices[playlistName] || 0; return currentIndex > 0; }, getTruncationText(playlistName) { const currentIndex = this.currentIndices[playlistName] || 0; const count = currentIndex; return `(${count} previous video${count === 1 ? "" : "s"})`; }, getVisibleVideos(playlistName, videos) { const currentIndex = this.currentIndices[playlistName] || 0; // Show from current video onwards, but keep original indices return videos.slice(currentIndex).map((video, index) => ({ ...video, originalIndex: currentIndex + index, })); }, getCurrentPlaylistVideos() { if ( !this.currentPlaylistName || !this.playlists[this.currentPlaylistName] ) { return []; } return this.playlists[this.currentPlaylistName]; }, // Button bindings for navigation historyButton: { ["@click"]() { this.showHistory(); }, }, backButton: { ["@click"]() { this.showPlaylists(); }, }, // Event handlers for playlist navigation playlistNameClick: { ["@click"]() { const playlistName = this.$el.dataset.playlistName; this.showPlaylist(playlistName); }, }, truncatedVideosClick: { ["@click"]() { const playlistName = this.$el.dataset.playlistName; this.showPlaylist(playlistName); }, }, truncatedVideosClick: { ["@click"]() { const playlistName = this.$el.dataset.playlistName; this.showPlaylist(playlistName); }, }, truncatedVideosDisplay: { ["x-show"]() { const playlistName = this.$el.dataset.playlistName; const playlistData = this.playlistsForDisplay.find( (p) => p.name === playlistName, ); return playlistData ? playlistData.shouldShowTruncation : false; }, ["@click"]() { const playlistName = this.$el.dataset.playlistName; this.showPlaylist(playlistName); }, }, // CSP-compliant view bindings playlistsHeader: { ["x-show"]() { return this.currentView === "playlists"; }, }, historyHeader: { ["x-show"]() { return this.currentView === "history"; }, }, playlistViewHeader: { ["x-show"]() { return this.currentView === "playlist"; }, }, playlistsContainer: { ["x-show"]() { return this.currentView === "playlists"; }, }, historyContainer: { ["x-show"]() { return this.currentView === "history"; }, }, playlistViewContainer: { ["x-show"]() { return this.currentView === "playlist"; }, }, exportContainer: { ["x-show"]() { return this.currentView === "playlists"; }, }, // History-specific bindings for CSP compliance historyEmptyState: { ["x-show"]() { return this.sortedHistory.length === 0; }, }, // Playlist view-specific bindings for CSP compliance playlistViewEmptyState: { ["x-show"]() { return this.currentPlaylistVideos.length === 0; }, }, // Tag chip bindings for CSP compliance tagChip: { ["@click"]() { const videoId = this.$el.dataset.videoId; const tag = this.$el.dataset.tag; this.toggleTag(videoId, tag); }, [":class"]() { const videoId = this.$el.dataset.videoId; const tag = this.$el.dataset.tag; return { active: this.isTagActive(videoId, tag), }; }, }, // Add current page button binding addCurrentPageButton: { ["@click"]() { this.addCurrentPageToPlaylist(); }, [":disabled"]() { return ( !this.currentTab || !this.isCurrentTabYoutube || !this.currentPlaylistName ); }, [":class"]() { return { disabled: !this.currentTab || !this.isCurrentTabYoutube || !this.currentPlaylistName, }; }, }, })); });