feat: local sync

feat: video deband
This commit is contained in:
ThaUnknown 2025-04-11 22:40:02 +02:00
parent 0128ce7efe
commit 14476a19aa
No known key found for this signature in database
18 changed files with 241 additions and 2863 deletions

View file

@ -70,6 +70,7 @@
"tailwind-variants": "^0.2.1",
"uint8-util": "^2.2.5",
"urql": "^4.2.1",
"video-deband": "^1.0.6",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0"
}

File diff suppressed because it is too large Load diff

View file

@ -16,8 +16,6 @@
export let size: NonNullable<$$Props['size']> = 'icon-sm'
export let variant: NonNullable<$$Props['variant']> = 'ghost'
const hasAuth = authAggregator.hasAuth
function toggleBookmark () {
if (!media.mediaListEntry?.status) {
authAggregator.entry({ id: media.id, status: 'PLANNING', lists: lists(media)?.filter(({ enabled }) => enabled).map(({ name }) => name) })
@ -27,6 +25,6 @@
}
</script>
<Button {size} {variant} class={className} disabled={!$hasAuth} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
<Button {size} {variant} class={className} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
<Bookmark fill={list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

@ -14,10 +14,8 @@
export let media: Media
export let size: NonNullable<$$Props['size']> = 'icon-sm'
export let variant: NonNullable<$$Props['variant']> = 'ghost'
const hasAuth = authAggregator.hasAuth
</script>
<Button {size} {variant} class={className} disabled={!$hasAuth} on:click={clickwrap(() => authAggregator.toggleFav(media.id))} on:keydown={keywrap(() => authAggregator.toggleFav(media.id))}>
<Button {size} {variant} class={className} on:click={clickwrap(() => authAggregator.toggleFav(media.id))} on:keydown={keywrap(() => authAggregator.toggleFav(media.id))}>
<Heart fill={fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button>

View file

@ -4,8 +4,9 @@
import { persisted } from 'svelte-persisted-store'
import { toast } from 'svelte-sonner'
import { fade } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte'
import { onMount } from 'svelte'
import { loadWithDefaults } from 'svelte-keybinds'
import VideoDeband from 'video-deband'
import Seekbar from './seekbar.svelte'
import { autoPiP, burnIn, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
@ -80,7 +81,7 @@
}
function pip (enable = !$pictureInPictureElement) {
return enable ? burnIn(video, subtitles) : document.exitPictureInPicture()
return enable ? burnIn(video, subtitles, deband) : document.exitPictureInPicture()
}
function toggleCast () {
@ -180,7 +181,6 @@
let animations: Animation[] = []
const thumbnailer = new Thumbnailer(mediaInfo.file.url)
$: thumbnailer.updateSource(mediaInfo.file.url)
onMount(() => thumbnailer.setVideo(video))
let chapters: Chapter[] = []
@ -191,14 +191,31 @@
$: loadChapters(chaptersPromise, safeduration)
let subtitles: Subs | undefined
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
$: if (video && !subtitles) {
function createSubtitles (video: HTMLVideoElement) {
subtitles = new Subs(video, files, mediaInfo.file)
return {
destroy: () => {
subtitles?.destroy()
}
}
}
let deband: VideoDeband | undefined
function createDeband (video: HTMLVideoElement, playerDeband: boolean) {
if (!playerDeband) return
deband = new VideoDeband(video)
deband.canvas.classList.add('deband-canvas', 'w-full', 'h-full', 'grow', 'pointer-events-none', 'object-contain')
video.before(deband.canvas)
return {
destroy: () => {
deband?.destroy()
deband?.canvas.remove()
}
}
}
onDestroy(() => {
if (subtitles) subtitles.destroy()
})
// other
let currentSkippable: string | null = null
@ -493,8 +510,11 @@
<svelte:document bind:fullscreenElement use:bindPiP={pictureInPictureElement} />
<div class='w-full h-full relative content-center fullscreen:bg-black overflow-clip text-left' bind:this={wrapper}>
<video class='w-full h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth}
<div class='w-full h-full relative content-center fullscreen:bg-black overflow-clip text-left' class:fitWidth bind:this={wrapper}>
<video class='w-full h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth} class:opacity-0={deband} class:absolute={deband} class:top-0={deband}
use:createDeband={$settings.playerDeband}
use:createSubtitles
use:autoPiP={pip}
crossorigin='anonymous'
src={mediaInfo.file.url}
bind:videoHeight
@ -513,7 +533,6 @@
on:dblclick={fullscreen}
on:loadeddata={checkAudio}
on:timeupdate={checkSkippableChapters}
use:autoPiP={pip}
/>
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
{#if seeking}
@ -652,6 +671,9 @@
</div>
<style>
.fitWidth :global(.deband-canvas) {
object-fit: cover !important;
}
.gradient {
background: linear-gradient(to top, oklab(0 0 0 / 0.85) 0%, oklab(0 0 0 / 0.7) 35%, oklab(0 0 0 / 0) 100%);
}

View file

@ -4,6 +4,7 @@ import type { Media } from '$lib/modules/anilist'
import type { Track } from '../../../../app'
import type Subtitles from './subtitles'
import type { ResolvedFile } from './resolver'
import type VideoDeband from 'video-deband'
import { settings } from '$lib/modules/settings'
@ -198,7 +199,8 @@ export function autoPiP (video: HTMLVideoElement, pipfn: (enable: boolean) => vo
}
}
export function burnIn (video: HTMLVideoElement, subtitles?: Subtitles) {
// TODO: de-shittify this code, abort signal, play/pause sync
export function burnIn (video: HTMLVideoElement, subtitles?: Subtitles, deband?: VideoDeband) {
if (!subtitles?.renderer) return video.requestPictureInPicture()
const canvasVideo = document.createElement('video')
@ -211,8 +213,7 @@ export function burnIn (video: HTMLVideoElement, subtitles?: Subtitles) {
canvas.height = video.videoHeight
subtitles.renderer.resize(video.videoWidth, video.videoHeight)
const renderFrame = () => {
// context.drawImage(deband ? deband.canvas : video, 0, 0)
context.drawImage(video, 0, 0)
context.drawImage(deband?.canvas ?? video, 0, 0)
// @ts-expect-error internal call on canvas
if (canvas.width && canvas.height && subtitles.renderer?._canvas) context.drawImage(subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height)
loop = video.requestVideoFrameCallback(renderFrame)

View file

@ -3,3 +3,4 @@
export const CHANGELOG_URL = 'https://api.github.com/repos/ThaUnknown/miru/releases'
export const WEB_URL = 'https://miru.watch'
export const DEFAULT_EXTENSIONS = 'gh:hayase-app/extensions'
export const SETUP_VERSION = 1

View file

@ -89,7 +89,7 @@ class AnilistClient {
if (!list?.entries) return list
return {
...list,
entries: list.entries.filter(entry => entry?.id !== id)
entries: list.entries.filter(entry => entry?.media?.mediaListEntry?.id !== id)
}
})
@ -101,15 +101,15 @@ class AnilistClient {
const entry = result.SaveMediaListEntry
if (entry?.customLists) entry.customLists = (entry.customLists as string[]).map(name => ({ enabled: true, name }))
if (entry?.media?.mediaListEntry?.customLists) entry.media.mediaListEntry.customLists = (entry.media.mediaListEntry.customLists as string[]).map(name => ({ enabled: true, name }))
cache.writeFragment(media, {
id: mediaId as number,
mediaListEntry: entry
mediaListEntry: entry?.media?.mediaListEntry ?? null
})
cache.updateQuery({ query: UserLists, variables: { id: this.viewer.value?.viewer?.id } }, data => {
if (!data?.MediaListCollection?.lists) return data
const oldLists = data.MediaListCollection.lists
const oldEntry = oldLists.flatMap(list => list?.entries).find(entry => entry?.media?.id === mediaId) ?? result.SaveMediaListEntry
const oldEntry = oldLists.flatMap(list => list?.entries).find(entry => entry?.media?.id === mediaId)
const lists = oldLists.map(list => {
if (!list?.entries) return list
@ -119,7 +119,7 @@ class AnilistClient {
}
})
const status = result.SaveMediaListEntry?.status ?? oldEntry?.status ?? 'PLANNING' as const
const status = result.SaveMediaListEntry?.media?.mediaListEntry?.status ?? oldEntry?.media?.mediaListEntry?.status ?? 'PLANNING' as const
const fallback: NonNullable<typeof oldLists[0]> = { status, entries: [] }
let targetList = lists.find(list => list?.status === status)
@ -187,8 +187,7 @@ class AnilistClient {
MediaTitle: () => null,
MediaCoverImage: () => null,
AiringSchedule: () => null,
// @ts-expect-error idk
MediaListCollection: e => e.user?.id as string | null,
MediaListCollection: e => (e.user as any)?.id as string | null,
MediaListGroup: () => null,
UserAvatar: () => null
}
@ -302,7 +301,7 @@ class AnilistClient {
const ids = mediaList.filter(entry => {
if (entry?.media?.status === 'FINISHED') return true
const progress = entry?.progress ?? 0
const progress = entry?.media?.mediaListEntry?.progress ?? 0
return progress < (entry?.media?.nextAiringEpisode?.episode ?? (progress + 2)) - 1
}).map(entry => entry?.media?.id) as number[]
@ -386,8 +385,8 @@ class AnilistClient {
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data!.Page!.media!.find(media => media!.id === id)]) as Array<[string, Media | undefined]>
}
schedule () {
return queryStore({ client: this.client, query: Schedule, variables: { seasonCurrent: currentSeason, seasonYearCurrent: currentYear, seasonLast: lastSeason, seasonYearLast: lastYear, seasonNext: nextSeason, seasonYearNext: nextYear }, pause: true })
schedule (ids?: number[]) {
return queryStore({ client: this.client, query: Schedule, variables: { ids, seasonCurrent: currentSeason, seasonYearCurrent: currentYear, seasonLast: lastSeason, seasonYearLast: lastYear, seasonNext: nextSeason, seasonYearNext: nextYear }, pause: true })
}
async toggleFav (id: number) {

View file

@ -12,26 +12,26 @@ declare module 'gql.tada' {
TadaDocumentNode<{ id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; }, {}, { fragment: "FullMedia"; on: "Media"; masked: false; }>;
"\n query Search($page: Int, $perPage: Int, $search: String, $genre: [String], $format: [MediaFormat], $status: [MediaStatus], $statusNot: [MediaStatus], $season: MediaSeason, $seasonYear: Int, $isAdult: Boolean, $sort: [MediaSort], $onList: Boolean, $ids: [Int]) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n hasNextPage\n },\n media(type: ANIME, format_not: MUSIC, id_in: $ids, search: $search, genre_in: $genre, format_in: $format, status_in: $status, status_not_in: $statusNot, season: $season, seasonYear: $seasonYear, isAdult: $isAdult, sort: $sort, onList: $onList) {\n ...FullMedia\n }\n }\n }\n":
TadaDocumentNode<{ Page: { pageInfo: { hasNextPage: boolean | null; } | null; media: ({ id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; } | null)[] | null; } | null; }, { ids?: (number | null)[] | null | undefined; onList?: boolean | null | undefined; sort?: ("ID" | "ID_DESC" | "TITLE_ROMAJI" | "TITLE_ROMAJI_DESC" | "TITLE_ENGLISH" | "TITLE_ENGLISH_DESC" | "TITLE_NATIVE" | "TITLE_NATIVE_DESC" | "TYPE" | "TYPE_DESC" | "FORMAT" | "FORMAT_DESC" | "START_DATE" | "START_DATE_DESC" | "END_DATE" | "END_DATE_DESC" | "SCORE" | "SCORE_DESC" | "POPULARITY" | "POPULARITY_DESC" | "TRENDING" | "TRENDING_DESC" | "EPISODES" | "EPISODES_DESC" | "DURATION" | "DURATION_DESC" | "STATUS" | "STATUS_DESC" | "CHAPTERS" | "CHAPTERS_DESC" | "VOLUMES" | "VOLUMES_DESC" | "UPDATED_AT" | "UPDATED_AT_DESC" | "SEARCH_MATCH" | "FAVOURITES" | "FAVOURITES_DESC" | null)[] | null | undefined; isAdult?: boolean | null | undefined; seasonYear?: number | null | undefined; season?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; statusNot?: ("FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null)[] | null | undefined; status?: ("FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null)[] | null | undefined; format?: ("MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null)[] | null | undefined; genre?: (string | null)[] | null | undefined; search?: string | null | undefined; perPage?: number | null | undefined; page?: number | null | undefined; }, void>;
"\n query IDMedia($id: Int) {\n Media(id: $id, type: ANIME) {\n ...FullMedia\n }\n }\n":
TadaDocumentNode<{ Media: { id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n query IDMedia($id: Int!) {\n Media(id: $id, type: ANIME) {\n ...FullMedia\n }\n }\n":
TadaDocumentNode<{ Media: { id: number; idMal: number | null; title: { romaji: string | null; english: string | null; native: string | null; userPreferred: string | null; } | null; description: string | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; episodes: number | null; duration: number | null; averageScore: number | null; genres: (string | null)[] | null; isFavourite: boolean; coverImage: { extraLarge: string | null; medium: string | null; color: string | null; } | null; source: "OTHER" | "ANIME" | "MANGA" | "NOVEL" | "ORIGINAL" | "LIGHT_NOVEL" | "VISUAL_NOVEL" | "VIDEO_GAME" | "DOUJINSHI" | "WEB_NOVEL" | "LIVE_ACTION" | "GAME" | "COMIC" | "MULTIMEDIA_PROJECT" | "PICTURE_BOOK" | null; countryOfOrigin: unknown; isAdult: boolean | null; bannerImage: string | null; synonyms: (string | null)[] | null; nextAiringEpisode: { id: number; timeUntilAiring: number; episode: number; } | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; trailer: { id: string | null; site: string | null; } | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; studios: { nodes: ({ id: number; name: string; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; title: { userPreferred: string | null; } | null; coverImage: { medium: string | null; } | null; type: "ANIME" | "MANGA" | null; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; format: "MANGA" | "TV" | "TV_SHORT" | "MOVIE" | "SPECIAL" | "OVA" | "ONA" | "MUSIC" | "NOVEL" | "ONE_SHOT" | null; episodes: number | null; synonyms: (string | null)[] | null; season: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null; seasonYear: number | null; startDate: { year: number | null; month: number | null; day: number | null; } | null; endDate: { year: number | null; month: number | null; day: number | null; } | null; } | null; } | null)[] | null; } | null; } | null; }, { id: number; }, void>;
"\n query Viewer {\n Viewer {\n avatar {\n medium\n },\n name,\n id,\n mediaListOptions {\n animeList {\n customLists\n }\n }\n }\n }\n":
TadaDocumentNode<{ Viewer: { avatar: { medium: string | null; } | null; name: string; id: number; mediaListOptions: { animeList: { customLists: (string | null)[] | null; } | null; } | null; } | null; }, {}, void>;
"\n query UserLists($id: Int) {\n MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) {\n user {\n id\n }\n lists {\n status,\n entries {\n ...FullMediaList,\n media {\n id,\n status,\n nextAiringEpisode {\n episode\n },\n relations {\n edges {\n relationType(version:2)\n node {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n":
TadaDocumentNode<{ MediaListCollection: { user: { id: number; } | null; lists: ({ status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; entries: ({ id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; media: { id: number; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; nextAiringEpisode: { episode: number; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; } | null; } | null)[] | null; } | null; } | null; } | null)[] | null; } | null)[] | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n query UserLists($id: Int) {\n MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) {\n user {\n id\n }\n lists {\n status,\n entries {\n id,\n media {\n id,\n status,\n mediaListEntry {\n ...FullMediaList\n },\n nextAiringEpisode {\n episode\n },\n relations {\n edges {\n relationType(version:2)\n node {\n id\n }\n }\n }\n }\n }\n }\n }\n }\n":
TadaDocumentNode<{ MediaListCollection: { user: { id: number; } | null; lists: ({ status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; entries: ({ id: number; media: { id: number; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; nextAiringEpisode: { episode: number; } | null; relations: { edges: ({ relationType: "ADAPTATION" | "PREQUEL" | "SEQUEL" | "PARENT" | "SIDE_STORY" | "CHARACTER" | "SUMMARY" | "ALTERNATIVE" | "SPIN_OFF" | "OTHER" | "SOURCE" | "COMPILATION" | "CONTAINS" | null; node: { id: number; } | null; } | null)[] | null; } | null; } | null; } | null)[] | null; } | null)[] | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n mutation CustomLists($lists: [String]) {\n UpdateUser(animeListOptions: { customLists: $lists }) {\n id\n }\n }\n":
TadaDocumentNode<{ UpdateUser: { id: number; } | null; }, { lists?: (string | null)[] | null | undefined; }, void>;
"\n fragment ScheduleMedia on Media @_unmask {\n id,\n title {\n userPreferred\n }\n mediaListEntry {\n status\n }\n aired: airingSchedule(page: 1, perPage: 50, notYetAired: false) {\n n: nodes {\n a: airingAt,\n e: episode\n }\n },\n notaired: airingSchedule(page: 1, perPage: 50, notYetAired: true) {\n n: nodes {\n a: airingAt,\n e: episode\n }\n }\n }\n":
TadaDocumentNode<{ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; }, {}, { fragment: "ScheduleMedia"; on: "Media"; masked: false; }>;
"\n query Schedule($seasonCurrent: MediaSeason, $seasonYearCurrent: Int, $seasonLast: MediaSeason, $seasonYearLast: Int, $seasonNext: MediaSeason, $seasonYearNext: Int) {\n curr1: Page(page: 1) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n }\n curr2: Page(page: 2) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n }\n curr3: Page(page: 3) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n }\n residue: Page(page: 1) {\n media(type: ANIME, season: $seasonLast, seasonYear: $seasonYearLast, episodes_greater: 16, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n },\n next1: Page(page: 1) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n },\n next2: Page(page: 2) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {\n ...ScheduleMedia\n }\n }\n }\n":
TadaDocumentNode<{ curr1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr3: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; residue: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; }, { seasonYearNext?: number | null | undefined; seasonNext?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearLast?: number | null | undefined; seasonLast?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearCurrent?: number | null | undefined; seasonCurrent?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; }, void>;
"\n query Following($id: Int) {\n Page {\n mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {\n id,\n status,\n score,\n progress,\n user {\n id,\n name,\n avatar {\n medium\n }\n }\n }\n }\n }\n":
TadaDocumentNode<{ Page: { mediaList: ({ id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; score: number | null; progress: number | null; user: { id: number; name: string; avatar: { medium: string | null; } | null; } | null; } | null)[] | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n mutation Entry($lists: [String], $id: Int, $status: MediaListStatus, $progress: Int, $repeat: Int, $score: Int) {\n SaveMediaListEntry(mediaId: $id, status: $status, progress: $progress, repeat: $repeat, scoreRaw: $score, customLists: $lists) {\n ...FullMediaList\n media {\n id,\n status,\n nextAiringEpisode {\n episode\n }\n }\n }\n }\n":
TadaDocumentNode<{ SaveMediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; media: { id: number; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; nextAiringEpisode: { episode: number; } | null; } | null; } | null; }, { score?: number | null | undefined; repeat?: number | null | undefined; progress?: number | null | undefined; status?: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null | undefined; id?: number | null | undefined; lists?: (string | null)[] | null | undefined; }, void>;
"\n mutation DeleteEntry($id: Int) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n":
TadaDocumentNode<{ DeleteMediaListEntry: { deleted: boolean | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n mutation ToggleFavourite($id: Int) {\n ToggleFavourite(animeId: $id) { anime { nodes { id } } } \n }\n":
TadaDocumentNode<{ ToggleFavourite: { anime: { nodes: ({ id: number; } | null)[] | null; } | null; } | null; }, { id?: number | null | undefined; }, void>;
"\n query Schedule($seasonCurrent: MediaSeason, $seasonYearCurrent: Int, $seasonLast: MediaSeason, $seasonYearLast: Int, $seasonNext: MediaSeason, $seasonYearNext: Int, $ids: [Int]) {\n curr1: Page(page: 1) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n curr2: Page(page: 2) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n curr3: Page(page: 3) {\n media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n residue: Page(page: 1) {\n media(type: ANIME, season: $seasonLast, seasonYear: $seasonYearLast, episodes_greater: 16, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n },\n next1: Page(page: 1) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n },\n next2: Page(page: 2) {\n media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {\n ...ScheduleMedia\n }\n }\n }\n":
TadaDocumentNode<{ curr1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; curr3: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; residue: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next1: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; next2: { media: ({ id: number; title: { userPreferred: string | null; } | null; mediaListEntry: { status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; } | null; aired: { n: ({ a: number; e: number; } | null)[] | null; } | null; notaired: { n: ({ a: number; e: number; } | null)[] | null; } | null; } | null)[] | null; } | null; }, { ids?: (number | null)[] | null | undefined; seasonYearNext?: number | null | undefined; seasonNext?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearLast?: number | null | undefined; seasonLast?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; seasonYearCurrent?: number | null | undefined; seasonCurrent?: "WINTER" | "SPRING" | "SUMMER" | "FALL" | null | undefined; }, void>;
"\n query Following($id: Int!) {\n Page {\n mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {\n id,\n status,\n score,\n progress,\n user {\n id,\n name,\n avatar {\n medium\n }\n }\n }\n }\n }\n":
TadaDocumentNode<{ Page: { mediaList: ({ id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; score: number | null; progress: number | null; user: { id: number; name: string; avatar: { medium: string | null; } | null; } | null; } | null)[] | null; } | null; }, { id: number; }, void>;
"\n mutation Entry($lists: [String], $id: Int!, $status: MediaListStatus, $progress: Int, $repeat: Int, $score: Int) {\n SaveMediaListEntry(mediaId: $id, status: $status, progress: $progress, repeat: $repeat, scoreRaw: $score, customLists: $lists) {\n id,\n media {\n id,\n status,\n mediaListEntry {\n ...FullMediaList\n },\n nextAiringEpisode {\n episode\n }\n }\n }\n }\n":
TadaDocumentNode<{ SaveMediaListEntry: { id: number; media: { id: number; status: "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" | null; mediaListEntry: { id: number; status: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null; progress: number | null; repeat: number | null; score: number | null; customLists: unknown; } | null; nextAiringEpisode: { episode: number; } | null; } | null; } | null; }, { score?: number | null | undefined; repeat?: number | null | undefined; progress?: number | null | undefined; status?: "CURRENT" | "PLANNING" | "COMPLETED" | "DROPPED" | "PAUSED" | "REPEATING" | null | undefined; id: number; lists?: (string | null)[] | null | undefined; }, void>;
"\n mutation DeleteEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n":
TadaDocumentNode<{ DeleteMediaListEntry: { deleted: boolean | null; } | null; }, { id: number; }, void>;
"\n mutation ToggleFavourite($id: Int!) {\n ToggleFavourite(animeId: $id) { anime { nodes { id } } } \n }\n":
TadaDocumentNode<{ ToggleFavourite: { anime: { nodes: ({ id: number; } | null)[] | null; } | null; } | null; }, { id: number; }, void>;
"fragment Med on Media {id, isFavourite}":
TadaDocumentNode<{ id: number; isFavourite: boolean; }, {}, { fragment: "Med"; on: "Media"; masked: true; }>;
"fragment Med on Media {id, mediaListEntry {status, progress, repeat, score, customLists }}":

View file

@ -125,7 +125,7 @@ export const Search = gql(`
`, [FullMedia])
export const IDMedia = gql(`
query IDMedia($id: Int) {
query IDMedia($id: Int!) {
Media(id: $id, type: ANIME) {
...FullMedia
}
@ -158,10 +158,13 @@ export const UserLists = gql(`
lists {
status,
entries {
...FullMediaList,
id,
media {
id,
status,
mediaListEntry {
...FullMediaList
},
nextAiringEpisode {
episode
},
@ -213,34 +216,34 @@ export const ScheduleMedia = gql(`
`)
export const Schedule = gql(`
query Schedule($seasonCurrent: MediaSeason, $seasonYearCurrent: Int, $seasonLast: MediaSeason, $seasonYearLast: Int, $seasonNext: MediaSeason, $seasonYearNext: Int) {
query Schedule($seasonCurrent: MediaSeason, $seasonYearCurrent: Int, $seasonLast: MediaSeason, $seasonYearLast: Int, $seasonNext: MediaSeason, $seasonYearNext: Int, $ids: [Int]) {
curr1: Page(page: 1) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
}
curr2: Page(page: 2) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
}
curr3: Page(page: 3) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonCurrent, seasonYear: $seasonYearCurrent, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
}
residue: Page(page: 1) {
media(type: ANIME, season: $seasonLast, seasonYear: $seasonYearLast, episodes_greater: 16, countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonLast, seasonYear: $seasonYearLast, episodes_greater: 16, countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
},
next1: Page(page: 1) {
media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
},
next2: Page(page: 2) {
media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true) {
media(type: ANIME, season: $seasonNext, seasonYear: $seasonYearNext, sort: [START_DATE], countryOfOrigin: JP, format_not: TV_SHORT, onList: true, id_in: $ids) {
...ScheduleMedia
}
}
@ -248,7 +251,7 @@ export const Schedule = gql(`
`, [ScheduleMedia])
export const Following = gql(`
query Following($id: Int) {
query Following($id: Int!) {
Page {
mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {
id,
@ -268,12 +271,15 @@ export const Following = gql(`
`)
export const Entry = gql(`
mutation Entry($lists: [String], $id: Int, $status: MediaListStatus, $progress: Int, $repeat: Int, $score: Int) {
mutation Entry($lists: [String], $id: Int!, $status: MediaListStatus, $progress: Int, $repeat: Int, $score: Int) {
SaveMediaListEntry(mediaId: $id, status: $status, progress: $progress, repeat: $repeat, scoreRaw: $score, customLists: $lists) {
...FullMediaList
id,
media {
id,
status,
mediaListEntry {
...FullMediaList
},
nextAiringEpisode {
episode
}
@ -283,7 +289,7 @@ export const Entry = gql(`
`, [FullMediaList])
export const DeleteEntry = gql(`
mutation DeleteEntry($id: Int) {
mutation DeleteEntry($id: Int!) {
DeleteMediaListEntry(id: $id) {
deleted
}
@ -291,7 +297,7 @@ export const DeleteEntry = gql(`
`)
export const ToggleFavourite = gql(`
mutation ToggleFavourite($id: Int) {
mutation ToggleFavourite($id: Int!) {
ToggleFavourite(animeId: $id) { anime { nodes { id } } }
}
`)

View file

@ -2,17 +2,19 @@ import { readable } from 'simple-store-svelte'
import { client } from '../anilist'
import local from './local'
import type { VariablesOf } from 'gql.tada'
import type { Entry } from '../anilist/queries'
export default new class AuthAggregator {
hasAuth = readable(this.checkAuth(), set => {
// add other subscriptions here
const unsAL = client.viewer.subscribe(() => set(this.checkAuth()))
// add other subscriptions here for MAL, kitsu, tvdb, etc
const unsub = [
client.viewer.subscribe(() => set(this.checkAuth()))
]
return () => {
unsAL()
}
return () => unsub.forEach(fn => fn())
})
// AUTH
@ -33,8 +35,6 @@ export default new class AuthAggregator {
profile () {
if (this.anilist()) return client.viewer.value?.viewer
return null
}
// QUERIES/MUTATIONS
@ -42,25 +42,22 @@ export default new class AuthAggregator {
schedule () {
if (this.anilist()) return client.schedule()
return client.schedule()
return local.schedule()
}
toggleFav (id: number) {
if (this.anilist()) return client.toggleFav(id)
return client.toggleFav(id)
if (this.anilist()) client.toggleFav(id)
local.toggleFav(id)
}
delete (id: number) {
if (this.anilist()) return client.deleteEntry(id)
if (this.anilist()) client.deleteEntry(id)
return client.deleteEntry(id)
local.deleteEntry(id)
}
following (id: number) {
if (this.anilist()) return client.following(id)
return client.following(id)
}
continueIDs () {
@ -82,8 +79,8 @@ export default new class AuthAggregator {
} else {
delete variables.lists
}
if (this.anilist()) return client.entry(variables)
if (this.anilist()) client.entry(variables)
return client.entry(variables)
local.entry(variables)
}
}()

View file

@ -0,0 +1,94 @@
import { createStore, set, get } from 'idb-keyval'
import { writable } from 'simple-store-svelte'
import { client, type Media } from '../anilist'
import type { Entry } from '../anilist/queries'
import type { VariablesOf } from 'gql.tada'
type StoredMedia = Pick<Media, 'isFavourite' | 'mediaListEntry' | 'id'>
export default new class LocalSync {
store = createStore('watchlist', 'local')
entries = writable<Record<number, StoredMedia>>({})
constructor () {
get('entries', this.store).then(s => {
this.entries.value = s ?? {}
})
this.entries.subscribe(entries => {
set('entries', entries, this.store)
})
}
get (id: number) {
return this.entries.value[id]
}
_createEntry (id: number): StoredMedia {
// const media = client.client.readQuery(IDMedia, { id })?.data?.Media
return this.entries.value[id] ?? {
id,
isFavourite: false,
mediaListEntry: {
id,
customLists: null,
progress: null,
repeat: null,
score: null,
status: null
}
}
}
schedule (): ReturnType<typeof client.schedule> {
return client.schedule(Object.keys(this.entries.value).map(id => parseInt(id)))
}
toggleFav (id: number) {
this.entries.update(entries => {
const entry = this._createEntry(id)
entry.isFavourite = !entry.isFavourite
return { ...entries, [id]: entry }
})
}
deleteEntry (id: number) {
this.entries.update(entries => {
const entry = this._createEntry(id)
entry.mediaListEntry = null
return { ...entries, [id]: entry }
})
}
// this is in theory doable, but hard to sync media's airing status
// continueIDs () {
// }
// sequelIDs () {
// }
_fillOutKeys (entry: StoredMedia, variables: VariablesOf<typeof Entry>) {
const keys = ['status', 'score', 'repeat', 'progress'] as const
for (const key of keys) {
// @ts-expect-error idk how to fix this tbf
entry.mediaListEntry[key] = variables[key] ?? null
}
}
entry (variables: VariablesOf<typeof Entry>) {
this.entries.update(entries => {
const entry = this._createEntry(variables.id)
const keys = ['status', 'score', 'repeat', 'progress'] as const
for (const key of keys) {
// @ts-expect-error idk how to fix this tbf
entry.mediaListEntry![key] = variables[key] ?? entry.mediaListEntry![key] ?? null
}
return { ...entries, [variables.id]: entry }
})
}
}()

View file

@ -1,31 +1,35 @@
import { episodes, type Media } from '../anilist'
export function progress ({ mediaListEntry }: Pick<Media, 'mediaListEntry'>): number | undefined {
if (!mediaListEntry?.progress) return
// TODO: these should probably be in auth aggregator
import local from './local'
export function progress ({ mediaListEntry, id }: Pick<Media, 'mediaListEntry' | 'id'>): number | undefined {
if (!mediaListEntry?.progress) return local.get(id)?.mediaListEntry?.progress ?? undefined
return mediaListEntry.progress
}
export function fav (media: Pick<Media, 'isFavourite'>): boolean {
return media.isFavourite
export function fav (media: Pick<Media, 'isFavourite' | 'id'>): boolean {
return media.isFavourite || (local.get(media.id)?.isFavourite ?? false)
}
export function list (media: Pick<Media, 'mediaListEntry'>): 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING' | null | undefined {
return media.mediaListEntry?.status
export function list (media: Pick<Media, 'mediaListEntry' | 'id'>): 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING' | null | undefined {
return media.mediaListEntry?.status ?? local.get(media.id)?.mediaListEntry?.status
}
export function lists (media: Pick<Media, 'mediaListEntry'>): Array<{ enabled: boolean, name: string }> | undefined {
export function lists (media: Pick<Media, 'mediaListEntry' | 'id'>): Array<{ enabled: boolean, name: string }> | undefined {
return media.mediaListEntry?.customLists as Array<{ enabled: boolean, name: string }> | undefined
}
export function repeat (media: Pick<Media, 'mediaListEntry'>): number | null | undefined {
return media.mediaListEntry?.repeat
export function repeat (media: Pick<Media, 'mediaListEntry' | 'id'>): number | null | undefined {
return media.mediaListEntry?.repeat ?? local.get(media.id)?.mediaListEntry?.repeat
}
export function score (media: Pick<Media, 'mediaListEntry'>): number | null | undefined {
return media.mediaListEntry?.score
export function score (media: Pick<Media, 'mediaListEntry' | 'id'>): number | null | undefined {
return media.mediaListEntry?.score ?? local.get(media.id)?.mediaListEntry?.score
}
export function of (media: Pick<Media, 'aired' | 'notaired' | 'episodes' | 'mediaListEntry'>): string | undefined {
export function of (media: Pick<Media, 'aired' | 'notaired' | 'episodes' | 'mediaListEntry' | 'id'>): string | undefined {
const count = episodes(media)
if (count === 1 || !count) return

View file

@ -1,8 +1,9 @@
import { redirect } from '@sveltejs/kit'
import { outdatedComponent } from '$lib/modules/update'
import { SETUP_VERSION } from '$lib'
export function load () {
if (outdatedComponent) return redirect(307, '/update/')
redirect(307, localStorage.getItem('setup-finished') ? '/app/home/' : '/setup')
redirect(307, Number(localStorage.getItem('setup-finished')) >= SETUP_VERSION ? '/app/home/' : '/setup')
}

View file

@ -3,9 +3,11 @@ import { error, redirect } from '@sveltejs/kit'
import { dev } from '$app/environment'
import native from '$lib/modules/native'
import { outdatedComponent } from '$lib/modules/update'
import { SETUP_VERSION } from '$lib'
export function load () {
if (!dev && !native.isApp) return error(401, 'How did you get here?')
if (Number(localStorage.getItem('setup-finished')) < SETUP_VERSION) redirect(307, '/setup')
if (outdatedComponent) redirect(307, '/update/')
}

View file

@ -15,7 +15,6 @@
import { cover, desc, duration, format, relation, season, status, title } from '$lib/modules/anilist'
import { authAggregator, of } from '$lib/modules/auth'
import native from '$lib/modules/native'
import { cn } from '$lib/utils'
import EpisodesList from '$lib/components/EpisodesList.svelte'
import MyAnimeList from '$lib/components/icons/MyAnimeList.svelte'
import Anilist from '$lib/components/icons/Anilist.svelte'
@ -55,10 +54,8 @@
hideBanner.value = target.scrollTop > 100
}
const hasAuth = authAggregator.hasAuth
$: mediaID = media.id
$: following = $hasAuth ? authAggregator.following(mediaID) : undefined
$: following = authAggregator.following(mediaID)
$: eps = data.eps
</script>
@ -116,10 +113,8 @@
</div>
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class={cn($hasAuth && 'rounded-r-none', 'w-full')} />
{#if $hasAuth}
<EntryEditor {media} />
{/if}
<PlayButton size='default' {media} class='rounded-r-none' />
<EntryEditor {media} />
</div>
<FavoriteButton {media} variant='secondary' size='icon' class='min-[380px]:-order-1 md:order-none' />
<BookmarkButton {media} variant='secondary' size='icon' class='min-[380px]:-order-2 md:order-none' />

View file

@ -1,7 +1,6 @@
<script lang='ts'>
import { addMonths, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek, subMonths } from 'date-fns'
import { Button as ButtonPrimitive } from 'bits-ui'
import { get } from 'svelte/store'
import { onMount, tick } from 'svelte'
import { Cross2 } from 'svelte-radix'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-svelte'
@ -46,7 +45,7 @@
$: dayList = listDays(firstDay, lastDay)
const paused = query.isPaused$
if (get(query.isPaused$)) query.resume()
if ($paused) query.resume()
interface DayAirTimes { day: { date: Date, number: number }, episodes: Array<ResultOf<typeof ScheduleMedia> & { episode: number, airTime: Date }> }

View file

@ -25,7 +25,7 @@
function checkNext () {
if (step !== 2) return
localStorage.setItem('setup-finished', 'true')
localStorage.setItem('setup-finished', '1')
}
</script>