diff --git a/public/notifications.xml b/public/notifications.xml new file mode 100644 index 00000000..63d07f2e --- /dev/null +++ b/public/notifications.xml @@ -0,0 +1,124 @@ + + + + P-Stream Notifications + https://pstream.mov + Site updates and important notifications for P-Stream users + en + Mon, 28 Jul 2025 21:53:00 GMT + + + + notification-8-1-25 + Welcome to the P-Stream Beta! + P-Stream is now in beta! This is a test of the new notification system. + +You can now receive notifications for new updates and important announcements. + +You can also view the notifications in the notifications page. + Fri, 01 Aug 2025 21:00:00 GMT + announcement + + + + notification-028 + P-Stream v5.0.4 released! + A bunch more quality-of-life improvements and fixes have been released. +- Accounts are no longer required for migration downloads and uploads. +- Right-click to open the details modal has been re-added. +- Increased hold-to-edit time for media cards. +- Re-added jiggle physics while editing. +- Custom passphrase support. +- Improved subtitle selection experience. +- Bug fixes and behind-the-scenes changes. + +A few new sources have also been added, including Flixer and VidSrc.vip. + Tue, 15 Jul 2025 20:58:00 GMT + update + + + + notification-026 + New Domain: pstream.mov + https://pstream.mov + The new domain is https://pstream.mov/ + +Most pages should now be working. If you had an account on the old site, your data will be there. If you did not have an account, you can download your data from pstream.org and upload it to the new site. + Mon, 14 Jul 2025 17:30:00 GMT + announcement + + + + notification-022 + P-Stream v5.0.2 released! + This is another minor update with great quality-of-life features. +- Added "open in..." button to the download menu. +- Added the ability to set a custom playback speed. +- Added buttons to mark episodes as watched or unwatched. +- Added custom color pickers to account styles and more user icons. +- Estimate quality for non-standard resolution sources. +- Re-added email to the DMCA page. +- Added an autoplay toggle to playback settings. +- Fixed end time to account for playback speed. +- Updated some translations and fixed many bugs. + Mon, 07 Jul 2025 19:55:00 GMT + update + + + + notification-008 + P-Stream v5.0.1 + Some minor updates this time: +- Added network images to the details modal. +- Added a letterboxd/trakt list page: "Discover All Lists." +- Fixed audio description not displaying. +- Automatically open the settings menu when failing to play. +- Added a compact episodes view. +- Updated the "About" page. +- Added low-performance/bandwidth mode. +- The share button on the details modal now opens the iOS share sheet. +- Fixed various bugs. + Tue, 24 Jun 2025 18:20:00 GMT + update + + + + notification-003-2 + New Movie Lists Page + https://pstream.mov/discover/all + A new page has been added to discover many movie lists powered by Letterboxd. A couple of minor bugs were also fixed. + Sat, 07 Jun 2025 18:41:00 GMT + update + + + + notification-005-2 + 5.0.0 Released! + This release is the largest facelift to the project yet, with many new features and fixes. + +New Features +- Discover Page Overhaul: The Discover page has been completely rewritten. +- New Trakt Sections: Explore new "Latest Releases" and "Top 4K" sections. +- Featured Movies/Shows Carousel: A new slideshow of new and popular movies or shows is now on the Discover page. +- Quality Indicators: See the video quality (HD or CAM) on the Details Modal or Featured Carousel. +- Expanded Descriptions: Episode descriptions can now be expanded. +- Watchparty (Beta)! Easily invite anyone to watch with you. Now joinable from the home page. +- You can now click on actors and directors on a movie or show's detail page to explore their other work. + +Improvements +- (Most) User preferences now sync. +- UI & UX Refresh: New player settings design and an overhauled Details Modal. +- You can now toggle Carousel View to show "Currently Watching" and "Bookmarked" lists as carousels. + +Bug Fixes +- Fixed several issues with loading skeletons. +- Implemented a fix to better handle subtitle files. +- Fixed various layout issues. +- Febbox Token now actually syncs with your account. +- Many other bugs. + Fri, 06 Jun 2025 11:29:00 GMT + update + + + + diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 34f363bb..f7687686 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -79,6 +79,8 @@ export enum Icons { TMDB = "tmdb", IMDB = "imdb", EAR = "ear", + BELL = "bell", + RELOAD = "reload", } export interface IconProps { @@ -175,6 +177,8 @@ const iconList: Record = { tmdb: ``, imdb: ` `, ear: ` `, + bell: ``, + reload: ``, }; function ChromeCastButton() { diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index fd60f9e2..90f97985 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -6,6 +6,7 @@ import { NoUserAvatar, UserAvatar } from "@/components/Avatar"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { LinksDropdown } from "@/components/LinksDropdown"; +import { useNotifications } from "@/components/overlays/NotificationModal"; import { Lightbar } from "@/components/utils/Lightbar"; import { useAuth } from "@/hooks/auth/useAuth"; import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; @@ -27,6 +28,7 @@ export function Navigation(props: NavigationProps) { const navigate = useNavigate(); const { loggedIn } = useAuth(); const [scrollPosition, setScrollPosition] = useState(0); + const { openNotifications, getUnreadCount } = useNotifications(); useEffect(() => { const handleScroll = () => { @@ -182,6 +184,18 @@ export function Navigation(props: NavigationProps) { /> ))} + openNotifications()} + rel="noreferrer" + className="text-xl text-white tabbable rounded-full backdrop-blur-lg relative" + > + + {getUnreadCount() > 0 && ( + + {getUnreadCount()} + + )} +
diff --git a/src/components/overlays/NotificationModal.tsx b/src/components/overlays/NotificationModal.tsx new file mode 100644 index 00000000..b25f4de5 --- /dev/null +++ b/src/components/overlays/NotificationModal.tsx @@ -0,0 +1,734 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Icon, Icons } from "@/components/Icon"; +import { Link } from "@/pages/migration/utils"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; + +import { FancyModal } from "./Modal"; + +const NOTIFICATIONS_ENDPOINT = "/notifications.xml"; + +interface NotificationItem { + guid: string; + title: string; + link: string; + description: string; + pubDate: string; + category: string; +} + +interface NotificationModalProps { + id: string; +} + +type ModalView = "list" | "detail"; + +// Detail view component +function DetailView({ + selectedNotification, + goBackToList, + getCategoryColor, + getCategoryLabel, + formatDate, +}: { + selectedNotification: NotificationItem; + goBackToList: () => void; + getCategoryColor: (category: string) => string; + getCategoryLabel: (category: string) => string; + formatDate: (dateString: string) => string; +}) { + return ( +
+ {/* Header with back button */} +
+ +
+ + {/* Notification content */} +
+
+ {getCategoryColor(selectedNotification.category) && ( + + )} + {getCategoryLabel(selectedNotification.category) && ( + <> + + {getCategoryLabel(selectedNotification.category)} + + + + {formatDate(selectedNotification.pubDate)} + + + )} + {!getCategoryLabel(selectedNotification.category) && ( + + {formatDate(selectedNotification.pubDate)} + + )} +
+ +
+

