|
|
@@ -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"]() {
|