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">● LIVE</div>
)}
</button>
</div>
))}
</div>
</ScrollArea>
</div>
);
};
|