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

Configure Feed

Select the types of activity you want to include in your feed.

Migration A

+306 -49
+3 -1
index.html
··· 18 18 <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> 19 19 <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" /> 20 20 <meta name="msapplication-TileColor" content="#120f1d" /> 21 - <meta name="theme-color" content="#120f1d" /> 21 + <meta name="theme-color" content="#000000" /> 22 22 23 23 <meta name="apple-mobile-web-app-capable" content="yes" /> 24 + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 25 + 24 26 <link rel="apple-touch-startup-image" 25 27 media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" 26 28 href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png">
+12
src/assets/css/index.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 1 2 @tailwind base; 2 3 @tailwind components; 3 4 @tailwind utilities; 4 5 5 6 html, 6 7 body { 8 + font-family: "Lato", sans-serif !important; 7 9 @apply bg-background-main font-main text-type-text; 8 10 min-height: 100vh; 9 11 min-height: 100dvh; ··· 77 79 .min-h-screen { 78 80 min-height: 100vh; 79 81 min-height: 100dvh; 82 + } 83 + 84 + .info-button { 85 + display: inline-block; 86 + padding: 0.75em; 87 + margin: -0.75em; 88 + position: absolute; 89 + bottom: 0; 90 + right: 0; 91 + transform: translate(-15px, -10px) 80 92 } 81 93 82 94 /*generated with Input range slider CSS style generator (version 20211225)
+33 -16
src/components/buttons/EditButton.tsx
··· 1 1 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 - import { useCallback } from "react"; 2 + import { useCallback, useRef } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 4 5 5 import { Icon, Icons } from "@/components/Icon"; ··· 7 7 export interface EditButtonProps { 8 8 editing: boolean; 9 9 onEdit?: (editing: boolean) => void; 10 + id?: string; 10 11 } 11 12 12 13 export function EditButton(props: EditButtonProps) { 13 14 const { t } = useTranslation(); 14 15 const [parent] = useAutoAnimate<HTMLSpanElement>(); 16 + const buttonRef = useRef<HTMLButtonElement>(null); 15 17 16 18 const onClick = useCallback(() => { 17 19 props.onEdit?.(!props.editing); 18 20 }, [props]); 19 21 20 22 return ( 21 - <button 22 - type="button" 23 - onClick={onClick} 24 - className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105" 25 - > 26 - <span ref={parent}> 27 - {props.editing ? ( 28 - <span className="mx-2 sm:mx-4 whitespace-nowrap"> 29 - {t("home.mediaList.stopEditing")} 30 - </span> 31 - ) : ( 32 - <Icon icon={Icons.EDIT} /> 33 - )} 34 - </span> 35 - </button> 23 + <> 24 + <button 25 + ref={buttonRef} 26 + type="button" 27 + onClick={onClick} 28 + className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105" 29 + id={props.id} // Assign id to the button 30 + > 31 + <span ref={parent}> 32 + {props.editing ? ( 33 + <span className="mx-2 sm:mx-4 whitespace-nowrap"> 34 + {t("home.mediaList.stopEditing")} 35 + </span> 36 + ) : ( 37 + <Icon icon={Icons.EDIT} /> 38 + )} 39 + </span> 40 + </button> 41 + 42 + {props.editing && ( 43 + <button 44 + type="button" 45 + onClick={onClick} 46 + className="fixed bottom-9 right-7 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-background-secondary text-white border-2 border-green-500 transition-[background-color,transform,box-shadow] hover:bg-background-secondaryHover hover:scale-110 cursor-pointer" 47 + id={props.id ? `${props.id}-check` : undefined} // Optionally use a different id for this button 48 + > 49 + <Icon icon={Icons.CHECKMARK} /> 50 + </button> 51 + )} 52 + </> 36 53 ); 37 54 }
+3 -1
src/components/layout/WideContainer.tsx
··· 10 10 return ( 11 11 <div 12 12 className={`mx-auto max-w-full px-8 ${ 13 - props.ultraWide ? "w-[1300px] sm:px-16" : "w-[900px] sm:px-8" 13 + props.ultraWide 14 + ? "w-[1300px] 2xl:w-[2000px] 3xl:w-[2400px] 4xl:w-[2800px]" 15 + : "w-[900px] 2xl:w-[1400px] 3xl:w-[1600px] 4xl:w-[1800px]" 14 16 } ${props.classNames || ""}`} 15 17 > 16 18 {props.children}
+24 -2
src/components/media/MediaCard.tsx
··· 12 12 13 13 import { MediaBookmarkButton } from "./MediaBookmark"; 14 14 import { IconPatch } from "../buttons/IconPatch"; 15 - import { Icons } from "../Icon"; 15 + import { Icon, Icons } from "../Icon"; 16 16 17 17 export interface MediaCardProps { 18 18 media: MediaItem; ··· 179 179 <h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white"> 180 180 <span>{media.title}</span> 181 181 </h1> 182 - <DotList className="text-xs" content={dotListContent} /> 182 + <div className="media-info-container justify-content-center flex flex-wrap"> 183 + <DotList className="text-xs" content={dotListContent} /> 184 + <button 185 + className="info-button" 186 + type="button" 187 + onClick={(e) => { 188 + e.preventDefault(); 189 + 190 + const searchParam = encodeURIComponent(encodeURI(media.id)); 191 + const url = 192 + media.type === "movie" 193 + ? `https://www.themoviedb.org/movie/${searchParam}` 194 + : `https://www.themoviedb.org/tv/${searchParam}`; 195 + 196 + window.open(url, "_blank"); 197 + }} 198 + > 199 + <Icon 200 + className="text-xs font-semibold text-type-secondary" 201 + icon={Icons.CIRCLE_QUESTION} 202 + /> 203 + </button> 204 + </div> 183 205 </Flare.Child> 184 206 </Flare.Base> 185 207 );
+1 -1
src/components/media/MediaGrid.tsx
··· 8 8 (props, ref) => { 9 9 return ( 10 10 <div 11 - className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4" 11 + className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 2xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10" 12 12 ref={ref} 13 13 > 14 14 {props.children}
+75 -5
src/pages/Discover.tsx
··· 23 23 import { Icon, Icons } from "../components/Icon"; 24 24 import { PageTitle } from "./parts/util/PageTitle"; 25 25 26 + const editorPicks = [ 27 + { id: 9342, type: "movie" }, // The Mask of Zorro 28 + { id: 293, type: "movie" }, // A River Runs Through It 29 + { id: 370172, type: "movie" }, // No Time To Die 30 + { id: 661374, type: "movie" }, // The Glass Onion 31 + { id: 207, type: "movie" }, // Dead Poets Society 32 + { id: 378785, type: "movie" }, // The Best of the Blues Brothers 33 + { id: 335984, type: "movie" }, // Blade Runner 2049 34 + { id: 13353, type: "movie" }, // It's the Great Pumpkin, Charlie Brown 35 + { id: 27205, type: "movie" }, // Inception 36 + { id: 106646, type: "movie" }, // The Wolf of Wall Street 37 + { id: 334533, type: "movie" }, // Captain Fantastic 38 + { id: 693134, type: "movie" }, // Dune: Part Two 39 + { id: 765245, type: "movie" }, // Swan Song 40 + { id: 264660, type: "movie" }, // Ex Machina 41 + { id: 92591, type: "movie" }, // Bernie 42 + { id: 976893, type: "movie" }, // Perfect Days 43 + ]; 44 + 26 45 export function Discover() { 27 46 const { t } = useTranslation(); 28 47 const [genres, setGenres] = useState<Genre[]>([]); ··· 48 67 const [countdownTimeout, setCountdownTimeout] = 49 68 useState<NodeJS.Timeout | null>(null); 50 69 70 + const [editorPicksData, setEditorPicksData] = useState<Media[]>([]); 71 + 72 + useEffect(() => { 73 + // Function to shuffle array 74 + const shuffleArray = (array: any[]) => { 75 + for (let i = array.length - 1; i > 0; i -= 1) { 76 + const j = Math.floor(Math.random() * (i + 1)); 77 + [array[i], array[j]] = [array[j], array[i]]; 78 + } 79 + return array; 80 + }; 81 + 82 + const fetchEditorPicks = async () => { 83 + try { 84 + // Shuffle the editorPicks array 85 + const shuffledPicks = shuffleArray([...editorPicks]); 86 + 87 + const promises = shuffledPicks.map(async (pick) => { 88 + const endpoint = 89 + pick.type === "movie" ? `/movie/${pick.id}` : `/tv/${pick.id}`; 90 + const data = await get<any>(endpoint, { 91 + api_key: conf().TMDB_READ_API_KEY, 92 + language: "en-US", 93 + }); 94 + return { 95 + ...data, 96 + type: pick.type, 97 + }; 98 + }); 99 + const results = await Promise.all(promises); 100 + setEditorPicksData(results); 101 + } catch (error) { 102 + console.error("Error fetching editor picks:", error); 103 + } 104 + }; 105 + 106 + fetchEditorPicks(); 107 + }, []); 108 + 51 109 useEffect(() => { 52 110 const fetchMoviesForCategory = async (category: Category) => { 53 111 try { ··· 315 373 const displayCategory = 316 374 category === "Now Playing" 317 375 ? "In Cinemas" 318 - : category.includes("Movie") 319 - ? `${category}s` 320 - : isTVShow 321 - ? `${category} Shows` 322 - : `${category} Movies`; 376 + : category === "Editor Picks" // Check for "Editor Picks" specifically 377 + ? category 378 + : category.includes("Movie") 379 + ? `${category}s` 380 + : isTVShow 381 + ? `${category} Shows` 382 + : `${category} Movies`; 323 383 324 384 // https://tailwindcss.com/docs/border-style 325 385 return ( ··· 532 592 </p> 533 593 </div> 534 594 )} 595 + 596 + {/* Editor Picks Section */} 597 + <div className="mt-8"> 598 + {editorPicksData.length > 0 && ( 599 + <div className="mt-8"> 600 + {renderMovies(editorPicksData, "Editor Picks")} 601 + </div> 602 + )} 603 + </div> 604 + 535 605 <div className="flex flex-col"> 536 606 {categories.map((category) => ( 537 607 <div
+1 -1
src/pages/HomePage.tsx
··· 74 74 ) : ( 75 75 <> 76 76 <div className="flex flex-col gap-8"> 77 - <BookmarksPart onItemsChange={setShowBookmarks} /> 78 77 <WatchingPart onItemsChange={setShowWatching} /> 78 + <BookmarksPart onItemsChange={setShowBookmarks} /> 79 79 </div> 80 80 {!(showBookmarks || showWatching) ? ( 81 81 <div className="flex flex-col items-center justify-center">
+52 -3
src/pages/parts/home/BookmarksPart.tsx
··· 1 1 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 - import { useEffect, useMemo, useState } from "react"; 2 + import { useEffect, useMemo, useRef, useState } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 4 5 5 import { EditButton } from "@/components/buttons/EditButton"; ··· 10 10 import { useBookmarkStore } from "@/stores/bookmarks"; 11 11 import { useProgressStore } from "@/stores/progress"; 12 12 import { MediaItem } from "@/utils/mediaTypes"; 13 + 14 + const LONG_PRESS_DURATION = 500; // 0.5 seconds 13 15 14 16 export function BookmarksPart({ 15 17 onItemsChange, ··· 23 25 const [editing, setEditing] = useState(false); 24 26 const [gridRef] = useAutoAnimate<HTMLDivElement>(); 25 27 28 + const pressTimerRef = useRef<NodeJS.Timeout | null>(null); 29 + 26 30 const items = useMemo(() => { 27 31 let output: MediaItem[] = []; 28 32 Object.entries(bookmarks).forEach((entry) => { ··· 49 53 onItemsChange(items.length > 0); 50 54 }, [items, onItemsChange]); 51 55 56 + const handleLongPress = () => { 57 + // Find the button by ID and simulate a click 58 + const editButton = document.getElementById("edit-button-bookmark"); 59 + if (editButton) { 60 + (editButton as HTMLButtonElement).click(); 61 + } 62 + }; 63 + 64 + const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => { 65 + e.preventDefault(); // Prevent default touch action 66 + pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION); 67 + }; 68 + 69 + const handleTouchEnd = () => { 70 + if (pressTimerRef.current) { 71 + clearTimeout(pressTimerRef.current); 72 + pressTimerRef.current = null; 73 + } 74 + }; 75 + 76 + const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => { 77 + e.preventDefault(); // Prevent default mouse action 78 + pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION); 79 + }; 80 + 81 + const handleMouseUp = () => { 82 + if (pressTimerRef.current) { 83 + clearTimeout(pressTimerRef.current); 84 + pressTimerRef.current = null; 85 + } 86 + }; 87 + 52 88 if (items.length === 0) return null; 53 89 54 90 return ( 55 - <div> 91 + <div 92 + className="relative" 93 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 94 + e.preventDefault() 95 + } // Prevent right-click context menu 96 + onTouchStart={handleTouchStart} // Handle touch start 97 + onTouchEnd={handleTouchEnd} // Handle touch end 98 + onMouseDown={handleMouseDown} // Handle mouse down 99 + onMouseUp={handleMouseUp} // Handle mouse up 100 + > 56 101 <SectionHeading 57 102 title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 58 103 icon={Icons.BOOKMARK} 59 104 > 60 - <EditButton editing={editing} onEdit={setEditing} /> 105 + <EditButton 106 + editing={editing} 107 + onEdit={setEditing} 108 + id="edit-button-bookmark" 109 + /> 61 110 </SectionHeading> 62 111 <MediaGrid ref={gridRef}> 63 112 {items.map((v) => (
+63 -17
src/pages/parts/home/WatchingPart.tsx
··· 1 1 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 - import { useEffect, useMemo, useState } from "react"; 2 + import { useEffect, useMemo, useRef, useState } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 4 5 5 import { EditButton } from "@/components/buttons/EditButton"; ··· 7 7 import { SectionHeading } from "@/components/layout/SectionHeading"; 8 8 import { MediaGrid } from "@/components/media/MediaGrid"; 9 9 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 10 - import { useBookmarkStore } from "@/stores/bookmarks"; 11 10 import { useProgressStore } from "@/stores/progress"; 12 11 import { shouldShowProgress } from "@/stores/progress/utils"; 13 12 import { MediaItem } from "@/utils/mediaTypes"; 13 + 14 + const LONG_PRESS_DURATION = 500; // 0.5 seconds 14 15 15 16 export function WatchingPart({ 16 17 onItemsChange, ··· 18 19 onItemsChange: (hasItems: boolean) => void; 19 20 }) { 20 21 const { t } = useTranslation(); 21 - const bookmarks = useBookmarkStore((s) => s.bookmarks); 22 22 const progressItems = useProgressStore((s) => s.items); 23 23 const removeItem = useProgressStore((s) => s.removeItem); 24 24 const [editing, setEditing] = useState(false); 25 25 const [gridRef] = useAutoAnimate<HTMLDivElement>(); 26 26 27 + const pressTimerRef = useRef<NodeJS.Timeout | null>(null); 28 + 27 29 const sortedProgressItems = useMemo(() => { 28 - let output: MediaItem[] = []; 30 + const output: MediaItem[] = []; 29 31 Object.entries(progressItems) 30 32 .filter((entry) => shouldShowProgress(entry[1]).show) 31 33 .sort((a, b) => b[1].updatedAt - a[1].updatedAt) ··· 36 38 }); 37 39 }); 38 40 39 - output = output.filter((v) => { 40 - const isBookMarked = !!bookmarks[v.id]; 41 - return !isBookMarked; 42 - }); 43 41 return output; 44 - }, [progressItems, bookmarks]); 42 + }, [progressItems]); 45 43 46 44 useEffect(() => { 47 45 onItemsChange(sortedProgressItems.length > 0); 48 46 }, [sortedProgressItems, onItemsChange]); 49 47 48 + const handleLongPress = () => { 49 + // Find the button by ID and simulate a click 50 + const editButton = document.getElementById("edit-button-watching"); 51 + if (editButton) { 52 + (editButton as HTMLButtonElement).click(); 53 + } 54 + }; 55 + 56 + const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => { 57 + e.preventDefault(); // Prevent default touch action 58 + pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION); 59 + }; 60 + 61 + const handleTouchEnd = () => { 62 + if (pressTimerRef.current) { 63 + clearTimeout(pressTimerRef.current); 64 + pressTimerRef.current = null; 65 + } 66 + }; 67 + 68 + const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => { 69 + e.preventDefault(); // Prevent default mouse action 70 + pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION); 71 + }; 72 + 73 + const handleMouseUp = () => { 74 + if (pressTimerRef.current) { 75 + clearTimeout(pressTimerRef.current); 76 + pressTimerRef.current = null; 77 + } 78 + }; 79 + 50 80 if (sortedProgressItems.length === 0) return null; 51 81 52 82 return ( 53 - <div> 83 + <div 84 + className="relative" 85 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 86 + e.preventDefault() 87 + } // Prevent right-click context menu 88 + > 54 89 <SectionHeading 55 90 title={t("home.continueWatching.sectionTitle")} 56 91 icon={Icons.CLOCK} 57 92 > 58 - <EditButton editing={editing} onEdit={setEditing} /> 93 + <EditButton 94 + editing={editing} 95 + onEdit={setEditing} 96 + id="edit-button-watching" 97 + /> 59 98 </SectionHeading> 60 99 <MediaGrid ref={gridRef}> 61 100 {sortedProgressItems.map((v) => ( 62 - <WatchedMediaCard 63 - key={v.id} 64 - media={v} 65 - closable={editing} 66 - onClose={() => removeItem(v.id)} 67 - /> 101 + <div 102 + onTouchStart={handleTouchStart} // Handle touch start 103 + onTouchEnd={handleTouchEnd} // Handle touch end 104 + onMouseDown={handleMouseDown} // Handle mouse down 105 + onMouseUp={handleMouseUp} // Handle mouse up 106 + > 107 + <WatchedMediaCard 108 + key={v.id} 109 + media={v} 110 + closable={editing} 111 + onClose={() => removeItem(v.id)} 112 + /> 113 + </div> 68 114 ))} 69 115 </MediaGrid> 70 116 </div>
+34
src/pages/parts/player/PlayerPart.tsx
··· 1 1 import { ReactNode } from "react"; 2 + import { useParams } from "react-router-dom"; 2 3 4 + import { Icon, Icons } from "@/components/Icon"; 3 5 import { BrandPill } from "@/components/layout/BrandPill"; 4 6 import { Player } from "@/components/player"; 7 + import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; 5 8 import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; 6 9 import { useIsMobile } from "@/hooks/useIsMobile"; 7 10 import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; ··· 15 18 } 16 19 17 20 export function PlayerPart(props: PlayerPartProps) { 21 + const params = useParams<{ 22 + media: string; 23 + episode?: string; 24 + season?: string; 25 + }>(); 26 + const media = params.media; 18 27 const { showTargets, showTouchTargets } = useShouldShowControls(); 19 28 const status = usePlayerStore((s) => s.status); 20 29 const { isMobile } = useIsMobile(); 21 30 const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); 31 + const { playerMeta: meta } = usePlayerMeta(); 22 32 23 33 return ( 24 34 <Player.Container onLoad={props.onLoad} showingControls={showTargets}> ··· 60 70 <Player.BackLink url={props.backUrl} /> 61 71 <span className="text mx-3 text-type-secondary">/</span> 62 72 <Player.Title /> 73 + 74 + <button 75 + type="button" 76 + onClick={(e) => { 77 + e.preventDefault(); 78 + if (!media) return; 79 + const id = media 80 + .replace("tmdb-tv-", "") 81 + .replace("tmdb-movie-", ""); 82 + let url; 83 + if (meta?.type === "movie") { 84 + url = `https://www.themoviedb.org/movie/${id}`; 85 + } else { 86 + url = `https://www.themoviedb.org/tv/${id}`; 87 + } 88 + window.open(url, "_blank"); 89 + }} 90 + > 91 + <Icon 92 + className="text-xs font-semibold text-type-secondary" 93 + icon={Icons.CIRCLE_QUESTION} 94 + /> 95 + </button> 96 + 63 97 <Player.BookmarkButton /> 64 98 </div> 65 99 <div className="text-center hidden xl:flex justify-center items-center">
+3
tailwind.config.ts
··· 12 12 /* breakpoints */ 13 13 screens: { 14 14 ssm: "400px", 15 + '2xl': '1921px', // Custom breakpoint for screens at least 1920px wide 16 + '3xl': '2650px', // Custom breakpoint for screens at least 2650px wide 17 + '4xl': '3840px', // Custom breakpoint for screens at least 4096px wide 15 18 }, 16 19 17 20 /* fonts */
+2 -2
vite.config.mts
··· 70 70 name: "sudo-flix", 71 71 short_name: "sudo-flix", 72 72 description: "Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)", 73 - theme_color: "#120f1d", 74 - background_color: "#120f1d", 73 + theme_color: "#000000", 74 + background_color: "#000000", 75 75 display: "standalone", 76 76 start_url: "/", 77 77 icons: [