5 次代碼提交 928cd90aaf ... 4e25952b6b

作者 SHA1 備註 提交日期
  Brandon Wong 4e25952b6b fix history entries merging for YouTube Shorts 1 月之前
  Brandon Wong 2fff0ac7ef fix history tracking for YouTube queue auto-advance 1 月之前
  Brandon Wong cf7254a650 use plain cursor instead of not-allowed on disabled jump buttons 1 月之前
  Brandon Wong 7ba29abb86 add floating up/down buttons to jump-scroll between playlists 1 月之前
  Brandon Wong 5ca275ba83 add tooltip showing full title on history video hover 1 月之前

文件差異過大導致無法顯示
+ 40 - 0
2026-07-11-claude-floating-playlist-scroll-buttons.md


+ 13 - 0
2026-07-11-claude-history-title-tooltip.md

@@ -0,0 +1,13 @@
+# History Title Tooltip
+
+## Request
+
+> in the history view of the popup window, the titles of videos are truncated - that's good. however, when the mouse hovers over them, the full title should appear as a tooltip.
+
+## Changes
+
+- `popup/popup.html`: added `:title="videoHistory.title"` to the `.history-video-title` div (line ~210) alongside the existing `x-text="videoHistory.title"`. Since `videoHistory.title` already holds the untruncated string (truncation is purely visual via `text-overflow: ellipsis` in `popup/popup.css`), binding the native `title` attribute to the same property gives the browser's default tooltip with the full text on hover — no new computed property needed.
+
+## Verification
+
+- Ran the `verify-csp` skill: the new binding is a plain property reference (no expressions, comparisons, or method calls), so it's CSP-compliant. Confirmed the only existing CSP violation in the file (`x-show="videos.length === 0"`, line 195) is pre-existing and unrelated to this change.

文件差異過大導致無法顯示
+ 74 - 0
2026-07-26-claude-fix-queue-history-tracking.md


+ 1 - 1
CLAUDE.md

@@ -11,7 +11,7 @@ Firefox browser extension that manages video playlists and tracks playback histo
 - `popup/` — Extension popup UI (Alpine.js, single-page, multiple views)
 - `background.js` — Service worker: context menus, storage init, autoplay chaining, history tracking
 - `content_scripts/content.js` — Injected into YouTube pages; forwards `play`/`pause`/`ended` events to background
-- `shared/playlist-utils.js` — Shared helpers used by both popup and background (video ID extraction, deduplication, search)
+- `shared/playlist-utils.js` — Shared helpers used by the popup, background, and content script (video ID extraction, URL normalization, deduplication, search)
 
 ## Alpine.js CSP Compliance (Critical)
 

+ 1 - 2
background.js

