All files / src/components/channels RecentlyPlayedRow.tsx

93.61% Statements 44/47
85.71% Branches 12/14
88.88% Functions 16/18
97.43% Lines 38/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133                              5x                               10x   5x 291x   291x 98x 98x   97x     1x   98x 98x         291x 186x 167x   19x 19x 7x 7x 5x     19x         291x 2x 2x 1x     1x 3x     1x       291x 2x 2x 1x   1x       291x   11x         2x                 14x           2x         4x                              
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useState, useEffect, useMemo } from "react";
import { X } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
	clearWatchHistoryOfType,
	deleteHistoryEntry,
	deleteHistorySeries,
	getWatchHistory,
} from "@/lib/tauri";
import type { WatchHistoryEntry } from "@/lib/types";
 
/** How many of the newest entries of its type the row shows. */
const ROW_LIMIT = 50;
 
interface RecentlyPlayedRowProps {
	contentType: "live" | "movie" | "series";
	onPlay: (entry: WatchHistoryEntry) => void;
}
 
interface DisplayEntry {
	key: string;
	displayName: string;
	/** The newest entry, which the card plays. */
	entry: WatchHistoryEntry;
	/** Set on a series card, which stands for every episode of the show. */
	seriesTitle?: string;
}
 
const showOf = (entry: WatchHistoryEntry): string => entry.seriesTitle ?? entry.channelName;
 
export const RecentlyPlayedRow = ({ contentType, onPlay }: RecentlyPlayedRowProps) => {
	const [entries, setEntries] = useState<WatchHistoryEntry[]>([]);
 
	useEffect(() => {
		let current = true;
		getWatchHistory(ROW_LIMIT, contentType)
			.then((history) => {
				Eif (current) setEntries(history);
			})
			.catch((err) => {
				console.error("[RecentlyPlayedRow] failed to load watch history:", err);
			});
		return () => {
			current = false;
		};
	}, [contentType]);
 
	// Series show one card per show, for its most recent episode.
	const displayEntries = useMemo((): DisplayEntry[] => {
		if (contentType !== "series") {
			return entries.map((e) => ({ key: e.channelId, displayName: e.channelName, entry: e }));
		}
		const byShow = new Map<string, DisplayEntry>();
		for (const entry of entries) {
			const show = showOf(entry);
			if (!byShow.has(show)) {
				byShow.set(show, { key: show, displayName: show, entry, seriesTitle: show });
			}
		}
		return [...byShow.values()];
	}, [entries, contentType]);
 
	// The deletes run in the backend, so they reach entries older than the
	// ones loaded here. A card whose delete fails stays, matching a reload.
	const dismiss = async ({ entry, seriesTitle }: DisplayEntry) => {
		try {
			if (seriesTitle === undefined) {
				await deleteHistoryEntry(entry.channelId);
				setEntries((prev) => prev.filter((e) => e.channelId !== entry.channelId));
			} else {
				await deleteHistorySeries(seriesTitle);
				setEntries((prev) => prev.filter((e) => showOf(e) !== seriesTitle));
			}
		} catch (err) {
			console.error("[RecentlyPlayedRow] failed to delete history:", err);
		}
	};
 
	const handleClearAll = async () => {
		try {
			await clearWatchHistoryOfType(contentType);
			setEntries([]);
		} catch (err) {
			console.error("[RecentlyPlayedRow] failed to clear history:", err);
		}
	};
 
	if (displayEntries.length === 0) return null;
 
	return (
		<div className="px-4 pt-3 pb-1">
			<div className="flex items-center justify-between mb-2">
				<span className="text-sm font-semibold text-muted-foreground">Recently Played</span>
				<button
					onClick={() => void handleClearAll()}
					className="text-[10px] text-primary hover:underline"
				>
					Clear all
				</button>
			</div>
			<ScrollArea className="w-full">
				<div className="flex gap-2.5 pb-2">
					{displayEntries.map((card) => (
						<div
							key={card.key}
							className="relative min-w-[160px] max-w-[180px] bg-secondary rounded-lg p-2.5 text-left hover:bg-accent transition-colors group shrink-0"
						>
							<button
								className="absolute top-1 right-1.5 z-10 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 transition-opacity p-0.5"
								onClick={() => void dismiss(card)}
								aria-label={`Dismiss ${card.displayName}`}
							>
								<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
							</button>
							<button className="w-full text-left" onClick={() => onPlay(card.entry)}>
								<div className="text-xs font-medium truncate pr-4">
									{card.displayName}
								</div>
								{contentType === "live" && (
									<div className="text-[9px] text-primary mt-1">&#9679; LIVE</div>
								)}
							</button>
						</div>
					))}
				</div>
			</ScrollArea>
		</div>
	);
};