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.

add buttons to mark as watched or unwatched

Pas cec7851d 7014d85f

+235 -13
+3 -1
src/assets/locales/en.json
··· 598 598 "unairedEpisodes": "One or more episodes in this season have been disabled because they haven't been aired yet.", 599 599 "seasons": "Seasons", 600 600 "showMore": "Show more", 601 - "showLess": "Show less" 601 + "showLess": "Show less", 602 + "markAsWatched": "Mark as watched", 603 + "markAsUnwatched": "Mark as unwatched" 602 604 }, 603 605 "playback": { 604 606 "speedLabel": "Playback speed",
+1
src/components/overlays/details/DetailsContent.tsx
··· 333 333 seasons={data.seasonData.seasons} 334 334 mediaId={data.id} 335 335 mediaTitle={data.title} 336 + mediaPosterUrl={data.posterUrl} 336 337 /> 337 338 )} 338 339
+5
src/components/overlays/details/DetailsModal.tsx
··· 6 6 getMediaBackdrop, 7 7 getMediaDetails, 8 8 getMediaLogo, 9 + getMediaPoster, 9 10 } from "@/backend/metadata/tmdb"; 10 11 import { 11 12 TMDBContentTypes, ··· 40 41 const logoUrl = await getMediaLogo(data.id.toString(), type); 41 42 if (type === TMDBContentTypes.MOVIE) { 42 43 const movieDetails = details as TMDBMovieData; 44 + const posterUrl = getMediaPoster(movieDetails.poster_path); 43 45 setDetailsData({ 44 46 title: movieDetails.title, 45 47 overview: movieDetails.overview, 46 48 backdrop: backdropUrl, 49 + posterUrl, 47 50 runtime: movieDetails.runtime, 48 51 genres: movieDetails.genres, 49 52 language: movieDetails.original_language, ··· 70 73 season_number: number; 71 74 }>; 72 75 }; 76 + const posterUrl = getMediaPoster(showDetails.poster_path); 73 77 setDetailsData({ 74 78 title: showDetails.name, 75 79 overview: showDetails.overview, 76 80 backdrop: backdropUrl, 81 + posterUrl, 77 82 episodes: showDetails.number_of_episodes, 78 83 seasons: showDetails.number_of_seasons, 79 84 genres: showDetails.genres,
+94 -5
src/components/overlays/details/EpisodeCarousel.tsx
··· 7 7 import { Dropdown } from "@/components/form/Dropdown"; 8 8 import { Icon, Icons } from "@/components/Icon"; 9 9 import { hasAired } from "@/components/player/utils/aired"; 10 + import { useProgressStore } from "@/stores/progress"; 10 11 11 12 import { EpisodeCarouselProps } from "./types"; 12 13 ··· 19 20 seasons, 20 21 mediaId, 21 22 mediaTitle, 23 + mediaPosterUrl, 22 24 }: EpisodeCarouselProps) { 23 25 const [showEpisodeMenu, setShowEpisodeMenu] = useState(false); 24 26 const [customSeason, setCustomSeason] = useState(""); ··· 35 37 const descriptionRefs = useRef<{ 36 38 [key: number]: HTMLParagraphElement | null; 37 39 }>({}); 40 + const updateItem = useProgressStore((s) => s.updateItem); 38 41 39 42 const handleScroll = (direction: "left" | "right") => { 40 43 if (!carouselRef.current) return; ··· 145 148 setShowEpisodeMenu(false); 146 149 }; 147 150 151 + const toggleWatchStatus = (episodeId: number, event: React.MouseEvent) => { 152 + event.preventDefault(); 153 + event.stopPropagation(); 154 + 155 + if (mediaId) { 156 + const episode = episodes.find((ep) => ep.id === episodeId); 157 + if (episode) { 158 + const seasonData = seasons.find( 159 + (s) => s.season_number === selectedSeason, 160 + ); 161 + if (!seasonData) return; 162 + 163 + // Check if the episode is already watched 164 + const episodeProgress = 165 + progress[mediaId.toString()]?.episodes?.[episodeId]; 166 + const percentage = episodeProgress 167 + ? (episodeProgress.progress.watched / 168 + episodeProgress.progress.duration) * 169 + 100 170 + : 0; 171 + 172 + // If watched (>90%), reset to 0%, otherwise set to 100% 173 + const isWatched = percentage > 90; 174 + 175 + // Get the poster URL from the mediaPosterUrl prop 176 + const posterUrl = mediaPosterUrl; 177 + 178 + // Update progress 179 + updateItem({ 180 + meta: { 181 + tmdbId: mediaId.toString(), 182 + title: mediaTitle || "", 183 + type: "show", 184 + releaseYear: new Date().getFullYear(), 185 + poster: posterUrl, 186 + episode: { 187 + tmdbId: episodeId.toString(), 188 + number: episode.episode_number, 189 + title: episode.name || "", 190 + }, 191 + season: { 192 + tmdbId: seasonData.id.toString(), 193 + number: selectedSeason, 194 + title: seasonData.name || "", 195 + }, 196 + }, 197 + progress: { 198 + watched: isWatched ? 0 : 60, 199 + duration: 60, 200 + }, 201 + }); 202 + } 203 + } 204 + }; 205 + 148 206 const currentSeasonEpisodes = episodes.filter( 149 207 (ep) => ep.season_number === selectedSeason, 150 208 ); ··· 314 372 : 0; 315 373 const isAired = hasAired(episode.air_date); 316 374 const isExpanded = expandedEpisodes[episode.id]; 375 + const isWatched = percentage > 98; 317 376 318 377 return ( 319 378 <Link ··· 362 421 </span> 363 422 )} 364 423 </div> 424 + 425 + {/* Mark as watched button */} 426 + <div className="absolute top-2 right-2"> 427 + <button 428 + type="button" 429 + onClick={(e) => toggleWatchStatus(episode.id, e)} 430 + className="p-1.5 bg-black/50 rounded-full hover:bg-black/80 transition-colors" 431 + title={ 432 + isWatched 433 + ? t("player.menus.episodes.markAsUnwatched") 434 + : t("player.menus.episodes.markAsWatched") 435 + } 436 + > 437 + <Icon 438 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 439 + className="h-4 w-4 text-white/80" 440 + /> 441 + </button> 442 + </div> 365 443 </div> 366 444 )} 367 445 ··· 376 454 <h3 className="font-bold text-white line-clamp-1"> 377 455 {episode.name} 378 456 </h3> 379 - {!isExpanded && ( 380 - <span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm"> 381 - {t("media.episodeShort")} 382 - {episode.episode_number} 383 - </span> 457 + {isExpanded && ( 458 + <button 459 + type="button" 460 + onClick={(e) => toggleWatchStatus(episode.id, e)} 461 + className="p-1.5 rounded-full hover:bg-white/20 transition-colors" 462 + title={ 463 + isWatched 464 + ? t("player.menus.episodes.markAsUnwatched") 465 + : t("player.menus.episodes.markAsWatched") 466 + } 467 + > 468 + <Icon 469 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 470 + className="h-4 w-4 text-white/80" 471 + /> 472 + </button> 384 473 )} 385 474 </div> 386 475 {episode.overview && (
+2
src/components/overlays/details/types.ts
··· 4 4 title: string; 5 5 overview?: string; 6 6 backdrop?: string; 7 + posterUrl?: string; 7 8 runtime?: number | null; 8 9 genres?: Array<{ id: number; name: string }>; 9 10 language?: string; ··· 97 98 }>; 98 99 mediaId?: number; 99 100 mediaTitle?: string; 101 + mediaPosterUrl?: string; 100 102 } 101 103 102 104 export interface DetailsBodyProps {
+130 -7
src/components/player/atoms/Episodes.tsx
··· 112 112 const meta = usePlayerStore((s) => s.meta); 113 113 const [loadingState] = useSeasonData(meta?.tmdbId ?? "", selectedSeason); 114 114 const progress = useProgressStore(); 115 + const updateItem = useProgressStore((s) => s.updateItem); 115 116 const carouselRef = useRef<HTMLDivElement>(null); 116 117 const activeEpisodeRef = useRef<HTMLDivElement>(null); 117 118 const [expandedEpisodes, setExpandedEpisodes] = useState<{ ··· 195 196 [setPlayerMeta, loadingState, router, onChange], 196 197 ); 197 198 199 + const toggleWatchStatus = useCallback( 200 + (episodeId: string, event: React.MouseEvent) => { 201 + event.stopPropagation(); 202 + if (loadingState.value && meta?.tmdbId) { 203 + const episode = loadingState.value.season.episodes.find( 204 + (ep) => ep.id === episodeId, 205 + ); 206 + if (episode) { 207 + // Check if the episode is already watched 208 + const episodeProgress = 209 + progress.items[meta.tmdbId]?.episodes?.[episodeId]; 210 + const percentage = episodeProgress 211 + ? (episodeProgress.progress.watched / 212 + episodeProgress.progress.duration) * 213 + 100 214 + : 0; 215 + 216 + // If watched (>90%), reset to 0%, otherwise set to 100% 217 + const isWatched = percentage > 90; 218 + 219 + updateItem({ 220 + meta: { 221 + tmdbId: meta.tmdbId, 222 + title: meta.title || "", 223 + type: "show", 224 + releaseYear: meta.releaseYear, 225 + poster: meta.poster, 226 + episode: { 227 + tmdbId: episodeId, 228 + number: episode.number, 229 + title: episode.title || "", 230 + }, 231 + season: { 232 + tmdbId: selectedSeason, 233 + number: loadingState.value.season.number, 234 + title: loadingState.value.season.title || "", 235 + }, 236 + }, 237 + progress: { 238 + watched: isWatched ? 0 : 60, 239 + duration: 60, 240 + }, 241 + }); 242 + } 243 + } 244 + }, 245 + [loadingState, meta, selectedSeason, updateItem, progress.items], 246 + ); 247 + 198 248 const handleScroll = (direction: "left" | "right") => { 199 249 if (!carouselRef.current) return; 200 250 ··· 300 350 301 351 const isAired = hasAired(ep.air_date); 302 352 const isActive = ep.id === meta?.episode?.tmdbId; 353 + const isWatched = percentage > 98; 303 354 304 355 return ( 305 356 <div key={ep.id} ref={isActive ? activeEpisodeRef : null}> 306 357 {/* Extra small screens - Simple vertical list with no thumbnails */} 307 358 <div 308 359 className={classNames( 309 - "block w-full px-3", 360 + "block w-full px-3 relative", 310 361 forceCompactEpisodeView ? "" : "sm:hidden", 311 362 )} 312 363 > ··· 315 366 active={isActive} 316 367 clickable={isAired} 317 368 rightSide={ 318 - episodeProgress && ( 319 - <ProgressRing 320 - className="h-[18px] w-[18px] text-white" 321 - percentage={percentage > 90 ? 100 : percentage} 322 - /> 323 - ) 369 + <div className="flex items-center gap-2"> 370 + <button 371 + type="button" 372 + onClick={(e) => toggleWatchStatus(ep.id, e)} 373 + className="p-1.5 rounded-full hover:bg-white/20 transition-colors" 374 + title={ 375 + isWatched 376 + ? t("player.menus.episodes.markAsUnwatched") 377 + : t("player.menus.episodes.markAsWatched") 378 + } 379 + > 380 + <Icon 381 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 382 + className="h-4 w-4 text-white/80" 383 + /> 384 + </button> 385 + {episodeProgress && ( 386 + <ProgressRing 387 + className="h-[18px] w-[18px] text-white" 388 + percentage={percentage > 98 ? 100 : percentage} 389 + /> 390 + )} 391 + </div> 324 392 } 325 393 > 326 394 <Menu.LinkTitle> ··· 383 451 : `(${t("media.unreleased")})`} 384 452 </span> 385 453 )} 454 + </div> 455 + 456 + {/* Mark as watched button */} 457 + <div className="absolute top-2 right-2"> 458 + <button 459 + type="button" 460 + onClick={(e) => toggleWatchStatus(ep.id, e)} 461 + className="p-1.5 bg-black/50 rounded-full hover:bg-black/80 transition-colors" 462 + title={ 463 + isWatched 464 + ? t("player.menus.episodes.markAsUnwatched") 465 + : t("player.menus.episodes.markAsWatched") 466 + } 467 + > 468 + <Icon 469 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 470 + className="h-4 w-4 text-white/80" 471 + /> 472 + </button> 386 473 </div> 387 474 </div> 388 475 ··· 491 578 </span> 492 579 )} 493 580 </div> 581 + 582 + {/* Mark as watched button */} 583 + <div className="absolute top-2 right-2"> 584 + <button 585 + type="button" 586 + onClick={(e) => toggleWatchStatus(ep.id, e)} 587 + className="p-1.5 bg-black/50 rounded-full hover:bg-black/80 transition-colors" 588 + title={ 589 + isWatched 590 + ? t("player.menus.episodes.markAsUnwatched") 591 + : t("player.menus.episodes.markAsWatched") 592 + } 593 + > 594 + <Icon 595 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 596 + className="h-4 w-4 text-white/80" 597 + /> 598 + </button> 599 + </div> 494 600 </div> 495 601 )} 496 602 ··· 507 613 <h3 className="font-bold text-white line-clamp-1"> 508 614 {ep.title} 509 615 </h3> 616 + {expandedEpisodes[`large-${ep.id}`] && ( 617 + <button 618 + type="button" 619 + onClick={(e) => toggleWatchStatus(ep.id, e)} 620 + className="p-1.5 rounded-full hover:bg-white/20 transition-colors" 621 + title={ 622 + isWatched 623 + ? t("player.menus.episodes.markAsUnwatched") 624 + : t("player.menus.episodes.markAsWatched") 625 + } 626 + > 627 + <Icon 628 + icon={isWatched ? Icons.EYE_SLASH : Icons.EYE} 629 + className="h-4 w-4 text-white/80" 630 + /> 631 + </button> 632 + )} 510 633 </div> 511 634 {ep.overview && ( 512 635 <div className="relative">