pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

feat: hide the arrow buttons on scroll lists when at either end of the list (#61)

authored by

Rj Manhas and committed by
GitHub
598f752b a14b3755

+248 -74
+55 -18
src/components/overlays/detailsModal/components/carousels/EpisodeCarousel.tsx
··· 45 45 const updateItem = useProgressStore((s) => s.updateItem); 46 46 const confirmModal = useModal("season-watch-confirm"); 47 47 48 + const [canScrollLeft, setCanScrollLeft] = useState(false); 49 + const [canScrollRight, setCanScrollRight] = useState(false); 50 + 51 + const updateScrollState = () => { 52 + if (!carouselRef.current) { 53 + setCanScrollLeft(false); 54 + setCanScrollRight(false); 55 + return; 56 + } 57 + 58 + const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; 59 + const isAtStart = scrollLeft <= 1; 60 + const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1; 61 + 62 + setCanScrollLeft(!isAtStart); 63 + setCanScrollRight(!isAtEnd); 64 + }; 65 + 66 + useEffect(() => { 67 + const carousel = carouselRef.current; 68 + if (!carousel) return; 69 + 70 + updateScrollState(); 71 + 72 + carousel.addEventListener("scroll", updateScrollState); 73 + window.addEventListener("resize", updateScrollState); 74 + 75 + return () => { 76 + carousel.removeEventListener("scroll", updateScrollState); 77 + window.removeEventListener("resize", updateScrollState); 78 + }; 79 + }, []); 80 + 48 81 const handleScroll = (direction: "left" | "right") => { 49 82 if (!carouselRef.current) return; 50 83 ··· 530 563 {/* Episodes Carousel */} 531 564 <div className="relative"> 532 565 {/* Left scroll button */} 533 - <div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> 534 - <button 535 - type="button" 536 - className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 537 - onClick={() => handleScroll("left")} 538 - > 539 - <Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" /> 540 - </button> 541 - </div> 566 + {canScrollLeft && ( 567 + <div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> 568 + <button 569 + type="button" 570 + className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 571 + onClick={() => handleScroll("left")} 572 + > 573 + <Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" /> 574 + </button> 575 + </div> 576 + )} 542 577 543 578 <div 544 579 ref={carouselRef} ··· 783 818 </div> 784 819 785 820 {/* Right scroll button */} 786 - <div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> 787 - <button 788 - type="button" 789 - className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 790 - onClick={() => handleScroll("right")} 791 - > 792 - <Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" /> 793 - </button> 794 - </div> 821 + {canScrollRight && ( 822 + <div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block"> 823 + <button 824 + type="button" 825 + className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 826 + onClick={() => handleScroll("right")} 827 + > 828 + <Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" /> 829 + </button> 830 + </div> 831 + )} 795 832 </div> 796 833 </div> 797 834 );
+63 -26
src/components/player/atoms/Episodes.tsx
··· 766 766 ], 767 767 ); 768 768 769 + const [canScrollLeft, setCanScrollLeft] = useState(false); 770 + const [canScrollRight, setCanScrollRight] = useState(false); 771 + 772 + const updateScrollState = () => { 773 + if (!carouselRef.current) { 774 + setCanScrollLeft(false); 775 + setCanScrollRight(false); 776 + return; 777 + } 778 + 779 + const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; 780 + const isAtStart = scrollLeft <= 1; 781 + const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1; 782 + 783 + setCanScrollLeft(!isAtStart); 784 + setCanScrollRight(!isAtEnd); 785 + }; 786 + 787 + useEffect(() => { 788 + const carousel = carouselRef.current; 789 + if (!carousel) return; 790 + 791 + updateScrollState(); 792 + 793 + carousel.addEventListener("scroll", updateScrollState); 794 + window.addEventListener("resize", updateScrollState); 795 + 796 + return () => { 797 + carousel.removeEventListener("scroll", updateScrollState); 798 + window.removeEventListener("resize", updateScrollState); 799 + }; 800 + }, []); 801 + 769 802 const handleScroll = (direction: "left" | "right") => { 770 803 if (!carouselRef.current) return; 771 804 ··· 916 949 content = ( 917 950 <div className="relative"> 918 951 {/* Horizontal scroll buttons */} 919 - <div 920 - className={classNames( 921 - "absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4", 922 - forceCompactEpisodeView ? "hidden" : "hidden lg:block", 923 - )} 924 - > 925 - <button 926 - type="button" 927 - className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 928 - onClick={() => handleScroll("left")} 952 + {canScrollLeft && ( 953 + <div 954 + className={classNames( 955 + "absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4", 956 + forceCompactEpisodeView ? "hidden" : "hidden lg:block", 957 + )} 929 958 > 930 - <Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" /> 931 - </button> 932 - </div> 959 + <button 960 + type="button" 961 + className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 962 + onClick={() => handleScroll("left")} 963 + > 964 + <Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" /> 965 + </button> 966 + </div> 967 + )} 933 968 934 969 <div 935 970 ref={carouselRef} ··· 996 1031 </div> 997 1032 998 1033 {/* Right scroll button */} 999 - <div 1000 - className={classNames( 1001 - "absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4", 1002 - forceCompactEpisodeView ? "hidden" : "hidden lg:block", 1003 - )} 1004 - > 1005 - <button 1006 - type="button" 1007 - className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 1008 - onClick={() => handleScroll("right")} 1034 + {canScrollRight && ( 1035 + <div 1036 + className={classNames( 1037 + "absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4", 1038 + forceCompactEpisodeView ? "hidden" : "hidden lg:block", 1039 + )} 1009 1040 > 1010 - <Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" /> 1011 - </button> 1012 - </div> 1041 + <button 1042 + type="button" 1043 + className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm" 1044 + onClick={() => handleScroll("right")} 1045 + > 1046 + <Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" /> 1047 + </button> 1048 + </div> 1049 + )} 1013 1050 </div> 1014 1051 ); 1015 1052 }
+50 -3
src/pages/discover/components/CarouselNavButtons.tsx
··· 1 + import { useCallback, useEffect, useState } from "react"; 2 + 1 3 import { Icon, Icons } from "@/components/Icon"; 2 4 import { Flare } from "@/components/utils/Flare"; 3 5 ··· 11 13 interface NavButtonProps { 12 14 direction: "left" | "right"; 13 15 onClick: () => void; 16 + visible: boolean; 14 17 } 15 18 16 - function NavButton({ direction, onClick }: NavButtonProps) { 19 + function NavButton({ direction, onClick, visible }: NavButtonProps) { 20 + if (!visible) return null; 21 + 17 22 return ( 18 23 <button 19 24 type="button" ··· 43 48 categorySlug, 44 49 carouselRefs, 45 50 }: CarouselNavButtonsProps) { 51 + const [canScrollLeft, setCanScrollLeft] = useState(false); 52 + const [canScrollRight, setCanScrollRight] = useState(false); 53 + 54 + const updateScrollState = useCallback(() => { 55 + const carousel = carouselRefs.current[categorySlug]; 56 + if (!carousel) { 57 + setCanScrollLeft(false); 58 + setCanScrollRight(false); 59 + return; 60 + } 61 + 62 + const { scrollLeft, scrollWidth, clientWidth } = carousel; 63 + const isAtStart = scrollLeft <= 1; 64 + const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1; 65 + 66 + setCanScrollLeft(!isAtStart); 67 + setCanScrollRight(!isAtEnd); 68 + }, [categorySlug, carouselRefs]); 69 + 70 + useEffect(() => { 71 + const carousel = carouselRefs.current[categorySlug]; 72 + if (!carousel) return; 73 + 74 + updateScrollState(); 75 + 76 + carousel.addEventListener("scroll", updateScrollState); 77 + window.addEventListener("resize", updateScrollState); 78 + 79 + return () => { 80 + carousel.removeEventListener("scroll", updateScrollState); 81 + window.removeEventListener("resize", updateScrollState); 82 + }; 83 + }, [categorySlug, carouselRefs, updateScrollState]); 84 + 46 85 const handleScroll = (direction: "left" | "right") => { 47 86 const carousel = carouselRefs.current[categorySlug]; 48 87 if (!carousel) return; ··· 76 115 77 116 return ( 78 117 <> 79 - <NavButton direction="left" onClick={() => handleScroll("left")} /> 80 - <NavButton direction="right" onClick={() => handleScroll("right")} /> 118 + <NavButton 119 + direction="left" 120 + onClick={() => handleScroll("left")} 121 + visible={canScrollLeft} 122 + /> 123 + <NavButton 124 + direction="right" 125 + onClick={() => handleScroll("right")} 126 + visible={canScrollRight} 127 + /> 81 128 </> 82 129 ); 83 130 }
+79 -26
src/pages/discover/components/CategoryButtons.tsx
··· 1 + import { useCallback, useEffect, useState } from "react"; 2 + 1 3 import { Icon, Icons } from "@/components/Icon"; 2 4 3 5 interface CategoryButtonsProps { ··· 15 17 isMobile, 16 18 showAlwaysScroll, 17 19 }: CategoryButtonsProps) { 18 - const renderScrollButton = (direction: "left" | "right") => ( 19 - <div> 20 - <button 21 - type="button" 22 - className="flex items-center rounded-full px-4 text-white py-3" 23 - onClick={() => { 24 - const element = document.getElementById( 25 - `button-carousel-${categoryType}`, 26 - ); 27 - if (element) { 28 - element.scrollBy({ 29 - left: direction === "left" ? -200 : 200, 30 - behavior: "smooth", 31 - }); 32 - } 33 - }} 34 - > 35 - <Icon 36 - icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT} 37 - className="text-2xl rtl:-scale-x-100" 38 - /> 39 - </button> 40 - </div> 41 - ); 20 + const [canScrollLeft, setCanScrollLeft] = useState(false); 21 + const [canScrollRight, setCanScrollRight] = useState(false); 22 + 23 + const updateScrollState = useCallback(() => { 24 + const element = document.getElementById(`button-carousel-${categoryType}`); 25 + if (!element) { 26 + setCanScrollLeft(false); 27 + setCanScrollRight(false); 28 + return; 29 + } 30 + 31 + const { scrollLeft, scrollWidth, clientWidth } = element; 32 + const isAtStart = scrollLeft <= 1; 33 + const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1; 34 + 35 + setCanScrollLeft(!isAtStart); 36 + setCanScrollRight(!isAtEnd); 37 + }, [categoryType]); 38 + 39 + useEffect(() => { 40 + const element = document.getElementById(`button-carousel-${categoryType}`); 41 + if (!element) return; 42 + 43 + updateScrollState(); 44 + 45 + element.addEventListener("scroll", updateScrollState); 46 + window.addEventListener("resize", updateScrollState); 47 + 48 + return () => { 49 + element.removeEventListener("scroll", updateScrollState); 50 + window.removeEventListener("resize", updateScrollState); 51 + }; 52 + }, [categoryType, updateScrollState]); 53 + 54 + useEffect(() => { 55 + const timeoutId = setTimeout(() => { 56 + updateScrollState(); 57 + }, 0); 58 + return () => clearTimeout(timeoutId); 59 + }, [categories, categoryType, updateScrollState]); 60 + 61 + const renderScrollButton = (direction: "left" | "right") => { 62 + const shouldShow = direction === "left" ? canScrollLeft : canScrollRight; 63 + 64 + if (!shouldShow && !showAlwaysScroll && !isMobile) return null; 65 + 66 + return ( 67 + <div> 68 + <button 69 + type="button" 70 + className="flex items-center rounded-full px-4 text-white py-3" 71 + onClick={() => { 72 + const element = document.getElementById( 73 + `button-carousel-${categoryType}`, 74 + ); 75 + if (element) { 76 + element.scrollBy({ 77 + left: direction === "left" ? -200 : 200, 78 + behavior: "smooth", 79 + }); 80 + } 81 + }} 82 + > 83 + <Icon 84 + icon={ 85 + direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT 86 + } 87 + className="text-2xl rtl:-scale-x-100" 88 + /> 89 + </button> 90 + </div> 91 + ); 92 + }; 42 93 43 94 return ( 44 95 <div className="flex overflow-x-auto"> 45 - {(showAlwaysScroll || isMobile) && renderScrollButton("left")} 96 + {(showAlwaysScroll || isMobile || canScrollLeft) && 97 + renderScrollButton("left")} 46 98 47 99 <div 48 100 id={`button-carousel-${categoryType}`} ··· 62 114 </div> 63 115 </div> 64 116 65 - {(showAlwaysScroll || isMobile) && renderScrollButton("right")} 117 + {(showAlwaysScroll || isMobile || canScrollRight) && 118 + renderScrollButton("right")} 66 119 </div> 67 120 ); 68 121 }
+1 -1
src/stores/__old/watched/store.ts
··· 1 1 import { useProgressStore } from "@/stores/progress"; 2 2 3 + import { createVersionedStore } from "../migrations"; 3 4 import { OldData, migrateV2Videos } from "./migrations/v2"; 4 5 import { migrateV3Videos } from "./migrations/v3"; 5 6 import { migrateV4Videos } from "./migrations/v4"; 6 7 import { WatchedStoreData } from "./types"; 7 - import { createVersionedStore } from "../migrations"; 8 8 9 9 export const VideoProgressStore = createVersionedStore<WatchedStoreData>() 10 10 .setKey("video-progress")