feat: continue watching row on home, synced via account timecodes
This commit is contained in:
parent
4494a53c2c
commit
53a119087d
3 changed files with 91 additions and 1 deletions
|
|
@ -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)}`),
|
||||||
|
|
||||||
|
|
|
||||||
37
src/renderer/src/lib/continueWatching.ts
Normal file
37
src/renderer/src/lib/continueWatching.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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 ?? []} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue