popup.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353
  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 and filter out done videos
  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. if (video.status === "done") {
  811. return false;
  812. }
  813. return true;
  814. });
  815. // Add the validated playlist if it has valid videos
  816. if (validVideos.length > 0) {
  817. validPlaylists[playlistName] = validVideos;
  818. }
  819. }
  820. if (Object.keys(validPlaylists).length === 0) {
  821. alert("No valid playlists found in the import file");
  822. return;
  823. }
  824. if (hasErrors) {
  825. const confirmImport = confirm(
  826. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  827. );
  828. if (!confirmImport) return;
  829. }
  830. // Update storage and state
  831. this.appendToPlaylists(validPlaylists);
  832. },
  833. async appendToPlaylists(importedPlaylists) {
  834. try {
  835. const currentPlaylists = JSON.parse(JSON.stringify(this.playlists));
  836. let addedCount = 0;
  837. let skippedCount = 0;
  838. for (const [playlistName, importedVideos] of Object.entries(importedPlaylists)) {
  839. if (!currentPlaylists[playlistName]) {
  840. currentPlaylists[playlistName] = [];
  841. }
  842. for (const video of importedVideos) {
  843. const alreadyExists = PlaylistUtils.findPlaylist(currentPlaylists, video.url);
  844. if (!alreadyExists) {
  845. currentPlaylists[playlistName].push(video);
  846. addedCount++;
  847. } else {
  848. skippedCount++;
  849. }
  850. }
  851. }
  852. await browser.storage.local.set({ playlists: currentPlaylists });
  853. this.playlists = currentPlaylists;
  854. this.updatePlaylistsForDisplay();
  855. this.updateCurrentPlaylistVideos();
  856. alert(`Import complete! Added ${addedCount} video(s), skipped ${skippedCount} duplicate(s).`);
  857. } catch (error) {
  858. console.error("Error appending playlists:", error);
  859. alert("Error importing playlists: " + error.message);
  860. }
  861. },
  862. getMenuId(playlistName, index) {
  863. return `${playlistName}-${index}`;
  864. },
  865. isMenuOpen(playlistName, index) {
  866. return this.openMenus.has(this.getMenuId(playlistName, index));
  867. },
  868. toggleMenu(playlistName, index) {
  869. const menuId = this.getMenuId(playlistName, index);
  870. if (this.openMenus.has(menuId)) {
  871. this.openMenus.delete(menuId);
  872. } else {
  873. // Close all other menus first
  874. this.openMenus.clear();
  875. this.openMenus.add(menuId);
  876. }
  877. },
  878. closeAllMenus() {
  879. this.openMenus.clear();
  880. },
  881. moreMenuButton: {
  882. ["@click.stop"]() {
  883. this.toggleMenu(
  884. this.$el.dataset.playlistName,
  885. parseInt(this.$el.dataset.playlistIndex),
  886. );
  887. },
  888. },
  889. moreMenu: {
  890. ["x-show"]() {
  891. return this.isMenuOpen(
  892. this.$el.dataset.playlistName,
  893. parseInt(this.$el.dataset.playlistIndex),
  894. );
  895. },
  896. },
  897. // View navigation methods
  898. showHistory() {
  899. this.currentView = "history";
  900. this.loadHistory(); // Refresh history data when switching to history view
  901. },
  902. showPlaylists() {
  903. this.currentView = "playlists";
  904. },
  905. showPlaylist(playlistName) {
  906. this.currentView = "playlist";
  907. this.currentPlaylistName = playlistName;
  908. this.updateCurrentPlaylistVideos();
  909. },
  910. // Methods for truncated display
  911. shouldShowTruncation(playlistName) {
  912. const currentIndex = this.currentIndices[playlistName] || 0;
  913. return currentIndex > 0;
  914. },
  915. getTruncationText(playlistName) {
  916. const currentIndex = this.currentIndices[playlistName] || 0;
  917. const count = currentIndex;
  918. return `(${count} previous video${count === 1 ? "" : "s"})`;
  919. },
  920. getVisibleVideos(playlistName, videos) {
  921. const currentIndex = this.currentIndices[playlistName] || 0;
  922. // Show from current video onwards, but keep original indices
  923. return videos.slice(currentIndex).map((video, index) => ({
  924. ...video,
  925. originalIndex: currentIndex + index,
  926. }));
  927. },
  928. getCurrentPlaylistVideos() {
  929. if (
  930. !this.currentPlaylistName ||
  931. !this.playlists[this.currentPlaylistName]
  932. ) {
  933. return [];
  934. }
  935. return this.playlists[this.currentPlaylistName];
  936. },
  937. // Button bindings for navigation
  938. historyButton: {
  939. ["@click"]() {
  940. this.showHistory();
  941. },
  942. },
  943. saveChannelButton: {
  944. ["@click"]() {
  945. this.showSaveChannel();
  946. },
  947. },
  948. wikiInspButton: {
  949. ["@click"]() {
  950. this.showWikiInsp();
  951. },
  952. },
  953. backButton: {
  954. ["@click"]() {
  955. this.showPlaylists();
  956. },
  957. },
  958. // Event handlers for playlist navigation
  959. playlistNameClick: {
  960. ["@click"]() {
  961. const playlistName = this.$el.dataset.playlistName;
  962. this.showPlaylist(playlistName);
  963. },
  964. },
  965. truncatedVideosClick: {
  966. ["@click"]() {
  967. const playlistName = this.$el.dataset.playlistName;
  968. this.showPlaylist(playlistName);
  969. },
  970. },
  971. truncatedVideosClick: {
  972. ["@click"]() {
  973. const playlistName = this.$el.dataset.playlistName;
  974. this.showPlaylist(playlistName);
  975. },
  976. },
  977. truncatedVideosDisplay: {
  978. ["x-show"]() {
  979. const playlistName = this.$el.dataset.playlistName;
  980. const playlistData = this.playlistsForDisplay.find(
  981. (p) => p.name === playlistName,
  982. );
  983. return playlistData ? playlistData.shouldShowTruncation : false;
  984. },
  985. ["@click"]() {
  986. const playlistName = this.$el.dataset.playlistName;
  987. this.showPlaylist(playlistName);
  988. },
  989. },
  990. // CSP-compliant view bindings
  991. playlistsHeader: {
  992. ["x-show"]() {
  993. return this.currentView === "playlists";
  994. },
  995. },
  996. historyHeader: {
  997. ["x-show"]() {
  998. return this.currentView === "history";
  999. },
  1000. },
  1001. playlistViewHeader: {
  1002. ["x-show"]() {
  1003. return this.currentView === "playlist";
  1004. },
  1005. },
  1006. saveChannelHeader: {
  1007. ["x-show"]() {
  1008. return this.currentView === "saveChannel";
  1009. },
  1010. },
  1011. wikiInspHeader: {
  1012. ["x-show"]() {
  1013. return this.currentView === "wikiInsp";
  1014. },
  1015. },
  1016. importHeader: {
  1017. ["x-show"]() {
  1018. return this.currentView === "import";
  1019. },
  1020. },
  1021. playlistsContainer: {
  1022. ["x-show"]() {
  1023. return this.currentView === "playlists";
  1024. },
  1025. },
  1026. historyContainer: {
  1027. ["x-show"]() {
  1028. return this.currentView === "history";
  1029. },
  1030. },
  1031. playlistViewContainer: {
  1032. ["x-show"]() {
  1033. return this.currentView === "playlist";
  1034. },
  1035. },
  1036. saveChannelContainer: {
  1037. ["x-show"]() {
  1038. return this.currentView === "saveChannel";
  1039. },
  1040. },
  1041. wikiInspContainer: {
  1042. ["x-show"]() {
  1043. return this.currentView === "wikiInsp";
  1044. },
  1045. },
  1046. importContainer: {
  1047. ["x-show"]() {
  1048. return this.currentView === "import";
  1049. },
  1050. },
  1051. exportContainer: {
  1052. ["x-show"]() {
  1053. return this.currentView === "playlists";
  1054. },
  1055. },
  1056. // History-specific bindings for CSP compliance
  1057. historyEmptyState: {
  1058. ["x-show"]() {
  1059. return this.sortedHistory.length === 0;
  1060. },
  1061. },
  1062. // Playlist view-specific bindings for CSP compliance
  1063. playlistViewEmptyState: {
  1064. ["x-show"]() {
  1065. return this.currentPlaylistVideos.length === 0;
  1066. },
  1067. },
  1068. // Tag chip bindings for CSP compliance
  1069. tagChip: {
  1070. ["@click"]() {
  1071. const videoId = this.$el.dataset.videoId;
  1072. const tag = this.$el.dataset.tag;
  1073. this.toggleTag(videoId, tag);
  1074. },
  1075. [":class"]() {
  1076. const videoId = this.$el.dataset.videoId;
  1077. const tag = this.$el.dataset.tag;
  1078. return {
  1079. active: this.isTagActive(videoId, tag),
  1080. };
  1081. },
  1082. },
  1083. // Save Channel specific bindings
  1084. copyCurlButton: {
  1085. ["@click"]() {
  1086. this.copyCurlCommand();
  1087. },
  1088. [":disabled"]() {
  1089. return !this.isCurrentTabChannelPage;
  1090. },
  1091. },
  1092. // Wiki/insp specific bindings
  1093. copyWikitextButton: {
  1094. ["@click"]() {
  1095. this.copyWikitextCommand();
  1096. },
  1097. [":disabled"]() {
  1098. return !this.isCurrentVideoYoutube;
  1099. },
  1100. },
  1101. timestampCheckbox: {
  1102. ["@change"]() {
  1103. this.toggleTimestamp();
  1104. },
  1105. [":checked"]() {
  1106. return this.includeTimestamp;
  1107. },
  1108. },
  1109. refreshTimestampButton: {
  1110. ["@click"]() {
  1111. this.refreshVideoTimestamp();
  1112. },
  1113. [":disabled"]() {
  1114. return !this.isCurrentVideoYoutube;
  1115. },
  1116. },
  1117. categoryButton: {
  1118. ["@click"]() {
  1119. const category = this.$el.dataset.category;
  1120. this.selectCategory(category);
  1121. },
  1122. [":class"]() {
  1123. const category = this.$el.dataset.category;
  1124. return {
  1125. active: this.selectedCategory === category,
  1126. };
  1127. },
  1128. },
  1129. saveChannelNotice: {
  1130. ["x-show"]() {
  1131. return !this.isCurrentTabChannelPage;
  1132. },
  1133. },
  1134. wikiInspNotice: {
  1135. ["x-show"]() {
  1136. return !this.isCurrentVideoYoutube;
  1137. },
  1138. },
  1139. intervalInput: {
  1140. [":value"]() {
  1141. return this.checkInterval;
  1142. },
  1143. ["@input"]() {
  1144. this.updateCheckInterval(this.$el.value);
  1145. },
  1146. },
  1147. // Add current page button binding
  1148. addCurrentPageButton: {
  1149. ["@click"]() {
  1150. this.addCurrentPageToPlaylist();
  1151. },
  1152. [":disabled"]() {
  1153. return (
  1154. !this.currentTab ||
  1155. !this.isCurrentTabYoutube ||
  1156. !this.currentPlaylistName
  1157. );
  1158. },
  1159. [":class"]() {
  1160. return {
  1161. disabled:
  1162. !this.currentTab ||
  1163. !this.isCurrentTabYoutube ||
  1164. !this.currentPlaylistName,
  1165. };
  1166. },
  1167. },
  1168. // Import view bindings
  1169. importTextarea: {
  1170. [":value"]() {
  1171. return this.importText;
  1172. },
  1173. ["@input"]() {
  1174. this.importText = this.$el.value;
  1175. },
  1176. },
  1177. importSubmitButton: {
  1178. ["@click"]() {
  1179. this.handleImport();
  1180. },
  1181. },
  1182. }));
  1183. });