popup.js 45 KB

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