feat: continue watching row on home, synced via account timecodes

This commit is contained in:
ImSavsis 2026-07-19 06:24:02 +05:00
parent 4494a53c2c
commit 53a119087d
3 changed files with 91 additions and 1 deletions

View file

@ -1,6 +1,7 @@
import type { import type {
CatalogFilters, CatalogFilters,
CatalogResponse, CatalogResponse,
Episode,
Genre, Genre,
Release, Release,
ScheduleDay, ScheduleDay,
@ -41,6 +42,9 @@ export const api = {
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`), release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
episode: (releaseEpisodeId: string) =>
req<Episode & { release: Release }>(`/anime/releases/episodes/${releaseEpisodeId}`),
search: (query: string) => search: (query: string) =>
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`), req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),

View file

@ -0,0 +1,37 @@
import { api } from './api'
import { getAllLocalProgress } from './localProgress'
import type { Episode, Release } from '../../../shared/types'
export interface ContinueWatchingItem {
release: Release
episode: Episode
time: number
}
export async function getContinueWatching(limit = 12): Promise<ContinueWatchingItem[]> {
const merged = new Map<string, { time: number; is_watched: boolean }>()
for (const [id, p] of Object.entries(getAllLocalProgress())) merged.set(id, p)
try {
const remote = await window.anisav.timecodes()
for (const t of remote) merged.set(t.release_episode_id, { time: t.time, is_watched: t.is_watched })
} catch {}
const candidates = [...merged.entries()]
.filter(([, p]) => !p.is_watched && p.time > 10)
.slice(0, limit)
const resolved = await Promise.all(
candidates.map(async ([id, p]) => {
try {
const { release, ...episode } = await api.episode(id)
return { release, episode, time: p.time }
} catch {
return null
}
})
)
return resolved.filter((x): x is ContinueWatchingItem => x !== null)
}

View file

@ -1,20 +1,23 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Link } from 'react-router-dom'
import { api, imageUrl } from '../lib/api' import { api, imageUrl } from '../lib/api'
import { getContinueWatching, ContinueWatchingItem } from '../lib/continueWatching'
import type { Release, ScheduleDay } from '../../../shared/types' import type { Release, ScheduleDay } from '../../../shared/types'
import Row from '../components/Row' import Row from '../components/Row'
import { GridSkeleton } from '../components/Skeleton' import { GridSkeleton } from '../components/Skeleton'
import { Link } from 'react-router-dom'
export default function Home() { export default function Home() {
const [latest, setLatest] = useState<Release[] | null>(null) const [latest, setLatest] = useState<Release[] | null>(null)
const [recommended, setRecommended] = useState<Release[] | null>(null) const [recommended, setRecommended] = useState<Release[] | null>(null)
const [today, setToday] = useState<ScheduleDay[] | null>(null) const [today, setToday] = useState<ScheduleDay[] | null>(null)
const [continueWatching, setContinueWatching] = useState<ContinueWatchingItem[]>([])
useEffect(() => { useEffect(() => {
api.latest(21).then(setLatest).catch(() => setLatest([])) api.latest(21).then(setLatest).catch(() => setLatest([]))
api.recommended(14).then(setRecommended).catch(() => setRecommended([])) api.recommended(14).then(setRecommended).catch(() => setRecommended([]))
api.scheduleNow().then((s) => setToday(s.today)).catch(() => setToday([])) api.scheduleNow().then((s) => setToday(s.today)).catch(() => setToday([]))
getContinueWatching().then(setContinueWatching).catch(() => setContinueWatching([]))
}, []) }, [])
const hero = latest?.[0] const hero = latest?.[0]
@ -64,6 +67,52 @@ export default function Home() {
</div> </div>
)} )}
{continueWatching.length > 0 && (
<section className="mx-auto max-w-7xl px-6 py-5">
<h2 className="mb-3 text-lg font-bold">Продолжить просмотр</h2>
<motion.div
initial="hidden"
animate="show"
variants={{ show: { transition: { staggerChildren: 0.04 } } }}
className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-7"
>
{continueWatching.map((c) => (
<motion.div
key={c.episode.id}
variants={{ hidden: { opacity: 0, y: 16 }, show: { opacity: 1, y: 0 } }}
>
<Link to={`/title/${c.release.alias || c.release.id}`} className="poster-card group block">
<div className="aspect-[2/3] w-full overflow-hidden rounded-xl bg-surface-raised shadow-md shadow-black/30 ring-1 ring-white/5">
<img
src={imageUrl(c.release.poster?.optimized?.preview || c.release.poster?.preview)}
alt={c.release.name.main}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="absolute inset-x-0 bottom-0 p-2">
<p className="mb-1 truncate text-[10px] font-medium text-white/70">
Серия {c.episode.ordinal}
</p>
{c.episode.duration && (
<div className="h-0.5 w-full overflow-hidden rounded bg-black/40">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, (c.time / c.episode.duration) * 100)}%` }}
/>
</div>
)}
</div>
</div>
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-tight text-white/90">
{c.release.name.main}
</p>
</Link>
</motion.div>
))}
</motion.div>
</section>
)}
<Row title="Онгоинги сегодня" releases={today?.map((s) => s.release).filter(Boolean) ?? []} /> <Row title="Онгоинги сегодня" releases={today?.map((s) => s.release).filter(Boolean) ?? []} />
<Row title="Последние обновления" releases={latest} /> <Row title="Последние обновления" releases={latest} />
<Row title="Рекомендуем" releases={recommended ?? []} /> <Row title="Рекомендуем" releases={recommended ?? []} />