popup.js 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327
  1. document.addEventListener("alpine:init", () => {
  2. //TODO
  3. // - preserve playlist order (sort)
  4. // - "play/resume playlist" button (pick up where you left off)
  5. // X track timestamp to truly resume where you left off
  6. // X periodically remove watched videos
  7. // X or move to an "old" category, and add a screen to see the list
  8. // - consider separating context menu items (rather than having a sub-menu)
  9. // X view playback history (?)
  10. // X include currently open tabs in export
  11. // - support playlist management (add, remove, rename playlists)
  12. // - support dynamic (on-demand) playlists
  13. // - support input text box for adding to playlist (how to handle title?)
  14. // - or raw json editing
  15. // X option (probably a button) to add current page (url, title) to playlist
  16. // X align with addLinkToPlaylist in background.js (no repeated videos)
  17. // X button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
  18. // - long-term: replace youtube page? rss feeds? need server?
  19. // X add personal rating feature ("enjoyed", "this was important", etc)
  20. // - option to move video (including status) to another playlist
  21. // X button to copy current video to clipboard in wikitext format (for personal wiki list)
  22. Alpine.data("playlistManager", () => ({
  23. playlists: {},
  24. currentIndices: {},
  25. history: {},
  26. sortedHistory: [],
  27. openMenus: new Set(), // Track which menus are open
  28. currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
  29. currentPlaylistName: "", // Track which playlist is being viewed
  30. playlistsForDisplay: [], // Computed array for display
  31. currentPlaylistVideos: [], // Videos for current playlist view
  32. currentTab: null, // Current active tab info
  33. isCurrentTabYoutube: false, // Whether current tab is YouTube
  34. isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
  35. addCurrentPageButtonText: "Add Current Page", // Button text
  36. // Save channel properties
  37. selectedCategory: "FOR BOTH", // Currently selected category
  38. availableCategories: [
  39. "FOR BOTH",
  40. "CHANNELS",
  41. "SCIFI",
  42. "POLI/REL",
  43. "REAL ESTATE",
  44. "HELICOPTER",
  45. "SAILING",
  46. "VAN LIFE",
  47. "BLOCKCHAIN",
  48. "MAKING",
  49. "DESIGN",
  50. "TECH CONF",
  51. "CREATIVE",
  52. ], // Available categories
  53. curlCommand: "", // Generated curl command
  54. checkInterval: 14, // Check interval in days
  55. // Wiki/insp properties
  56. wikitextCommand: "", // Generated wikitext
  57. isCurrentVideoYoutube: false, // Whether current video is YouTube
  58. hasVideoEnded: false, // Whether video has ended
  59. currentVideoTimestamp: "", // Current video timestamp
  60. includeTimestamp: true, // Whether to include timestamp line
  61. // Import properties
  62. importText: "", // Text area content for import
  63. init() {
  64. this.loadPlaylists();
  65. this.loadHistory();
  66. this.getCurrentTab();
  67. // Add document click handler to close menus
  68. document.addEventListener("click", (e) => {
  69. // If click is not on a more button or menu, close all menus
  70. if (!e.target.closest(".more-menu-container")) {
  71. this.closeAllMenus();
  72. }
  73. });
  74. },
  75. async loadPlaylists() {
  76. try {
  77. const result = await browser.storage.local.get("playlists");
  78. console.log("LOAD RESULT", result.playlists);
  79. this.playlists = result.playlists || {};
  80. if (result.playlists) {
  81. this.currentIndices = Object.keys(result.playlists).reduce(
  82. (acc, pln) => {
  83. const ind = result.playlists[pln].findIndex(
  84. (v) => v.status !== "done",
  85. );
  86. if (ind === -1) {
  87. acc[pln] = result.playlists[pln].length;
  88. } else {
  89. acc[pln] = ind;
  90. }
  91. return acc;
  92. },
  93. {},
  94. );
  95. } else {
  96. this.currentIndices = {};
  97. }
  98. } catch (error) {
  99. console.error("Error loading playlists:", error);
  100. }
  101. this.updatePlaylistsForDisplay();
  102. },
  103. updatePlaylistsForDisplay() {
  104. this.playlistsForDisplay = Object.entries(this.playlists).map(
  105. ([playlistName, videos]) => {
  106. const currentIndex = this.currentIndices[playlistName] || 0;
  107. const shouldShowTruncation = currentIndex > 0;
  108. const truncationText = shouldShowTruncation
  109. ? `(${currentIndex} previous video${currentIndex === 1 ? "" : "s"})`
  110. : "";
  111. // Get visible videos from current index onwards with original indices
  112. const visibleVideos = videos
  113. .slice(currentIndex)
  114. .map((video, index) => ({
  115. ...video,
  116. originalIndex: currentIndex + index,
  117. doneButtonText:
  118. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  119. isNonContiguousDone: this.isNonContiguousDone(
  120. playlistName,
  121. currentIndex + index,
  122. ),
  123. }));
  124. return {
  125. name: playlistName,
  126. videos: videos,
  127. visibleVideos: visibleVideos,
  128. shouldShowTruncation: shouldShowTruncation,
  129. truncationText: truncationText,
  130. };
  131. },
  132. );
  133. },
  134. updateCurrentPlaylistVideos() {
  135. if (
  136. !this.currentPlaylistName ||
  137. !this.playlists[this.currentPlaylistName]
  138. ) {
  139. this.currentPlaylistVideos = [];
  140. } else {
  141. this.currentPlaylistVideos = this.playlists[
  142. this.currentPlaylistName
  143. ].map((video, index) => ({
  144. ...video,
  145. doneButtonText:
  146. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  147. isNonContiguousDone: this.isNonContiguousDone(
  148. this.currentPlaylistName,
  149. index,
  150. ),
  151. }));
  152. }
  153. },
  154. async loadHistory() {
  155. try {
  156. const result = await browser.storage.local.get("history");
  157. console.log("LOAD HISTORY RESULT", result.history);
  158. this.history = result.history || {};
  159. this.sortHistoryByRecentInteraction();
  160. } catch (error) {
  161. console.error("Error loading history:", error);
  162. }
  163. },
  164. async getCurrentTab() {
  165. try {
  166. const tabs = await browser.tabs.query({
  167. active: true,
  168. currentWindow: true,
  169. });
  170. if (tabs.length > 0) {
  171. this.currentTab = tabs[0];
  172. const url = new URL(this.currentTab.url);
  173. this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
  174. this.isCurrentTabChannelPage =
  175. this.isCurrentTabYoutube && url.pathname.includes("/videos");
  176. this.isCurrentVideoYoutube =
  177. this.isCurrentTabYoutube && url.pathname.includes("/watch");
  178. // Get video timestamp if on YouTube video page
  179. if (this.isCurrentVideoYoutube) {
  180. await this.getVideoTimestamp();
  181. }
  182. this.updateAddCurrentPageButtonText();
  183. this.updateCurlCommand();
  184. this.updateWikitextCommand();
  185. }
  186. } catch (error) {
  187. console.error("Error getting current tab:", error);
  188. this.currentTab = null;
  189. this.isCurrentTabYoutube = false;
  190. this.isCurrentTabChannelPage = false;
  191. this.isCurrentVideoYoutube = false;
  192. this.updateAddCurrentPageButtonText();
  193. this.updateCurlCommand();
  194. this.updateWikitextCommand();
  195. }
  196. },
  197. async getVideoTimestamp() {
  198. try {
  199. // Execute script in the current tab to get video timestamp
  200. const results = await browser.tabs.executeScript(this.currentTab.id, {
  201. code: `
  202. (function() {
  203. const video = document.querySelector('video');
  204. if (video) {
  205. const currentTime = video.currentTime;
  206. const duration = video.duration;
  207. const ended = video.ended;
  208. const formatTime = (seconds) => {
  209. const hours = Math.floor(seconds / 3600);
  210. const minutes = Math.floor((seconds % 3600) / 60);
  211. const remainingSeconds = Math.floor(seconds % 60);
  212. if (hours > 0) {
  213. return hours + ':' + minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
  214. } else {
  215. return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
  216. }
  217. };
  218. return {
  219. currentTime: currentTime,
  220. formattedTime: formatTime(currentTime),
  221. duration: duration,
  222. ended: ended,
  223. isLongVideo: duration > 3600
  224. };
  225. }
  226. return null;
  227. })();
  228. `
  229. });
  230. if (results && results[0]) {
  231. const videoData = results[0];
  232. this.currentVideoTimestamp = videoData.formattedTime;
  233. this.hasVideoEnded = videoData.ended;
  234. } else {
  235. this.currentVideoTimestamp = "0:00";
  236. this.hasVideoEnded = false;
  237. }
  238. } catch (error) {
  239. console.error("Error getting video timestamp:", error);
  240. this.currentVideoTimestamp = "0:00";
  241. this.hasVideoEnded = false;
  242. }
  243. },
  244. updateAddCurrentPageButtonText() {
  245. if (!this.currentTab) {
  246. this.addCurrentPageButtonText = "Unable to get current page";
  247. } else if (!this.isCurrentTabYoutube) {
  248. this.addCurrentPageButtonText = "Add Current Page (YouTube only)";
  249. } else {
  250. this.addCurrentPageButtonText = "Add Current Page to Playlist";
  251. }
  252. },
  253. updateCurlCommand() {
  254. if (!this.currentTab || !this.isCurrentTabChannelPage) {
  255. this.curlCommand =
  256. "This feature is only available on YouTube channel pages (/videos).";
  257. return;
  258. }
  259. const title = this.currentTab.title.replace(" - YouTube", "");
  260. const url = this.currentTab.url;
  261. const category = this.selectedCategory;
  262. const interval = this.checkInterval;
  263. this.curlCommand = `curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \\"${category}\\", checkInterval: ${interval}, name: \\"${title}\\", url: \\"${url}\\"}){name} } "}' localhost:8543/data | jq '.'`;
  264. },
  265. async copyCurlCommand() {
  266. try {
  267. await navigator.clipboard.writeText(this.curlCommand);
  268. console.log("Curl command copied to clipboard");
  269. // Could add visual feedback here
  270. } catch (error) {
  271. console.error("Failed to copy to clipboard:", error);
  272. // Fallback for older browsers
  273. try {
  274. const textArea = document.createElement("textarea");
  275. textArea.value = this.curlCommand;
  276. document.body.appendChild(textArea);
  277. textArea.focus();
  278. textArea.select();
  279. document.execCommand("copy");
  280. document.body.removeChild(textArea);
  281. console.log("Curl command copied to clipboard (fallback)");
  282. } catch (fallbackError) {
  283. console.error("Fallback copy failed:", fallbackError);
  284. }
  285. }
  286. },
  287. selectCategory(category) {
  288. this.selectedCategory = category;
  289. this.updateCurlCommand();
  290. },
  291. updateCheckInterval(interval) {
  292. // Ensure it's a positive integer, default to 14 if invalid
  293. const parsedInterval = parseInt(interval);
  294. this.checkInterval = parsedInterval > 0 ? parsedInterval : 14;
  295. this.updateCurlCommand();
  296. },
  297. showSaveChannel() {
  298. this.currentView = "saveChannel";
  299. this.updateCurlCommand();
  300. },
  301. showWikiInsp() {
  302. this.currentView = "wikiInsp";
  303. // Refresh timestamp when opening the view
  304. if (this.isCurrentVideoYoutube) {
  305. this.getVideoTimestamp().then(() => {
  306. this.updateWikitextCommand();
  307. });
  308. } else {
  309. this.updateWikitextCommand();
  310. }
  311. },
  312. showImport() {
  313. this.currentView = "import";
  314. },
  315. async refreshVideoTimestamp() {
  316. if (this.isCurrentVideoYoutube) {
  317. await this.getVideoTimestamp();
  318. this.updateWikitextCommand();
  319. }
  320. },
  321. updateWikitextCommand() {
  322. if (!this.currentTab || !this.isCurrentVideoYoutube) {
  323. this.wikitextCommand = "This feature is only available on YouTube videos.";
  324. return;
  325. }
  326. const today = new Date();
  327. const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
  328. const title = this.currentTab.title.replace(" - YouTube", "").replace(/\|/g, "-");
  329. const url = this.currentTab.url;
  330. let wikitext = `!!! ${dateString}\n* [[${title}|${url}]] (yt)`;
  331. // Add timestamp if user has enabled it and video hasn't ended
  332. if (this.includeTimestamp && !this.hasVideoEnded) {
  333. wikitext += `\n** ${this.currentVideoTimestamp || "0:00"} - `;
  334. }
  335. this.wikitextCommand = wikitext;
  336. },
  337. async copyWikitextCommand() {
  338. try {
  339. await navigator.clipboard.writeText(this.wikitextCommand);
  340. console.log("Wikitext copied to clipboard");
  341. // Could add visual feedback here
  342. } catch (error) {
  343. console.error("Failed to copy to clipboard:", error);
  344. // Fallback for older browsers
  345. try {
  346. const textArea = document.createElement("textarea");
  347. textArea.value = this.wikitextCommand;
  348. document.body.appendChild(textArea);
  349. textArea.focus();
  350. textArea.select();
  351. document.execCommand("copy");
  352. document.body.removeChild(textArea);
  353. console.log("Wikitext copied to clipboard (fallback)");
  354. } catch (fallbackError) {
  355. console.error("Fallback copy failed:", fallbackError);
  356. }
  357. }
  358. },
  359. toggleTimestamp() {
  360. this.includeTimestamp = !this.includeTimestamp;
  361. this.updateWikitextCommand();
  362. },
  363. sortHistoryByRecentInteraction() {
  364. // Convert history object to array with video ID and sort by most recent interaction
  365. this.sortedHistory = Object.entries(this.history)
  366. .map(([videoId, videoData]) => {
  367. const lastInteraction =
  368. videoData.history.length > 0
  369. ? Math.max(...videoData.history.map((event) => event.timestamp))
  370. : 0;
  371. // Pre-process events with formatted data for CSP compliance
  372. const processedEvents = videoData.history
  373. .slice()
  374. .reverse()
  375. .map((event, index) => ({
  376. ...event,
  377. formattedAction: this.formatActionName(event.action),
  378. formattedPosition: `at ${this.formatVideoPosition(event.position)}`,
  379. formattedTimestamp: this.formatTimestamp(event.timestamp),
  380. uniqueKey: `${videoId}-${event.timestamp}-${index}`,
  381. }));
  382. return {
  383. videoId,
  384. formattedVideoId: `(${videoId})`,
  385. ...videoData,
  386. lastInteraction,
  387. processedEvents,
  388. tags: videoData.tags || [], // Ensure tags array exists
  389. };
  390. })
  391. .sort((a, b) => b.lastInteraction - a.lastInteraction);
  392. },
  393. formatTimestamp(timestamp) {
  394. const date = new Date(timestamp);
  395. const now = new Date();
  396. const diffMs = now - date;
  397. const diffMins = Math.floor(diffMs / (1000 * 60));
  398. const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
  399. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  400. if (diffMins < 1) return "Just now";
  401. if (diffMins < 60)
  402. return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
  403. if (diffHours < 24)
  404. return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
  405. if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
  406. return (
  407. date.toLocaleDateString() +
  408. " " +
  409. date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
  410. );
  411. },
  412. formatVideoPosition(seconds) {
  413. const minutes = Math.floor(seconds / 60);
  414. const remainingSeconds = Math.floor(seconds % 60);
  415. return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
  416. },
  417. async toggleTag(videoId, tag) {
  418. // Create a deep copy of history to avoid proxy issues
  419. const history = JSON.parse(JSON.stringify(this.history));
  420. if (!history[videoId]) {
  421. console.error(`Video ${videoId} not found in history`);
  422. return;
  423. }
  424. const videoData = history[videoId];
  425. if (!videoData.tags) {
  426. videoData.tags = [];
  427. }
  428. const tagIndex = videoData.tags.indexOf(tag);
  429. if (tagIndex === -1) {
  430. // Add tag
  431. videoData.tags.push(tag);
  432. } else {
  433. // Remove tag
  434. videoData.tags.splice(tagIndex, 1);
  435. }
  436. // Save to storage and update local state
  437. try {
  438. await browser.storage.local.set({ history: history });
  439. this.history = history;
  440. this.sortHistoryByRecentInteraction(); // Refresh display
  441. } catch (error) {
  442. console.error("Error saving tag changes:", error);
  443. }
  444. },
  445. isTagActive(videoId, tag) {
  446. const videoData = this.history[videoId];
  447. return videoData && videoData.tags && videoData.tags.includes(tag);
  448. },
  449. formatActionName(action) {
  450. const actionMap = {
  451. play: "Started",
  452. playing: "Playing",
  453. pause: "Paused",
  454. ended: "Finished",
  455. };
  456. return actionMap[action] || action;
  457. },
  458. formatPlaylistName(name) {
  459. // Convert "listening-1" to "Listening - 1"
  460. return name
  461. .split("-")
  462. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  463. .join(" - ");
  464. },
  465. openVideo(url) {
  466. browser.tabs.create({ url });
  467. },
  468. async removeVideo(playlistName, index) {
  469. const playlists = JSON.parse(JSON.stringify(this.playlists));
  470. // Make a copy of the current playlist
  471. const playlist = [...playlists[playlistName]];
  472. // Remove the video at the specified index
  473. playlist.splice(index, 1);
  474. // Create an updated playlists object with the remaining playlists unchanged
  475. const updatedPlaylists = {
  476. ...playlists,
  477. [playlistName]: playlist,
  478. };
  479. // Update the playlists in storage
  480. try {
  481. await browser.storage.local.set({ playlists: updatedPlaylists });
  482. this.playlists = updatedPlaylists;
  483. this.updatePlaylistsForDisplay();
  484. this.updateCurrentPlaylistVideos();
  485. } catch (error) {
  486. console.error("Error removing video:", error);
  487. }
  488. },
  489. async moveVideoUp(playlistName, index) {
  490. // Can't move the first item up
  491. if (index <= 0) return;
  492. const playlists = JSON.parse(JSON.stringify(this.playlists));
  493. // Make a copy of the current playlist
  494. const playlist = [...playlists[playlistName]];
  495. // Swap the video with the one above it
  496. [playlist[index], playlist[index - 1]] = [
  497. playlist[index - 1],
  498. playlist[index],
  499. ];
  500. // Create an updated playlists object
  501. const updatedPlaylists = {
  502. ...playlists,
  503. [playlistName]: playlist,
  504. };
  505. // Update the playlists in storage
  506. try {
  507. await browser.storage.local.set({ playlists: updatedPlaylists });
  508. this.playlists = updatedPlaylists;
  509. this.updatePlaylistsForDisplay();
  510. this.updateCurrentPlaylistVideos();
  511. } catch (error) {
  512. console.error("Error moving video up:", error);
  513. }
  514. },
  515. async moveVideoDown(playlistName, index) {
  516. const playlists = JSON.parse(JSON.stringify(this.playlists));
  517. const playlist = [...playlists[playlistName]];
  518. // Can't move the last item down
  519. if (index >= playlist.length - 1) return;
  520. // Swap the video with the one below it
  521. [playlist[index], playlist[index + 1]] = [
  522. playlist[index + 1],
  523. playlist[index],
  524. ];
  525. // Create an updated playlists object
  526. const updatedPlaylists = {
  527. ...playlists,
  528. [playlistName]: playlist,
  529. };
  530. // Update the playlists in storage
  531. try {
  532. await browser.storage.local.set({ playlists: updatedPlaylists });
  533. this.playlists = updatedPlaylists;
  534. this.updatePlaylistsForDisplay();
  535. this.updateCurrentPlaylistVideos();
  536. } catch (error) {
  537. console.error("Error moving video down:", error);
  538. }
  539. },
  540. async toggleVideoDoneStatus(playlistName, index) {
  541. const playlists = JSON.parse(JSON.stringify(this.playlists));
  542. const playlist = [...playlists[playlistName]];
  543. const video = { ...playlist[index] };
  544. // Toggle the done status
  545. if (video.status === "done") {
  546. // Remove status property (undefined status means not done)
  547. delete video.status;
  548. } else {
  549. // Set status to done
  550. video.status = "done";
  551. }
  552. // Update the video in the playlist
  553. playlist[index] = video;
  554. // Create an updated playlists object
  555. const updatedPlaylists = {
  556. ...playlists,
  557. [playlistName]: playlist,
  558. };
  559. // Update the playlists in storage
  560. try {
  561. await browser.storage.local.set({ playlists: updatedPlaylists });
  562. this.playlists = updatedPlaylists;
  563. this.updatePlaylistsForDisplay();
  564. this.updateCurrentPlaylistVideos();
  565. } catch (error) {
  566. console.error("Error toggling video done status:", error);
  567. }
  568. },
  569. async addCurrentPageToPlaylist() {
  570. if (
  571. !this.currentTab ||
  572. !this.isCurrentTabYoutube ||
  573. !this.currentPlaylistName
  574. ) {
  575. return;
  576. }
  577. // Create video object using current tab info
  578. const video = {
  579. url: this.currentTab.url,
  580. title: this.currentTab.title,
  581. };
  582. // Use shared utility function to add video (handles duplicate checking)
  583. const wasAdded = await PlaylistUtils.addVideoToPlaylist(
  584. this.currentPlaylistName,
  585. video,
  586. );
  587. if (wasAdded) {
  588. // Refresh displays only if video was actually added
  589. this.loadPlaylists(); // This will update both display arrays
  590. } else {
  591. // Could show user feedback that video already exists
  592. console.log("Video already exists in a playlist");
  593. }
  594. },
  595. async exportPlaylists() {
  596. try {
  597. // Get current playlists
  598. const playlistResult = await browser.storage.local.get("playlists");
  599. const playlists = playlistResult.playlists || {};
  600. // Get playback history
  601. const historyResult = await browser.storage.local.get("history");
  602. const playbackHistory = historyResult.history || {};
  603. // Get open tabs
  604. let openTabs = [];
  605. try {
  606. const tabsResult = await browser.tabs.query({});
  607. openTabs = tabsResult
  608. .filter(
  609. (tab) =>
  610. tab.url &&
  611. !tab.url.startsWith("moz-extension://") &&
  612. !tab.url.startsWith("about:") &&
  613. !tab.url.startsWith("chrome://"),
  614. )
  615. .map((tab) => ({
  616. url: tab.url,
  617. title: tab.title || "Untitled",
  618. }));
  619. } catch (error) {
  620. console.warn("Could not retrieve open tabs:", error);
  621. openTabs = [];
  622. }
  623. // Create export data object
  624. const exportData = {
  625. playlists,
  626. playbackHistory,
  627. openTabs,
  628. exportDate: new Date().toISOString(),
  629. };
  630. // Convert to JSON
  631. const jsonString = JSON.stringify(exportData, null, 2);
  632. // Create download
  633. const blob = new Blob([jsonString], { type: "application/json" });
  634. const url = URL.createObjectURL(blob);
  635. // Trigger download
  636. const a = document.createElement("a");
  637. a.href = url;
  638. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  639. document.body.appendChild(a);
  640. a.click();
  641. // Clean up
  642. setTimeout(() => {
  643. document.body.removeChild(a);
  644. URL.revokeObjectURL(url);
  645. }, 100);
  646. } catch (error) {
  647. console.error("Error exporting playlists:", error);
  648. }
  649. },
  650. videotitle: {
  651. ["@click"]() {
  652. console.log("TITLE CLICK", this.$el);
  653. },
  654. },
  655. videoPlayLink: {
  656. ["@click.prevent"]() {
  657. browser.tabs.update({ url: this.$el.href });
  658. },
  659. },
  660. isNonContiguousDone(playlistName, videoIndex) {
  661. const playlist = this.playlists[playlistName];
  662. if (
  663. !playlist ||
  664. !playlist[videoIndex] ||
  665. playlist[videoIndex].status !== "done"
  666. ) {
  667. return false;
  668. }
  669. // Check if there's any non-done video before this done video
  670. for (let i = 0; i < videoIndex; i++) {
  671. if (playlist[i].status !== "done") {
  672. return true;
  673. }
  674. }
  675. return false;
  676. },
  677. isCurrentVideo(playlistName, index) {
  678. const currentIndex = this.currentIndices[playlistName];
  679. return currentIndex === index;
  680. },
  681. isDoneVideo(playlistName, index) {
  682. const currentIndex = this.currentIndices[playlistName];
  683. return index < currentIndex;
  684. },
  685. isVideoDone(playlistName, index) {
  686. const video =
  687. this.playlists[playlistName] && this.playlists[playlistName][index];
  688. return video && video.status === "done";
  689. },
  690. videoItemClass: {
  691. [":class"]() {
  692. const playlistName = this.$el.dataset.playlistName;
  693. const index = parseInt(this.$el.dataset.playlistIndex);
  694. const video = this.playlists[playlistName][index];
  695. return {
  696. "current-video": this.isCurrentVideo(playlistName, index),
  697. "done-video":
  698. this.isDoneVideo(playlistName, index) && video.status === "done",
  699. "non-contiguous-done-video": this.isNonContiguousDone(
  700. playlistName,
  701. index,
  702. ),
  703. };
  704. },
  705. },
  706. removeVideoButton: {
  707. ["@click"]() {
  708. this.removeVideo(
  709. this.$el.dataset.playlistName,
  710. this.$el.dataset.playlistIndex,
  711. );
  712. this.closeAllMenus();
  713. },
  714. },
  715. toggleVideoDoneButton: {
  716. ["@click"]() {
  717. this.toggleVideoDoneStatus(
  718. this.$el.dataset.playlistName,
  719. parseInt(this.$el.dataset.playlistIndex),
  720. );
  721. this.closeAllMenus();
  722. },
  723. },
  724. moveUpButton: {
  725. ["@click"]() {
  726. this.moveVideoUp(
  727. this.$el.dataset.playlistName,
  728. parseInt(this.$el.dataset.playlistIndex),
  729. );
  730. },
  731. [":disabled"]() {
  732. return parseInt(this.$el.dataset.playlistIndex) === 0;
  733. },
  734. },
  735. moveDownButton: {
  736. ["@click"]() {
  737. this.moveVideoDown(
  738. this.$el.dataset.playlistName,
  739. parseInt(this.$el.dataset.playlistIndex),
  740. );
  741. },
  742. [":disabled"]() {
  743. return (
  744. parseInt(this.$el.dataset.playlistIndex) ===
  745. this.playlists[this.$el.dataset.playlistName].length - 1
  746. );
  747. },
  748. },
  749. exportButton: {
  750. ["@click"]() {
  751. this.exportPlaylists();
  752. },
  753. },
  754. importButton: {
  755. ["@click"]() {
  756. this.showImport();
  757. },
  758. },
  759. async handleImport() {
  760. if (!this.importText.trim()) {
  761. alert("Please paste your JSON export data");
  762. return;
  763. }
  764. try {
  765. const importedData = JSON.parse(this.importText);
  766. this.validateAndImportPlaylists(importedData);
  767. this.importText = "";
  768. } catch (error) {
  769. console.error("Error parsing JSON:", error);
  770. alert(
  771. "Invalid JSON format. Please paste valid playlist export data.",
  772. );
  773. }
  774. },
  775. validateAndImportPlaylists(data) {
  776. // Validate the playlists structure
  777. if (!data.playlists || typeof data.playlists !== "object") {
  778. alert("Invalid file format: Missing or invalid 'playlists' property");
  779. return;
  780. }
  781. // Validate each playlist
  782. const validPlaylists = {};
  783. let hasErrors = false;
  784. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  785. // Check if videos is an array
  786. if (!Array.isArray(videos)) {
  787. console.error(
  788. `Playlist '${playlistName}' does not contain a valid array of videos`,
  789. );
  790. hasErrors = true;
  791. continue;
  792. }
  793. // Validate each video in the playlist
  794. const validVideos = videos.filter((video) => {
  795. if (!video || typeof video !== "object") {
  796. console.error(`Invalid video object in '${playlistName}'`);
  797. return false;
  798. }
  799. if (
  800. !video.url ||
  801. typeof video.url !== "string" ||
  802. !video.title ||
  803. typeof video.title !== "string"
  804. ) {
  805. console.error(
  806. `Video in '${playlistName}' missing required properties (url, title)`,
  807. );
  808. return false;
  809. }
  810. return true;
  811. });
  812. // Add the validated playlist if it has valid videos
  813. if (validVideos.length > 0) {
  814. validPlaylists[playlistName] = validVideos;
  815. }
  816. }
  817. if (Object.keys(validPlaylists).length === 0) {
  818. alert("No valid playlists found in the import file");
  819. return;
  820. }
  821. if (hasErrors) {
  822. const confirmImport = confirm(
  823. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  824. );
  825. if (!confirmImport) return;
  826. }
  827. // Update storage and state
  828. this.updatePlaylists(validPlaylists);
  829. },
  830. async updatePlaylists(playlists) {
  831. try {
  832. await browser.storage.local.set({ playlists });
  833. this.playlists = playlists;
  834. this.updatePlaylistsForDisplay();
  835. this.updateCurrentPlaylistVideos();
  836. alert("Playlists imported successfully!");
  837. } catch (error) {
  838. console.error("Error updating playlists:", error);
  839. alert("Error importing playlists: " + error.message);
  840. }
  841. },
  842. getMenuId(playlistName, index) {
  843. return `${playlistName}-${index}`;
  844. },
  845. isMenuOpen(playlistName, index) {
  846. return this.openMenus.has(this.getMenuId(playlistName, index));
  847. },
  848. toggleMenu(playlistName, index) {
  849. const menuId = this.getMenuId(playlistName, index);
  850. if (this.openMenus.has(menuId)) {
  851. this.openMenus.delete(menuId);
  852. } else {
  853. // Close all other menus first
  854. this.openMenus.clear();
  855. this.openMenus.add(menuId);
  856. }
  857. },
  858. closeAllMenus() {
  859. this.openMenus.clear();
  860. },
  861. moreMenuButton: {
  862. ["@click.stop"]() {
  863. this.toggleMenu(
  864. this.$el.dataset.playlistName,
  865. parseInt(this.$el.dataset.playlistIndex),
  866. );
  867. },
  868. },
  869. moreMenu: {
  870. ["x-show"]() {
  871. return this.isMenuOpen(
  872. this.$el.dataset.playlistName,
  873. parseInt(this.$el.dataset.playlistIndex),
  874. );
  875. },
  876. },
  877. // View navigation methods
  878. showHistory() {
  879. this.currentView = "history";
  880. this.loadHistory(); // Refresh history data when switching to history view
  881. },
  882. showPlaylists() {
  883. this.currentView = "playlists";
  884. },
  885. showPlaylist(playlistName) {
  886. this.currentView = "playlist";
  887. this.currentPlaylistName = playlistName;
  888. this.updateCurrentPlaylistVideos();
  889. },
  890. // Methods for truncated display
  891. shouldShowTruncation(playlistName) {
  892. const currentIndex = this.currentIndices[playlistName] || 0;
  893. return currentIndex > 0;
  894. },
  895. getTruncationText(playlistName) {
  896. const currentIndex = this.currentIndices[playlistName] || 0;
  897. const count = currentIndex;
  898. return `(${count} previous video${count === 1 ? "" : "s"})`;
  899. },
  900. getVisibleVideos(playlistName, videos) {
  901. const currentIndex = this.currentIndices[playlistName] || 0;
  902. // Show from current video onwards, but keep original indices
  903. return videos.slice(currentIndex).map((video, index) => ({
  904. ...video,
  905. originalIndex: currentIndex + index,
  906. }));
  907. },
  908. getCurrentPlaylistVideos() {
  909. if (
  910. !this.currentPlaylistName ||
  911. !this.playlists[this.currentPlaylistName]
  912. ) {
  913. return [];
  914. }
  915. return this.playlists[this.currentPlaylistName];
  916. },
  917. // Button bindings for navigation
  918. historyButton: {
  919. ["@click"]() {
  920. this.showHistory();
  921. },
  922. },
  923. saveChannelButton: {
  924. ["@click"]() {
  925. this.showSaveChannel();
  926. },
  927. },
  928. wikiInspButton: {
  929. ["@click"]() {
  930. this.showWikiInsp();
  931. },
  932. },
  933. backButton: {
  934. ["@click"]() {
  935. this.showPlaylists();
  936. },
  937. },
  938. // Event handlers for playlist navigation
  939. playlistNameClick: {
  940. ["@click"]() {
  941. const playlistName = this.$el.dataset.playlistName;
  942. this.showPlaylist(playlistName);
  943. },
  944. },
  945. truncatedVideosClick: {
  946. ["@click"]() {
  947. const playlistName = this.$el.dataset.playlistName;
  948. this.showPlaylist(playlistName);
  949. },
  950. },
  951. truncatedVideosClick: {
  952. ["@click"]() {
  953. const playlistName = this.$el.dataset.playlistName;
  954. this.showPlaylist(playlistName);
  955. },
  956. },
  957. truncatedVideosDisplay: {
  958. ["x-show"]() {
  959. const playlistName = this.$el.dataset.playlistName;
  960. const playlistData = this.playlistsForDisplay.find(
  961. (p) => p.name === playlistName,
  962. );
  963. return playlistData ? playlistData.shouldShowTruncation : false;
  964. },
  965. ["@click"]() {
  966. const playlistName = this.$el.dataset.playlistName;
  967. this.showPlaylist(playlistName);
  968. },
  969. },
  970. // CSP-compliant view bindings
  971. playlistsHeader: {
  972. ["x-show"]() {
  973. return this.currentView === "playlists";
  974. },
  975. },
  976. historyHeader: {
  977. ["x-show"]() {
  978. return this.currentView === "history";
  979. },
  980. },
  981. playlistViewHeader: {
  982. ["x-show"]() {
  983. return this.currentView === "playlist";
  984. },
  985. },
  986. saveChannelHeader: {
  987. ["x-show"]() {
  988. return this.currentView === "saveChannel";
  989. },
  990. },
  991. wikiInspHeader: {
  992. ["x-show"]() {
  993. return this.currentView === "wikiInsp";
  994. },
  995. },
  996. importHeader: {
  997. ["x-show"]() {
  998. return this.currentView === "import";
  999. },
  1000. },
  1001. playlistsContainer: {
  1002. ["x-show"]() {
  1003. return this.currentView === "playlists";
  1004. },
  1005. },
  1006. historyContainer: {
  1007. ["x-show"]() {
  1008. return this.currentView === "history";
  1009. },
  1010. },
  1011. playlistViewContainer: {
  1012. ["x-show"]() {
  1013. return this.currentView === "playlist";
  1014. },
  1015. },
  1016. saveChannelContainer: {
  1017. ["x-show"]() {
  1018. return this.currentView === "saveChannel";
  1019. },
  1020. },
  1021. wikiInspContainer: {
  1022. ["x-show"]() {
  1023. return this.currentView === "wikiInsp";
  1024. },
  1025. },
  1026. importContainer: {
  1027. ["x-show"]() {
  1028. return this.currentView === "import";
  1029. },
  1030. },
  1031. exportContainer: {
  1032. ["x-show"]() {
  1033. return this.currentView === "playlists";
  1034. },
  1035. },
  1036. // History-specific bindings for CSP compliance
  1037. historyEmptyState: {
  1038. ["x-show"]() {
  1039. return this.sortedHistory.length === 0;
  1040. },
  1041. },
  1042. // Playlist view-specific bindings for CSP compliance
  1043. playlistViewEmptyState: {
  1044. ["x-show"]() {
  1045. return this.currentPlaylistVideos.length === 0;
  1046. },
  1047. },
  1048. // Tag chip bindings for CSP compliance
  1049. tagChip: {
  1050. ["@click"]() {
  1051. const videoId = this.$el.dataset.videoId;
  1052. const tag = this.$el.dataset.tag;
  1053. this.toggleTag(videoId, tag);
  1054. },
  1055. [":class"]() {
  1056. const videoId = this.$el.dataset.videoId;
  1057. const tag = this.$el.dataset.tag;
  1058. return {
  1059. active: this.isTagActive(videoId, tag),
  1060. };
  1061. },
  1062. },
  1063. // Save Channel specific bindings
  1064. copyCurlButton: {
  1065. ["@click"]() {
  1066. this.copyCurlCommand();
  1067. },
  1068. [":disabled"]() {
  1069. return !this.isCurrentTabChannelPage;
  1070. },
  1071. },
  1072. // Wiki/insp specific bindings
  1073. copyWikitextButton: {
  1074. ["@click"]() {
  1075. this.copyWikitextCommand();
  1076. },
  1077. [":disabled"]() {
  1078. return !this.isCurrentVideoYoutube;
  1079. },
  1080. },
  1081. timestampCheckbox: {
  1082. ["@change"]() {
  1083. this.toggleTimestamp();
  1084. },
  1085. [":checked"]() {
  1086. return this.includeTimestamp;
  1087. },
  1088. },
  1089. refreshTimestampButton: {
  1090. ["@click"]() {
  1091. this.refreshVideoTimestamp();
  1092. },
  1093. [":disabled"]() {
  1094. return !this.isCurrentVideoYoutube;
  1095. },
  1096. },
  1097. categoryButton: {
  1098. ["@click"]() {
  1099. const category = this.$el.dataset.category;
  1100. this.selectCategory(category);
  1101. },
  1102. [":class"]() {
  1103. const category = this.$el.dataset.category;
  1104. return {
  1105. active: this.selectedCategory === category,
  1106. };
  1107. },
  1108. },
  1109. saveChannelNotice: {
  1110. ["x-show"]() {
  1111. return !this.isCurrentTabChannelPage;
  1112. },
  1113. },
  1114. wikiInspNotice: {
  1115. ["x-show"]() {
  1116. return !this.isCurrentVideoYoutube;
  1117. },
  1118. },
  1119. intervalInput: {
  1120. [":value"]() {
  1121. return this.checkInterval;
  1122. },
  1123. ["@input"]() {
  1124. this.updateCheckInterval(this.$el.value);
  1125. },
  1126. },
  1127. // Add current page button binding
  1128. addCurrentPageButton: {
  1129. ["@click"]() {
  1130. this.addCurrentPageToPlaylist();
  1131. },
  1132. [":disabled"]() {
  1133. return (
  1134. !this.currentTab ||
  1135. !this.isCurrentTabYoutube ||
  1136. !this.currentPlaylistName
  1137. );
  1138. },
  1139. [":class"]() {
  1140. return {
  1141. disabled:
  1142. !this.currentTab ||
  1143. !this.isCurrentTabYoutube ||
  1144. !this.currentPlaylistName,
  1145. };
  1146. },
  1147. },
  1148. // Import view bindings
  1149. importTextarea: {
  1150. [":value"]() {
  1151. return this.importText;
  1152. },
  1153. ["@input"]() {
  1154. this.importText = this.$el.value;
  1155. },
  1156. },
  1157. importSubmitButton: {
  1158. ["@click"]() {
  1159. this.handleImport();
  1160. },
  1161. },
  1162. }));
  1163. });