@@ -143,8 +143,7 @@ async function updateTracking(message) {
 async function updateHistory(message) {
   const { history: currentHistory } =
     await browser.storage.local.get("history");
-  const q = new URL(message.url);
-  const v = q.searchParams.get("v");
+  const v = PlaylistUtils.extractVideoId(message.url);
   if (currentHistory[v]) {
     const { [v]: existing, ...rest } = currentHistory;
 

+ 21 - 17
content_scripts/content.js

@@ -4,7 +4,7 @@
   function msgPlayEvt(type, target) {
     const payload = {
       type,
-      url: window.location.href,
+      url: PlaylistUtils.cleanVideoUrl(window.location.href),
       timestamp: target.currentTime,
       duration: target.duration,
       title: document.title.replace(/ - YouTube$/, ""),
@@ -12,22 +12,26 @@
     return browser.runtime.sendMessage(payload);
   }
 
-  const vid = document.querySelector("video");
-
-  if (vid) {
-    vid.addEventListener("play", function (evt) {
-      msgPlayEvt("play", evt.target);
-    });
-    vid.addEventListener("playing", function (evt) {
-      msgPlayEvt("playing", evt.target);
-    });
-    vid.addEventListener("pause", function (evt) {
-      msgPlayEvt("pause", evt.target);
-    });
-    vid.addEventListener("ended", function (evt) {
-      msgPlayEvt("ended", evt.target);
-    });
-  }
+  // YouTube is a SPA: advancing to the next video in a queue swaps in a new
+  // <video> element without a full page load, so a listener attached to one
+  // specific element (grabbed once at injection time) stops receiving events.
+  // Listening on `document` in the capture phase catches every video, past or
+  // future, while `#movie_player` scopes this to the standard watch-page
+  // player only (excludes home/search hover-preview thumbnails, Shorts, etc).
+  ["play", "playing", "pause", "ended"].forEach(function (type) {
+    document.addEventListener(
+      type,
+      function (evt) {
+        if (
+          evt.target.tagName === "VIDEO" &&
+          evt.target.closest("#movie_player")
+        ) {
+          msgPlayEvt(type, evt.target);
+        }
+      },
+      true,
+    );
+  });
 
   function playIt(tryAgain) {
     const video = document.querySelector("video");

+ 1 - 1
manifest.json

@@ -23,7 +23,7 @@
   "content_scripts": [
     {
       "matches": ["https://*.youtube.com/*"],
-      "js": ["content_scripts/content.js"]
+      "js": ["shared/playlist-utils.js", "content_scripts/content.js"]
     }
   ]
 }

+ 42 - 0
popup/popup.css

@@ -236,6 +236,48 @@ button:hover {
   color: #3367d6;
 }
 
+/* Floating playlist jump-scroll buttons */
+.playlist-jump-buttons {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  z-index: 100;
+}
+
+.jump-btn {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  background-color: #4285f4;
+  color: white;
+  font-size: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+  transition: background-color 0.2s ease;
+}
+
+.jump-btn:hover:not(:disabled) {
+  background-color: #3367d6;
+}
+
+.jump-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: default;
+  box-shadow: none;
+}
+
+/* Reserve room below the last playlist so its videos can scroll clear
+   of the fixed jump buttons (2 x 36px stacked + 8px gap + 16px bottom offset). */
+.playlists-scroll-spacer {
+  height: 110px;
+}
+
 /* History view styles */
 .history-container {
   background: #fff;

+ 20 - 1
popup/popup.html

@@ -199,6 +199,25 @@
             </div>
           </div>
         </template>
+        <div class="playlists-scroll-spacer" aria-hidden="true"></div>
+      </div>
+
+      <!-- Floating jump-scroll buttons (playlists view only) -->
+      <div class="playlist-jump-buttons" x-bind="playlistJumpButtons">
+        <button
+          class="jump-btn jump-up-btn"
+          x-bind="scrollUpButton"
+          title="Jump to previous playlist"
+        >
+          ↑
+        </button>
+        <button
+          class="jump-btn jump-down-btn"
+          x-bind="scrollDownButton"
+          title="Jump to next playlist"
+        >
+          ↓
+        </button>
       </div>
 
       <!-- History view -->
@@ -207,7 +226,7 @@
           <template x-for="videoHistory in sortedHistory" :key="videoHistory.videoId">
             <div class="history-item" :data-video-id="videoHistory.videoId">
               <div class="history-video-info">
-                <div class="history-video-title" x-text="videoHistory.title"></div>
+                <div class="history-video-title" x-text="videoHistory.title" :title="videoHistory.title"></div>
                 <a
                   class="history-video-id"
                   :href="videoHistory.url"

+ 89 - 0
popup/popup.js

@@ -29,6 +29,7 @@ document.addEventListener("alpine:init", () => {
     currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
+    playlistsScrollTop: 0, // Tracks document.body.scrollTop for jump-button reactivity
     currentPlaylistVideos: [], // Videos for current playlist view
     otherPlaylists: [], // Playlist names (with display labels) other than currentPlaylistName
     currentTab: null, // Current active tab info
@@ -78,6 +79,14 @@ document.addEventListener("alpine:init", () => {
           this.closeAllMenus();
         }
       });
+      // Track scroll position of the root scrolling element so the playlist
+      // jump-scroll buttons' disabled state stays reactive. Whether the
+      // scrollable box ends up being <body> or <html> is a browser-decided
+      // CSS quirk (depends on which element's overflow gets propagated to
+      // the viewport), so use window/scrollingElement rather than assuming body.
+      window.addEventListener("scroll", () => {
+        this.playlistsScrollTop = document.scrollingElement.scrollTop;
+      });
     },
 
     async loadPlaylists() {
@@ -146,6 +155,42 @@ document.addEventListener("alpine:init", () => {
       );
     },
 
+    getPlaylistBoundaryOffsets() {
+      const scroller = document.scrollingElement;
+      // For the root scrolling element, getBoundingClientRect().top is
+      // already -scrollTop, so adding scrollTop converts a viewport-relative
+      // rect into a scroll-position-independent document offset.
+      return Array.from(
+        document.querySelectorAll(".playlist-name-clickable"),
+      ).map((el) => el.getBoundingClientRect().top + scroller.scrollTop);
+    },
+
+    hasScrollableOverflow() {
+      const scroller = document.scrollingElement;
+      return scroller.scrollHeight - scroller.clientHeight > 1;
+    },
+
+    scrollToNextPlaylistBoundary() {
+      const offsets = this.getPlaylistBoundaryOffsets();
+      const currentScroll = document.scrollingElement.scrollTop;
+      const next = offsets.find((offset) => offset > currentScroll + 1);
+      if (next !== undefined) {
+        window.scrollTo({ top: next, behavior: "smooth" });
+      }
+    },
+
+    scrollToPreviousPlaylistBoundary() {
+      const offsets = this.getPlaylistBoundaryOffsets();
+      const currentScroll = document.scrollingElement.scrollTop;
+      const previous = [...offsets]
+        .reverse()
+        .find((offset) => offset < currentScroll - 1);
+      window.scrollTo({
+        top: previous !== undefined ? previous : 0,
+        behavior: "smooth",
+      });
+    },
+
     updateCurrentPlaylistVideos() {
       if (
         !this.currentPlaylistName ||
@@ -1394,6 +1439,50 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    playlistJumpButtons: {
+      ["x-show"]() {
+        return this.currentView === "playlists";
+      },
+    },
+
+    scrollUpButton: {
+      ["@click"]() {
+        this.scrollToPreviousPlaylistBoundary();
+      },
+      [":disabled"]() {
+        // Read reactive properties unconditionally (before any early return)
+        // so Alpine always tracks them as dependencies for this binding,
+        // regardless of which branch below ends up short-circuiting.
+        const scrollTop = this.playlistsScrollTop;
+        const playlistCount = this.playlistsForDisplay.length;
+        if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
+        return scrollTop <= 0;
+      },
+    },
+
+    scrollDownButton: {
+      ["@click"]() {
+        this.scrollToNextPlaylistBoundary();
+      },
+      [":disabled"]() {
+        const scrollTop = this.playlistsScrollTop;
+        const playlistCount = this.playlistsForDisplay.length;
+        if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
+        const offsets = this.getPlaylistBoundaryOffsets();
+        if (offsets.length === 0) return true;
+        // The last playlist's heading may sit past the maximum scrollable
+        // position (there isn't enough content below it to scroll that far),
+        // so cap the target at whichever is reachable.
+        const scroller = document.scrollingElement;
+        const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
+        const lastReachableOffset = Math.min(
+          offsets[offsets.length - 1],
+          maxScrollTop,
+        );
+        return scrollTop >= lastReachableOffset - 1;
+      },
+    },
+
     // History-specific bindings for CSP compliance
     historyEmptyState: {
       ["x-show"]() {

+ 37 - 4
shared/playlist-utils.js

@@ -3,19 +3,47 @@
 
 const PlaylistUtils = {
   /**
-   * Extract video ID from YouTube URL
+   * Extract video ID from YouTube URL. Handles both standard watch URLs
+   * (`/watch?v=<id>`) and Shorts URLs (`/shorts/<id>`, which carry the ID
+   * in the path rather than a `v` query param).
    * @param {string} url - YouTube URL
    * @returns {string|null} - Video ID or null if not found
    */
   extractVideoId(url) {
     try {
       const urlObj = new URL(url);
-      return urlObj.searchParams.get("v");
+      const vParam = urlObj.searchParams.get("v");
+      if (vParam) {
+        return vParam;
+      }
+      const shortsMatch = urlObj.pathname.match(/^\/shorts\/([^/?]+)/);
+      return shortsMatch ? shortsMatch[1] : null;
     } catch (e) {
       return null;
     }
   },
 
+  /**
+   * Normalize a YouTube video URL down to just its video ID, stripping
+   * queue/playlist params like `list`, `index`, and `pp` so that
+   * navigating to the stored URL later doesn't drop the user back into
+   * whatever queue or playlist they originally watched it from. Shorts
+   * URLs keep the `/shorts/<id>` form rather than being rewritten to
+   * `/watch?v=<id>`.
+   * @param {string} url - YouTube URL
+   * @returns {string} - Clean URL, or the original url if no video ID is found
+   */
+  cleanVideoUrl(url) {
+    const videoId = this.extractVideoId(url);
+    if (!videoId) {
+      return url;
+    }
+    const isShorts = new URL(url).pathname.startsWith("/shorts/");
+    return isShorts
+      ? `https://www.youtube.com/shorts/${videoId}`
+      : `https://www.youtube.com/watch?v=${videoId}`;
+  },
+
   /**
    * Find a video in all playlists by URL
    * @param {Object} playlists - All playlists object
@@ -69,7 +97,12 @@ const PlaylistUtils = {
     const { playlists: currentPlaylists } =
       await browser.storage.local.get("playlists");
 
-    const alreadyExists = this.findPlaylist(currentPlaylists, video.url);
+    const cleanedVideo = { ...video, url: this.cleanVideoUrl(video.url) };
+
+    const alreadyExists = this.findPlaylist(
+      currentPlaylists,
+      cleanedVideo.url,
+    );
 
     if (alreadyExists) {
       console.log("Video already exists in playlist:", alreadyExists);
@@ -77,7 +110,7 @@ const PlaylistUtils = {
     }
 
     // Add video to the specified playlist
-    const updatedPlaylist = [...currentPlaylists[playlistName], video];
+    const updatedPlaylist = [...currentPlaylists[playlistName], cleanedVideo];
     const updatedPlaylists = {
       ...currentPlaylists,
       [playlistName]: updatedPlaylist,