notification bell shows newest episode per series with series context
This commit is contained in:
@@ -69,7 +69,9 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
})
|
})
|
||||||
const notifsRef = useRef<HTMLDivElement>(null)
|
const notifsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Poll recently-added items for the notification bell
|
// Poll recently-added episodes + movies for the notification bell.
|
||||||
|
// Episodes are deduplicated by series so a full-season drop doesn't
|
||||||
|
// flood the list -- only the newest episode per series is shown.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = jellyfinClient.getApi()
|
const api = jellyfinClient.getApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -77,16 +79,46 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
try {
|
try {
|
||||||
const auth = jellyfinClient.getAuthState()
|
const auth = jellyfinClient.getAuthState()
|
||||||
if (!auth?.userId) return
|
if (!auth?.userId) return
|
||||||
const res = await getItemsApi(api!).getItems({
|
const [movieRes, episodeRes] = await Promise.all([
|
||||||
|
getItemsApi(api!).getItems({
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
sortBy: ['DateCreated'],
|
sortBy: ['DateCreated'],
|
||||||
sortOrder: ['Descending'],
|
sortOrder: ['Descending'],
|
||||||
limit: 15,
|
limit: 10,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: ['Movie', 'Series'],
|
includeItemTypes: ['Movie'],
|
||||||
fields: ['DateCreated', 'PrimaryImageAspectRatio'],
|
fields: ['DateCreated', 'PrimaryImageAspectRatio'],
|
||||||
} as any)
|
} as any),
|
||||||
setNotifs(res.data.Items || [])
|
getItemsApi(api!).getItems({
|
||||||
|
userId: auth.userId,
|
||||||
|
sortBy: ['DateCreated'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 50,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: ['Episode'],
|
||||||
|
fields: ['DateCreated', 'PrimaryImageAspectRatio', 'SeriesName', 'SeriesId', 'ParentIndexNumber', 'IndexNumber'],
|
||||||
|
} as any),
|
||||||
|
])
|
||||||
|
const movies = (movieRes.data.Items || []) as any[]
|
||||||
|
const episodes = (episodeRes.data.Items || []) as any[]
|
||||||
|
// Deduplicate episodes by series -- keep only the newest per show
|
||||||
|
const seenSeries = new Set<string>()
|
||||||
|
const dedupedEpisodes: any[] = []
|
||||||
|
for (const ep of episodes) {
|
||||||
|
const sid = ep.SeriesId || ep.SeriesName
|
||||||
|
if (!sid || seenSeries.has(sid)) continue
|
||||||
|
seenSeries.add(sid)
|
||||||
|
dedupedEpisodes.push(ep)
|
||||||
|
}
|
||||||
|
// Interleave: one movie, one episode, one movie... capped at 15
|
||||||
|
const interleaved: any[] = []
|
||||||
|
let m = 0, e = 0
|
||||||
|
while (interleaved.length < 15 && (m < movies.length || e < dedupedEpisodes.length)) {
|
||||||
|
if (m < movies.length) interleaved.push(movies[m++])
|
||||||
|
if (interleaved.length >= 15) break
|
||||||
|
if (e < dedupedEpisodes.length) interleaved.push(dedupedEpisodes[e++])
|
||||||
|
}
|
||||||
|
setNotifs(interleaved)
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
@@ -325,6 +357,13 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
notifs.map((entry: any) => {
|
notifs.map((entry: any) => {
|
||||||
const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt)
|
const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt)
|
||||||
|
const isEpisode = entry.Type === 'Episode'
|
||||||
|
const seriesName = entry.SeriesName
|
||||||
|
const season = entry.ParentIndexNumber
|
||||||
|
const epNum = entry.IndexNumber
|
||||||
|
const epLabel = isEpisode && (season != null || epNum != null)
|
||||||
|
? `S${season ?? '?'}E${epNum ?? '?'}${entry.Name ? ` · ${entry.Name}` : ''}`
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={entry.Id}
|
key={entry.Id}
|
||||||
@@ -334,22 +373,31 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
setNotifsOpen(false)
|
setNotifsOpen(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-center gap-2.5 ${isUnread ? 'bg-white/[0.03]' : ''}`}
|
className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-start gap-2.5 ${isUnread ? 'bg-white/[0.03]' : ''}`}
|
||||||
>
|
>
|
||||||
{entry.Type === 'Series' || entry.Type === 'Episode' ? (
|
{isEpisode ? (
|
||||||
<Tv size={13} className="text-accent shrink-0" />
|
<Tv size={13} className="text-accent shrink-0 mt-0.5" />
|
||||||
) : (
|
) : (
|
||||||
<Film size={13} className="text-accent shrink-0" />
|
<Film size={13} className="text-accent shrink-0 mt-0.5" />
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEpisode && seriesName ? (
|
||||||
|
<>
|
||||||
|
<p className="text-text-2 truncate font-medium">{seriesName}</p>
|
||||||
|
{epLabel && (
|
||||||
|
<p className="text-text-3 truncate">{epLabel}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<p className="text-text-2 truncate font-medium">{entry.Name}</p>
|
<p className="text-text-2 truncate font-medium">{entry.Name}</p>
|
||||||
|
)}
|
||||||
<p className="text-text-4 tabular-nums mt-0.5">
|
<p className="text-text-4 tabular-nums mt-0.5">
|
||||||
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
||||||
{entry.ProductionYear ? ` · ${entry.ProductionYear}` : ''}
|
{entry.ProductionYear && !isEpisode ? ` · ${entry.ProductionYear}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0 mt-1.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user