") + .replace(/\n- /g, "

• ") + .replace(/\n\*\*([^*]+)\*\*/g, "

$1

") + .replace(/^/, "

") + .replace(/$/, "

") + .replace(/

<\/p>/g, "") + .replace( + /

• /g, + '

', + ) + .replace(/<\/p>/g, "

"), + }} + /> +
+ + {selectedNotification.link && ( +
+ + + Go to page + +
+ )} +
+
+ ); +} + +// List view component +function ListView({ + notifications, + readNotifications, + unreadCount, + loading, + error, + containerRef, + markAllAsRead, + markAllAsUnread, + isShiftHeld, + onRefresh, + openNotificationDetail, + getCategoryColor, + getCategoryLabel, + formatDate, +}: { + notifications: NotificationItem[]; + readNotifications: Set; + unreadCount: number; + loading: boolean; + error: string | null; + containerRef: React.RefObject; + markAllAsRead: () => void; + markAllAsUnread: () => void; + isShiftHeld: boolean; + onRefresh: () => void; + openNotificationDetail: (notification: NotificationItem) => void; + getCategoryColor: (category: string) => string; + getCategoryLabel: (category: string) => string; + formatDate: (dateString: string) => string; +}) { + return ( +
+ {/* Header with refresh and mark all buttons */} +
+ + {unreadCount} unread notification{unreadCount !== 1 ? "s" : ""} + +
+ {isShiftHeld ? ( + + ) : ( + unreadCount > 0 && ( + + ) + )} +
+
+ +
+
+ + {/* Loading state */} + {loading && ( +
+ + Loading... +
+ )} + + {/* Error state */} + {error && ( +
+ +

Failed to load notifications

+

{error}

+
+ )} + + {/* Notifications list */} + {!loading && !error && ( +
+ {notifications.length === 0 ? ( +
+ +

No notifications available

+
+ ) : ( + notifications.map((notification) => { + const isRead = readNotifications.has(notification.guid); + return ( +
openNotificationDetail(notification)} + > +
+
+
+

+ {notification.title} +

+ {!isRead && ( + + )} +
+

+ {notification.description + .replace(/\n/g, " ") + .substring(0, 150)} + {notification.description.length > 150 ? "..." : ""} +

+
+ +
+ {getCategoryColor(notification.category) && ( + + )} + + {getCategoryLabel(notification.category)} + +
+
+
+ + {formatDate(notification.pubDate)} + + +
+
+ ); + }) + )} +
+ )} +
+ ); +} + +export function NotificationModal({ id }: NotificationModalProps) { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [readNotifications, setReadNotifications] = useState>( + new Set(), + ); + const [currentView, setCurrentView] = useState("list"); + const [selectedNotification, setSelectedNotification] = + useState(null); + const [isShiftHeld, setIsShiftHeld] = useState(false); + const containerRef = useRef(null); + + // Load read notifications from cookie + useEffect(() => { + const savedRead = localStorage.getItem("read-notifications"); + if (savedRead) { + try { + const readArray = JSON.parse(savedRead); + setReadNotifications(new Set(readArray)); + } catch (e) { + console.error("Failed to parse read notifications:", e); + } + } + }, []); + + // Handle shift key for mark all as unread button + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") { + setIsShiftHeld(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") { + setIsShiftHeld(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + // Fetch RSS feed function + const fetchNotifications = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch(NOTIFICATIONS_ENDPOINT); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseText = await response.text(); + + // Handle CORS proxy response (JSON wrapper) + let xmlText = responseText; + try { + const jsonResponse = JSON.parse(responseText); + if (jsonResponse.contents) { + xmlText = jsonResponse.contents; + } + } catch { + // If it's not JSON, assume it's direct XML + xmlText = responseText; + } + + // Basic validation that we got XML content + if ( + !xmlText || + (!xmlText.includes(" { + try { + const guid = item.querySelector("guid")?.textContent || ""; + const title = item.querySelector("title")?.textContent || ""; + const link = item.querySelector("link")?.textContent || ""; + const description = + item.querySelector("description")?.textContent || ""; + const pubDate = item.querySelector("pubDate")?.textContent || ""; + const category = item.querySelector("category")?.textContent || ""; + + // Skip items without essential data + if (!guid || !title) { + return; + } + + // Parse the publication date + const notificationDate = new Date(pubDate); + + // Include all notifications, but collect old ones to mark as read + parsedNotifications.push({ + guid, + title, + link, + description, + pubDate, + category, + }); + + // Collect GUIDs of notifications older than 14 days + if (notificationDate <= fourteenDaysAgo) { + autoReadGuids.push(guid); + } + } catch (itemError) { + // Skip malformed items + console.warn("Skipping malformed RSS item:", itemError); + } + }); + + setNotifications(parsedNotifications); + + // Update read notifications after setting notifications + if (autoReadGuids.length > 0) { + setReadNotifications((prevReadSet) => { + const newReadSet = new Set(prevReadSet); + autoReadGuids.forEach((guid) => newReadSet.add(guid)); + + // Update localStorage + localStorage.setItem( + "read-notifications", + JSON.stringify(Array.from(newReadSet)), + ); + + return newReadSet; + }); + } + } catch (err) { + console.error("RSS fetch error:", err); + setError( + err instanceof Error ? err.message : "Failed to load notifications", + ); + // Set empty notifications to prevent crashes + setNotifications([]); + } finally { + setLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + fetchNotifications(); + }, [fetchNotifications]); + + // Refresh function + const handleRefresh = () => { + fetchNotifications(); + }; + + // Save read notifications to cookie + const markAsRead = (guid: string) => { + const newReadSet = new Set(readNotifications); + newReadSet.add(guid); + setReadNotifications(newReadSet); + + // Save to localStorage + localStorage.setItem( + "read-notifications", + JSON.stringify(Array.from(newReadSet)), + ); + }; + + // Mark all as read + const markAllAsRead = () => { + const allGuids = notifications.map((n) => n.guid); + const newReadSet = new Set(allGuids); + setReadNotifications(newReadSet); + localStorage.setItem( + "read-notifications", + JSON.stringify(Array.from(newReadSet)), + ); + }; + + // Mark all as unread + const markAllAsUnread = () => { + setReadNotifications(new Set()); + localStorage.setItem("read-notifications", JSON.stringify([])); + }; + + // Navigate to detail view + const openNotificationDetail = (notification: NotificationItem) => { + setSelectedNotification(notification); + setCurrentView("detail"); + markAsRead(notification.guid); + }; + + // Navigate back to list + const goBackToList = () => { + setCurrentView("list"); + setSelectedNotification(null); + }; + + // Scroll to last read notification + useEffect(() => { + if ( + notifications.length > 0 && + containerRef.current && + currentView === "list" + ) { + const lastReadIndex = notifications.findIndex( + (n) => !readNotifications.has(n.guid), + ); + if (lastReadIndex > 0) { + const element = containerRef.current.children[ + lastReadIndex + ] as HTMLElement; + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + } + }, [notifications, readNotifications, currentView]); + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateString; + } + }; + + const getCategoryColor = (category: string) => { + if (!category || category.trim() === "") { + return ""; + } + + switch (category.toLowerCase()) { + case "announcement": + return "bg-blue-500"; + case "feature": + return "bg-green-500"; + case "update": + return "bg-yellow-500"; + case "bugfix": + return "bg-red-500"; + default: + return ""; + } + }; + + const getCategoryLabel = (category: string) => { + switch (category.toLowerCase()) { + case "announcement": + return "Announcement"; + case "feature": + return "New Feature"; + case "update": + return "Update"; + case "bugfix": + return "Bug Fix"; + default: + return category; + } + }; + + const unreadCount = notifications.filter( + (n) => !readNotifications.has(n.guid), + ).length; + + // Don't render if there's a critical error + if (error && !loading) { + return ( + +
+ +

Failed to load notifications

+

{error}

+ +
+
+ ); + } + + return ( + + {currentView === "list" ? ( + + ) : selectedNotification ? ( + + ) : null} + + ); +} + +// Hook to manage notifications +export function useNotifications() { + const { showModal, hideModal, isModalVisible } = useOverlayStack(); + const modalId = "notifications"; + const [notifications, setNotifications] = useState([]); + + // Fetch notifications for badge count + useEffect(() => { + const fetchNotifications = async () => { + try { + const response = await fetch(NOTIFICATIONS_ENDPOINT); + if (!response.ok) return; + + const xmlText = await response.text(); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlText, "text/xml"); + + const items = xmlDoc.querySelectorAll("item"); + const parsedNotifications: NotificationItem[] = []; + + items.forEach((item) => { + const guid = item.querySelector("guid")?.textContent || ""; + const title = item.querySelector("title")?.textContent || ""; + const link = item.querySelector("link")?.textContent || ""; + const description = + item.querySelector("description")?.textContent || ""; + const pubDate = item.querySelector("pubDate")?.textContent || ""; + const category = item.querySelector("category")?.textContent || ""; + + parsedNotifications.push({ + guid, + title, + link, + description, + pubDate, + category, + }); + }); + + setNotifications(parsedNotifications); + } catch (err) { + // Silently fail for badge count + } + }; + + fetchNotifications(); + }, []); + + const openNotifications = () => { + showModal(modalId); + }; + + const closeNotifications = () => { + hideModal(modalId); + }; + + const isNotificationsOpen = () => { + return isModalVisible(modalId); + }; + + // Get unread count for badge + const getUnreadCount = () => { + try { + const savedRead = localStorage.getItem("read-notifications"); + if (!savedRead) return notifications.length; // Return total count if no read data + + const readArray = JSON.parse(savedRead); + const readSet = new Set(readArray); + + // Get the actual count from the notifications state + return notifications.filter((n: NotificationItem) => !readSet.has(n.guid)) + .length; + } catch { + return 0; + } + }; + + return { + openNotifications, + closeNotifications, + isNotificationsOpen, + getUnreadCount, + }; +} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index c602eb80..7a5f9d5a 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -11,6 +11,7 @@ import { import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; +import { NotificationModal } from "@/components/overlays/NotificationModal"; import { useOnlineListener } from "@/hooks/usePing"; import { AboutPage } from "@/pages/About"; import { AdminPage } from "@/pages/admin/AdminPage"; @@ -117,6 +118,7 @@ function App() { return ( + {!showDowntime && ( {/* functional routes */}