popup.js 37 KB

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