popup.js 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448
  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 moveVideoToTop(playlistName, index) {
  541. const playlists = JSON.parse(JSON.stringify(this.playlists));
  542. const playlist = [...playlists[playlistName]];
  543. const topPosition = this.findTopPosition(playlistName);
  544. if (index === topPosition) return;
  545. const video = playlist.splice(index, 1)[0];
  546. playlist.splice(topPosition, 0, video);
  547. const updatedPlaylists = {
  548. ...playlists,
  549. [playlistName]: playlist,
  550. };
  551. try {
  552. await browser.storage.local.set({ playlists: updatedPlaylists });
  553. this.playlists = updatedPlaylists;
  554. this.updatePlaylistsForDisplay();
  555. this.updateCurrentPlaylistVideos();
  556. } catch (error) {
  557. console.error("Error moving video to top:", error);
  558. }
  559. },
  560. async moveVideoToBottom(playlistName, index) {
  561. const playlists = JSON.parse(JSON.stringify(this.playlists));
  562. const playlist = [...playlists[playlistName]];
  563. if (index === playlist.length - 1) return;
  564. const video = playlist.splice(index, 1)[0];
  565. playlist.push(video);
  566. const updatedPlaylists = {
  567. ...playlists,
  568. [playlistName]: playlist,
  569. };
  570. try {
  571. await browser.storage.local.set({ playlists: updatedPlaylists });
  572. this.playlists = updatedPlaylists;
  573. this.updatePlaylistsForDisplay();
  574. this.updateCurrentPlaylistVideos();
  575. } catch (error) {
  576. console.error("Error moving video to bottom:", error);
  577. }
  578. },
  579. findTopPosition(playlistName) {
  580. const playlist = this.playlists[playlistName];
  581. if (!playlist) return 0;
  582. for (let i = 0; i < playlist.length; i++) {
  583. if (playlist[i].status !== "done") {
  584. return i;
  585. }
  586. }
  587. return playlist.length;
  588. },
  589. async toggleVideoDoneStatus(playlistName, index) {
  590. const playlists = JSON.parse(JSON.stringify(this.playlists));
  591. const playlist = [...playlists[playlistName]];
  592. const video = { ...playlist[index] };
  593. // Toggle the done status
  594. if (video.status === "done") {
  595. // Remove status property (undefined status means not done)
  596. delete video.status;
  597. } else {
  598. // Set status to done
  599. video.status = "done";
  600. }
  601. // Update the video in the playlist
  602. playlist[index] = video;
  603. // Create an updated playlists object
  604. const updatedPlaylists = {
  605. ...playlists,
  606. [playlistName]: playlist,
  607. };
  608. // Update the playlists in storage
  609. try {
  610. await browser.storage.local.set({ playlists: updatedPlaylists });
  611. this.playlists = updatedPlaylists;
  612. this.updatePlaylistsForDisplay();
  613. this.updateCurrentPlaylistVideos();
  614. } catch (error) {
  615. console.error("Error toggling video done status:", error);
  616. }
  617. },
  618. async addCurrentPageToPlaylist() {
  619. if (
  620. !this.currentTab ||
  621. !this.isCurrentTabYoutube ||
  622. !this.currentPlaylistName
  623. ) {
  624. return;
  625. }
  626. // Create video object using current tab info
  627. const video = {
  628. url: this.currentTab.url,
  629. title: this.currentTab.title,
  630. };
  631. // Use shared utility function to add video (handles duplicate checking)
  632. const wasAdded = await PlaylistUtils.addVideoToPlaylist(
  633. this.currentPlaylistName,
  634. video,
  635. );
  636. if (wasAdded) {
  637. // Refresh displays only if video was actually added
  638. this.loadPlaylists(); // This will update both display arrays
  639. } else {
  640. // Could show user feedback that video already exists
  641. console.log("Video already exists in a playlist");
  642. }
  643. },
  644. async exportPlaylists() {
  645. try {
  646. // Get current playlists
  647. const playlistResult = await browser.storage.local.get("playlists");
  648. const playlists = playlistResult.playlists || {};
  649. // Get playback history
  650. const historyResult = await browser.storage.local.get("history");
  651. const playbackHistory = historyResult.history || {};
  652. // Get open tabs
  653. let openTabs = [];
  654. try {
  655. const tabsResult = await browser.tabs.query({});
  656. openTabs = tabsResult
  657. .filter(
  658. (tab) =>
  659. tab.url &&
  660. !tab.url.startsWith("moz-extension://") &&
  661. !tab.url.startsWith("about:") &&
  662. !tab.url.startsWith("chrome://"),
  663. )
  664. .map((tab) => ({
  665. url: tab.url,
  666. title: tab.title || "Untitled",
  667. }));
  668. } catch (error) {
  669. console.warn("Could not retrieve open tabs:", error);
  670. openTabs = [];
  671. }
  672. // Create export data object
  673. const exportData = {
  674. playlists,
  675. playbackHistory,
  676. openTabs,
  677. exportDate: new Date().toISOString(),
  678. };
  679. // Convert to JSON
  680. const jsonString = JSON.stringify(exportData, null, 2);
  681. // Create download
  682. const blob = new Blob([jsonString], { type: "application/json" });
  683. const url = URL.createObjectURL(blob);
  684. // Trigger download
  685. const a = document.createElement("a");
  686. a.href = url;
  687. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  688. document.body.appendChild(a);
  689. a.click();
  690. // Clean up
  691. setTimeout(() => {
  692. document.body.removeChild(a);
  693. URL.revokeObjectURL(url);
  694. }, 100);
  695. } catch (error) {
  696. console.error("Error exporting playlists:", error);
  697. }
  698. },
  699. videotitle: {
  700. ["@click"]() {
  701. console.log("TITLE CLICK", this.$el);
  702. },
  703. },
  704. videoPlayLink: {
  705. ["@click.prevent"]() {
  706. browser.tabs.update({ url: this.$el.href });
  707. },
  708. },
  709. isNonContiguousDone(playlistName, videoIndex) {
  710. const playlist = this.playlists[playlistName];
  711. if (
  712. !playlist ||
  713. !playlist[videoIndex] ||
  714. playlist[videoIndex].status !== "done"
  715. ) {
  716. return false;
  717. }
  718. // Check if there's any non-done video before this done video
  719. for (let i = 0; i < videoIndex; i++) {
  720. if (playlist[i].status !== "done") {
  721. return true;
  722. }
  723. }
  724. return false;
  725. },
  726. isCurrentVideo(playlistName, index) {
  727. const currentIndex = this.currentIndices[playlistName];
  728. return currentIndex === index;
  729. },
  730. isDoneVideo(playlistName, index) {
  731. const currentIndex = this.currentIndices[playlistName];
  732. return index < currentIndex;
  733. },
  734. isVideoDone(playlistName, index) {
  735. const video =
  736. this.playlists[playlistName] && this.playlists[playlistName][index];
  737. return video && video.status === "done";
  738. },
  739. videoItemClass: {
  740. [":class"]() {
  741. const playlistName = this.$el.dataset.playlistName;
  742. const index = parseInt(this.$el.dataset.playlistIndex);
  743. const video = this.playlists[playlistName][index];
  744. return {
  745. "current-video": this.isCurrentVideo(playlistName, index),
  746. "done-video":
  747. this.isDoneVideo(playlistName, index) && video.status === "done",
  748. "non-contiguous-done-video": this.isNonContiguousDone(
  749. playlistName,
  750. index,
  751. ),
  752. };
  753. },
  754. },
  755. removeVideoButton: {
  756. ["@click"]() {
  757. this.removeVideo(
  758. this.$el.dataset.playlistName,
  759. this.$el.dataset.playlistIndex,
  760. );
  761. this.closeAllMenus();
  762. },
  763. },
  764. toggleVideoDoneButton: {
  765. ["@click"]() {
  766. this.toggleVideoDoneStatus(
  767. this.$el.dataset.playlistName,
  768. parseInt(this.$el.dataset.playlistIndex),
  769. );
  770. this.closeAllMenus();
  771. },
  772. },
  773. moveUpButton: {
  774. ["@click"]() {
  775. this.moveVideoUp(
  776. this.$el.dataset.playlistName,
  777. parseInt(this.$el.dataset.playlistIndex),
  778. );
  779. },
  780. [":disabled"]() {
  781. return parseInt(this.$el.dataset.playlistIndex) === 0;
  782. },
  783. },
  784. moveDownButton: {
  785. ["@click"]() {
  786. this.moveVideoDown(
  787. this.$el.dataset.playlistName,
  788. parseInt(this.$el.dataset.playlistIndex),
  789. );
  790. },
  791. [":disabled"]() {
  792. return (
  793. parseInt(this.$el.dataset.playlistIndex) ===
  794. this.playlists[this.$el.dataset.playlistName].length - 1
  795. );
  796. },
  797. },
  798. moveToTopButton: {
  799. ["@click"]() {
  800. this.moveVideoToTop(
  801. this.$el.dataset.playlistName,
  802. parseInt(this.$el.dataset.playlistIndex),
  803. );
  804. this.closeAllMenus();
  805. },
  806. [":disabled"]() {
  807. const index = parseInt(this.$el.dataset.playlistIndex);
  808. const playlistName = this.$el.dataset.playlistName;
  809. const topPosition = this.findTopPosition(playlistName);
  810. return index === topPosition;
  811. },
  812. },
  813. moveToBottomButton: {
  814. ["@click"]() {
  815. this.moveVideoToBottom(
  816. this.$el.dataset.playlistName,
  817. parseInt(this.$el.dataset.playlistIndex),
  818. );
  819. this.closeAllMenus();
  820. },
  821. [":disabled"]() {
  822. return (
  823. parseInt(this.$el.dataset.playlistIndex) ===
  824. this.playlists[this.$el.dataset.playlistName].length - 1
  825. );
  826. },
  827. },
  828. exportButton: {
  829. ["@click"]() {
  830. this.exportPlaylists();
  831. },
  832. },
  833. importButton: {
  834. ["@click"]() {
  835. this.showImport();
  836. },
  837. },
  838. async handleImport() {
  839. if (!this.importText.trim()) {
  840. alert("Please paste your JSON export data");
  841. return;
  842. }
  843. try {
  844. const importedData = JSON.parse(this.importText);
  845. this.validateAndImportPlaylists(importedData);
  846. this.importText = "";
  847. } catch (error) {
  848. console.error("Error parsing JSON:", error);
  849. alert(
  850. "Invalid JSON format. Please paste valid playlist export data.",
  851. );
  852. }
  853. },
  854. validateAndImportPlaylists(data) {
  855. // Validate the playlists structure
  856. if (!data.playlists || typeof data.playlists !== "object") {
  857. alert("Invalid file format: Missing or invalid 'playlists' property");
  858. return;
  859. }
  860. // Validate each playlist
  861. const validPlaylists = {};
  862. let hasErrors = false;
  863. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  864. // Check if videos is an array
  865. if (!Array.isArray(videos)) {
  866. console.error(
  867. `Playlist '${playlistName}' does not contain a valid array of videos`,
  868. );
  869. hasErrors = true;
  870. continue;
  871. }
  872. // Validate each video in the playlist and filter out done videos
  873. const validVideos = videos.filter((video) => {
  874. if (!video || typeof video !== "object") {
  875. console.error(`Invalid video object in '${playlistName}'`);
  876. return false;
  877. }
  878. if (
  879. !video.url ||
  880. typeof video.url !== "string" ||
  881. !video.title ||
  882. typeof video.title !== "string"
  883. ) {
  884. console.error(
  885. `Video in '${playlistName}' missing required properties (url, title)`,
  886. );
  887. return false;
  888. }
  889. if (video.status === "done") {
  890. return false;
  891. }
  892. return true;
  893. });
  894. // Add the validated playlist if it has valid videos
  895. if (validVideos.length > 0) {
  896. validPlaylists[playlistName] = validVideos;
  897. }
  898. }
  899. if (Object.keys(validPlaylists).length === 0) {
  900. alert("No valid playlists found in the import file");
  901. return;
  902. }
  903. if (hasErrors) {
  904. const confirmImport = confirm(
  905. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  906. );
  907. if (!confirmImport) return;
  908. }
  909. // Update storage and state
  910. this.appendToPlaylists(validPlaylists);
  911. },
  912. async appendToPlaylists(importedPlaylists) {
  913. try {
  914. const currentPlaylists = JSON.parse(JSON.stringify(this.playlists));
  915. let addedCount = 0;
  916. let skippedCount = 0;
  917. for (const [playlistName, importedVideos] of Object.entries(importedPlaylists)) {
  918. if (!currentPlaylists[playlistName]) {
  919. currentPlaylists[playlistName] = [];
  920. }
  921. for (const video of importedVideos) {
  922. const alreadyExists = PlaylistUtils.findPlaylist(currentPlaylists, video.url);
  923. if (!alreadyExists) {
  924. currentPlaylists[playlistName].push(video);
  925. addedCount++;
  926. } else {
  927. skippedCount++;
  928. }
  929. }
  930. }
  931. await browser.storage.local.set({ playlists: currentPlaylists });
  932. this.playlists = currentPlaylists;
  933. this.updatePlaylistsForDisplay();
  934. this.updateCurrentPlaylistVideos();
  935. alert(`Import complete! Added ${addedCount} video(s), skipped ${skippedCount} duplicate(s).`);
  936. } catch (error) {
  937. console.error("Error appending playlists:", error);
  938. alert("Error importing playlists: " + error.message);
  939. }
  940. },
  941. getMenuId(playlistName, index) {
  942. return `${playlistName}-${index}`;
  943. },
  944. isMenuOpen(playlistName, index) {
  945. return this.openMenus.has(this.getMenuId(playlistName, index));
  946. },
  947. toggleMenu(playlistName, index) {
  948. const menuId = this.getMenuId(playlistName, index);
  949. if (this.openMenus.has(menuId)) {
  950. this.openMenus.delete(menuId);
  951. } else {
  952. // Close all other menus first
  953. this.openMenus.clear();
  954. this.openMenus.add(menuId);
  955. }
  956. },
  957. closeAllMenus() {
  958. this.openMenus.clear();
  959. },
  960. moreMenuButton: {
  961. ["@click.stop"]() {
  962. this.toggleMenu(
  963. this.$el.dataset.playlistName,
  964. parseInt(this.$el.dataset.playlistIndex),
  965. );
  966. },
  967. },
  968. moreMenu: {
  969. ["x-show"]() {
  970. return this.isMenuOpen(
  971. this.$el.dataset.playlistName,
  972. parseInt(this.$el.dataset.playlistIndex),
  973. );
  974. },
  975. },
  976. // View navigation methods
  977. showHistory() {
  978. this.currentView = "history";
  979. this.loadHistory(); // Refresh history data when switching to history view
  980. },
  981. showPlaylists() {
  982. this.currentView = "playlists";
  983. },
  984. showPlaylist(playlistName) {
  985. this.currentView = "playlist";
  986. this.currentPlaylistName = playlistName;
  987. this.updateCurrentPlaylistVideos();
  988. },
  989. // Methods for truncated display
  990. shouldShowTruncation(playlistName) {
  991. const currentIndex = this.currentIndices[playlistName] || 0;
  992. return currentIndex > 0;
  993. },
  994. getTruncationText(playlistName) {
  995. const currentIndex = this.currentIndices[playlistName] || 0;
  996. const count = currentIndex;
  997. return `(${count} previous video${count === 1 ? "" : "s"})`;
  998. },
  999. getVisibleVideos(playlistName, videos) {
  1000. const currentIndex = this.currentIndices[playlistName] || 0;
  1001. // Show from current video onwards, but keep original indices
  1002. return videos.slice(currentIndex).map((video, index) => ({
  1003. ...video,
  1004. originalIndex: currentIndex + index,
  1005. }));
  1006. },
  1007. getCurrentPlaylistVideos() {
  1008. if (
  1009. !this.currentPlaylistName ||
  1010. !this.playlists[this.currentPlaylistName]
  1011. ) {
  1012. return [];
  1013. }
  1014. return this.playlists[this.currentPlaylistName];
  1015. },
  1016. // Button bindings for navigation
  1017. historyButton: {
  1018. ["@click"]() {
  1019. this.showHistory();
  1020. },
  1021. },
  1022. saveChannelButton: {
  1023. ["@click"]() {
  1024. this.showSaveChannel();
  1025. },
  1026. },
  1027. wikiInspButton: {
  1028. ["@click"]() {
  1029. this.showWikiInsp();
  1030. },
  1031. },
  1032. backButton: {
  1033. ["@click"]() {
  1034. this.showPlaylists();
  1035. },
  1036. },
  1037. // Event handlers for playlist navigation
  1038. playlistNameClick: {
  1039. ["@click"]() {
  1040. const playlistName = this.$el.dataset.playlistName;
  1041. this.showPlaylist(playlistName);
  1042. },
  1043. },
  1044. truncatedVideosClick: {
  1045. ["@click"]() {
  1046. const playlistName = this.$el.dataset.playlistName;
  1047. this.showPlaylist(playlistName);
  1048. },
  1049. },
  1050. truncatedVideosClick: {
  1051. ["@click"]() {
  1052. const playlistName = this.$el.dataset.playlistName;
  1053. this.showPlaylist(playlistName);
  1054. },
  1055. },
  1056. truncatedVideosDisplay: {
  1057. ["x-show"]() {
  1058. const playlistName = this.$el.dataset.playlistName;
  1059. const playlistData = this.playlistsForDisplay.find(
  1060. (p) => p.name === playlistName,
  1061. );
  1062. return playlistData ? playlistData.shouldShowTruncation : false;
  1063. },
  1064. ["@click"]() {
  1065. const playlistName = this.$el.dataset.playlistName;
  1066. this.showPlaylist(playlistName);
  1067. },
  1068. },
  1069. // CSP-compliant view bindings
  1070. playlistsHeader: {
  1071. ["x-show"]() {
  1072. return this.currentView === "playlists";
  1073. },
  1074. },
  1075. historyHeader: {
  1076. ["x-show"]() {
  1077. return this.currentView === "history";
  1078. },
  1079. },
  1080. playlistViewHeader: {
  1081. ["x-show"]() {
  1082. return this.currentView === "playlist";
  1083. },
  1084. },
  1085. saveChannelHeader: {
  1086. ["x-show"]() {
  1087. return this.currentView === "saveChannel";
  1088. },
  1089. },
  1090. wikiInspHeader: {
  1091. ["x-show"]() {
  1092. return this.currentView === "wikiInsp";
  1093. },
  1094. },
  1095. importHeader: {
  1096. ["x-show"]() {
  1097. return this.currentView === "import";
  1098. },
  1099. },
  1100. playlistsContainer: {
  1101. ["x-show"]() {
  1102. return this.currentView === "playlists";
  1103. },
  1104. },
  1105. historyContainer: {
  1106. ["x-show"]() {
  1107. return this.currentView === "history";
  1108. },
  1109. },
  1110. playlistViewContainer: {
  1111. ["x-show"]() {
  1112. return this.currentView === "playlist";
  1113. },
  1114. },
  1115. saveChannelContainer: {
  1116. ["x-show"]() {
  1117. return this.currentView === "saveChannel";
  1118. },
  1119. },
  1120. wikiInspContainer: {
  1121. ["x-show"]() {
  1122. return this.currentView === "wikiInsp";
  1123. },
  1124. },
  1125. importContainer: {
  1126. ["x-show"]() {
  1127. return this.currentView === "import";
  1128. },
  1129. },
  1130. exportContainer: {
  1131. ["x-show"]() {
  1132. return this.currentView === "playlists";
  1133. },
  1134. },
  1135. // History-specific bindings for CSP compliance
  1136. historyEmptyState: {
  1137. ["x-show"]() {
  1138. return this.sortedHistory.length === 0;
  1139. },
  1140. },
  1141. // Playlist view-specific bindings for CSP compliance
  1142. playlistViewEmptyState: {
  1143. ["x-show"]() {
  1144. return this.currentPlaylistVideos.length === 0;
  1145. },
  1146. },
  1147. // Tag chip bindings for CSP compliance
  1148. tagChip: {
  1149. ["@click"]() {
  1150. const videoId = this.$el.dataset.videoId;
  1151. const tag = this.$el.dataset.tag;
  1152. this.toggleTag(videoId, tag);
  1153. },
  1154. [":class"]() {
  1155. const videoId = this.$el.dataset.videoId;
  1156. const tag = this.$el.dataset.tag;
  1157. return {
  1158. active: this.isTagActive(videoId, tag),
  1159. };
  1160. },
  1161. },
  1162. // Save Channel specific bindings
  1163. copyCurlButton: {
  1164. ["@click"]() {
  1165. this.copyCurlCommand();
  1166. },
  1167. [":disabled"]() {
  1168. return !this.isCurrentTabChannelPage;
  1169. },
  1170. },
  1171. // Wiki/insp specific bindings
  1172. copyWikitextButton: {
  1173. ["@click"]() {
  1174. this.copyWikitextCommand();
  1175. },
  1176. [":disabled"]() {
  1177. return !this.isCurrentVideoYoutube;
  1178. },
  1179. },
  1180. timestampCheckbox: {
  1181. ["@change"]() {
  1182. this.toggleTimestamp();
  1183. },
  1184. [":checked"]() {
  1185. return this.includeTimestamp;
  1186. },
  1187. },
  1188. refreshTimestampButton: {
  1189. ["@click"]() {
  1190. this.refreshVideoTimestamp();
  1191. },
  1192. [":disabled"]() {
  1193. return !this.isCurrentVideoYoutube;
  1194. },
  1195. },
  1196. categoryButton: {
  1197. ["@click"]() {
  1198. const category = this.$el.dataset.category;
  1199. this.selectCategory(category);
  1200. },
  1201. [":class"]() {
  1202. const category = this.$el.dataset.category;
  1203. return {
  1204. active: this.selectedCategory === category,
  1205. };
  1206. },
  1207. },
  1208. saveChannelNotice: {
  1209. ["x-show"]() {
  1210. return !this.isCurrentTabChannelPage;
  1211. },
  1212. },
  1213. wikiInspNotice: {
  1214. ["x-show"]() {
  1215. return !this.isCurrentVideoYoutube;
  1216. },
  1217. },
  1218. intervalInput: {
  1219. [":value"]() {
  1220. return this.checkInterval;
  1221. },
  1222. ["@input"]() {
  1223. this.updateCheckInterval(this.$el.value);
  1224. },
  1225. },
  1226. // Add current page button binding
  1227. addCurrentPageButton: {
  1228. ["@click"]() {
  1229. this.addCurrentPageToPlaylist();
  1230. },
  1231. [":disabled"]() {
  1232. return (
  1233. !this.currentTab ||
  1234. !this.isCurrentTabYoutube ||
  1235. !this.currentPlaylistName
  1236. );
  1237. },
  1238. [":class"]() {
  1239. return {
  1240. disabled:
  1241. !this.currentTab ||
  1242. !this.isCurrentTabYoutube ||
  1243. !this.currentPlaylistName,
  1244. };
  1245. },
  1246. },
  1247. // Import view bindings
  1248. importTextarea: {
  1249. [":value"]() {
  1250. return this.importText;
  1251. },
  1252. ["@input"]() {
  1253. this.importText = this.$el.value;
  1254. },
  1255. },
  1256. importSubmitButton: {
  1257. ["@click"]() {
  1258. this.handleImport();
  1259. },
  1260. },
  1261. }));
  1262. });