Merge branch 'master' of https://github.com/RockinChaos/miru into pr-test

This commit is contained in:
NoCrypt 2024-09-09 11:39:22 +07:00
commit 50f8c2c190
72 changed files with 3150 additions and 798 deletions

View file

@ -19,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "github:thaunknown/capacitor-assets", "@capacitor/assets": "github:thaunknown/capacitor-assets",
"@capacitor/cli": "^6.1.2",
"cordova-res": "^0.15.4", "cordova-res": "^0.15.4",
"nodejs-mobile-gyp": "^0.4.0", "nodejs-mobile-gyp": "^0.4.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
@ -30,7 +31,7 @@
"@capacitor/android": "^6.1.1", "@capacitor/android": "^6.1.1",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/app-launcher": "^6.0.2", "@capacitor/app-launcher": "^6.0.2",
"@capacitor/browser": "^6.0.1", "@capacitor/browser": "^6.0.2",
"@capacitor/cli": "^6.1.1", "@capacitor/cli": "^6.1.1",
"@capacitor/core": "^6.1.1", "@capacitor/core": "^6.1.1",
"@capacitor/device": "^6.0.1", "@capacitor/device": "^6.0.1",

View file

@ -85,6 +85,7 @@ IPC.on('dialog', async () => {
// schema: migu://key/value // schema: migu://key/value
const protocolMap = { const protocolMap = {
auth: token => sendToken(token), auth: token => sendToken(token),
malauth: token => sendMalToken(token),
anime: id => IPC.emit('open-anime', id), anime: id => IPC.emit('open-anime', id),
w2g: link => IPC.emit('w2glink', link), w2g: link => IPC.emit('w2glink', link),
schedule: () => IPC.emit('schedule'), schedule: () => IPC.emit('schedule'),
@ -106,6 +107,17 @@ function sendToken (line) {
} }
} }
function sendMalToken (line) {
let code = line.split('code=')[1].split('&state')[0]
let state = line.split('&state=')[1]
if (code && state) {
if (code.endsWith('/')) code = code.slice(0, -1)
if (state.endsWith('/')) state = state.slice(0, -1)
if (state.includes('%')) state = decodeURIComponent(state)
IPC.emit('maltoken', code, state)
}
}
App.getLaunchUrl().then(res => { App.getLaunchUrl().then(res => {
if (location.hash !== '#skipAlLogin') { if (location.hash !== '#skipAlLogin') {
location.hash = '#skipAlLogin' location.hash = '#skipAlLogin'

View file

@ -6,6 +6,7 @@
// import { rss } from './views/TorrentSearch/TorrentModal.svelte' // import { rss } from './views/TorrentSearch/TorrentModal.svelte'
export const page = writable('home') export const page = writable('home')
export const overlay = writable('none')
export const view = writable(null) export const view = writable(null)
export async function handleAnime (anime) { export async function handleAnime (anime) {
view.set(null) view.set(null)
@ -58,7 +59,7 @@
import TorrentModal from './views/TorrentSearch/TorrentModal.svelte' import TorrentModal from './views/TorrentSearch/TorrentModal.svelte'
import Menubar from './components/Menubar.svelte' import Menubar from './components/Menubar.svelte'
import { toast, Toaster } from 'svelte-sonner' import { toast, Toaster } from 'svelte-sonner'
import Logout from './components/Logout.svelte' import Profiles from './components/Profiles.svelte'
import Navbar from './components/Navbar.svelte' import Navbar from './components/Navbar.svelte'
import { SUPPORTS } from '@/modules/support.js'; import { SUPPORTS } from '@/modules/support.js';
import UpdateModal, { changeLog, updateModal } from './components/UpdateModal.svelte'; import UpdateModal, { changeLog, updateModal } from './components/UpdateModal.svelte';
@ -108,20 +109,20 @@
</script> </script>
<div class='page-wrapper with-transitions bg-dark position-relative' data-sidebar-type='overlayed-all'> <div class="page-wrapper with-transitions bg-dark position-relative" data-sidebar-type='overlayed-all'>
<Menubar bind:page={$page} /> <Menubar bind:page={$page} />
<ViewAnime /> <Profiles />
<UpdateModal />
<Logout />
<Sidebar bind:page={$page} /> <Sidebar bind:page={$page} />
<div class='overflow-hidden content-wrapper h-full'>
<Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton toastOptions={{ <Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton toastOptions={{
classes: { classes: {
closeButton: SUPPORTS.isAndroid ? "toast-close-button" : "" closeButton: SUPPORTS.isAndroid ? "toast-close-button" : ""
} }
}} style="margin-top: var(--safe-area-top)"/> }} />
<div class='overflow-hidden content-wrapper h-full z-10'> <ViewAnime bind:overlay={$overlay} />
<TorrentModal /> <UpdateModal/>
<Router bind:page={$page} /> <TorrentModal bind:overlay={$overlay} />
<Router bind:page={$page} bind:overlay={$overlay} />
</div> </div>
<Navbar bind:page={$page} /> <Navbar bind:page={$page} />
</div> </div>
@ -145,6 +146,7 @@
.content-wrapper { .content-wrapper {
will-change: width; will-change: width;
white-space: pre-line;
top: 0 !important; top: 0 !important;
} }

View file

@ -1,4 +1,6 @@
<script context='module'> <script context='module'>
import { readable } from 'simple-store-svelte'
const mql = matchMedia('(min-width: 769px)') const mql = matchMedia('(min-width: 769px)')
const isMobile = readable(!mql.matches, set => { const isMobile = readable(!mql.matches, set => {
const check = ({ matches }) => set(!matches) const check = ({ matches }) => set(!matches)
@ -15,7 +17,6 @@
import Miniplayer from 'svelte-miniplayer' import Miniplayer from 'svelte-miniplayer'
import Search from './views/Search.svelte' import Search from './views/Search.svelte'
import AiringSchedule from './views/AiringSchedule.svelte' import AiringSchedule from './views/AiringSchedule.svelte'
import { readable } from 'simple-store-svelte'
import { files } from './views/Player/MediaHandler.svelte' // this is sooo hacky and possibly delaying viewer on startup import { files } from './views/Player/MediaHandler.svelte' // this is sooo hacky and possibly delaying viewer on startup
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SUPPORTS } from '@/modules/support.js'; import { SUPPORTS } from '@/modules/support.js';
@ -26,6 +27,7 @@
import { rss } from './views/TorrentSearch/TorrentModal.svelte'; import { rss } from './views/TorrentSearch/TorrentModal.svelte';
export let page = 'home' export let page = 'home'
export let overlay = 'none'
$: minwidth = $isMobile ? '200px' : '35rem' $: minwidth = $isMobile ? '200px' : '35rem'
$: maxwidth = $isMobile ? '200px' : '60rem' $: maxwidth = $isMobile ? '200px' : '60rem'
@ -50,8 +52,8 @@
</script> </script>
<div class='w-full h-full position-absolute overflow-hidden' class:sr-only={($files.length === 0)}> <div class='w-full h-full position-absolute overflow-hidden' class:sr-only={($files.length === 0)}>
<Miniplayer active={page !== 'player'} class='bg-dark-light z-10 {page === 'player' ? 'h-full' : ''}' {minwidth} {maxwidth} width='300px' padding='2rem' resize={!$isMobile}> <Miniplayer active={(page !== 'player' && overlay !== 'torrent') || overlay === 'viewanime'} class='bg-dark-light z-100 {(page === "player" && overlay !== "viewanime") ? "h-full" : ""}' {minwidth} {maxwidth} width='300px' padding='2rem' resize={!$isMobile}>
<MediaHandler miniplayer={page !== 'player'} bind:page /> <MediaHandler miniplayer={page !== 'player' || overlay === 'viewanime'} bind:page bind:overlay />
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
{#if page !== 'player'} {#if page !== 'player'}
<div tabindex="0" use:click={closeMiniplayer} style="position: absolute; top: 5px; right: 5px; cursor: alias; z-index: 100; font-size: 3rem; line-height: 2.2rem; text-shadow: 0px 0px 10px black;">&times;</div> <div tabindex="0" use:click={closeMiniplayer} style="position: absolute; top: 5px; right: 5px; cursor: alias; z-index: 100; font-size: 3rem; line-height: 2.2rem; text-shadow: 0px 0px 10px black;">&times;</div>

View file

@ -1,49 +0,0 @@
<script context='module'>
import { click } from '@/modules/click.js'
import { writable } from 'simple-store-svelte'
export const logout = writable(false)
function confirm () {
localStorage.removeItem('ALviewer')
location.hash = ''
location.reload()
}
</script>
<script>
let modal
function close () {
$logout = false
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $logout && modal?.focus()
</script>
<div class='modal z-40' class:show={$logout}>
{#if $logout}
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content d-flex justify-content-center flex-column'>
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> &times; </button>
<h5 class='modal-title'>Log Out</h5>
<p>
Are You Sure You Want To Sign Out?
</p>
<div class='text-right mt-20'>
<button class='btn mr-5' type='button' on:click={close}>Cancel</button>
<button class='btn btn-danger' type='button' on:click={confirm}>Sign Out</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.close {
top: 4rem !important;
left: unset !important;
right: 2.5rem !important;
}
</style>

View file

@ -3,6 +3,8 @@
import { media } from '../views/Player/MediaHandler.svelte' import { media } from '../views/Player/MediaHandler.svelte'
import { rss } from '@/views/TorrentSearch/TorrentModal.svelte' import { rss } from '@/views/TorrentSearch/TorrentModal.svelte'
import NavbarLink from './NavbarLink.svelte' import NavbarLink from './NavbarLink.svelte'
// import { click } from '@/modules/click.js'
// import IPC from '@/modules/ipc.js'
import { MagnifyingGlass } from 'svelte-radix' import { MagnifyingGlass } from 'svelte-radix'
import { Users, Clock, Settings, Heart, ListVideo, House } from 'lucide-svelte' import { Users, Clock, Settings, Heart, ListVideo, House } from 'lucide-svelte'
const view = getContext('view') const view = getContext('view')
@ -15,11 +17,15 @@
$rss = null $rss = null
} }
// function close () {
// $view = null
// page = 'home'
// }
</script> </script>
<nav class='navbar navbar-fixed-bottom d-block d-md-none border-0 bg-dark' style='border-top: 1.5px #fff2 solid !important;'> <nav class='navbar navbar-fixed-bottom d-block d-md-none border-0 bg-dark' style='border-top: 1.5px #fff2 solid !important;'>
<div class='navbar-menu h-full d-flex flex-row justify-content-center align-items-center m-0 pb-5' class:animate={page !== 'player'}> <div class='navbar-menu h-full d-flex flex-row justify-content-center align-items-center m-0 pb-5' class:animate={page !== 'player'}>
<!-- <img src='./logo_filled.png' class='w-50 h-50 m-10 pointer p-5' alt='ico' /> --> <!-- <img src='./logo_filled.png' class='w-50 h-50 m-10 pointer p-5' alt='ico' use:click={close} /> -->
<NavbarLink click={() => { page = 'home'; noModals()}} _page='home' icon='home' {page} let:active> <NavbarLink click={() => { page = 'home'; noModals()}} _page='home' icon='home' {page} let:active>
<House size='2.2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' strokeWidth={active ? '3.5' : '2'} /> <House size='2.2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' strokeWidth={active ? '3.5' : '2'} />
</NavbarLink> </NavbarLink>

View file

@ -11,8 +11,7 @@
<div <div
class='navbar-link navbar-link-with-icon pointer overflow-hidden mx-auto {css}' class='navbar-link navbar-link-with-icon pointer overflow-hidden mx-auto {css}'
use:click={_click}> use:click={() => { _click(); if (!icon.includes("favorite")) { window.dispatchEvent(new Event('overlay-check')) } } }>
<span class='rounded d-flex'> <span class='rounded d-flex'>
<slot active={page === _page}>{icon}</slot> <slot active={page === _page}>{icon}</slot>
</span> </span>

View file

@ -0,0 +1,230 @@
<script context='module'>
import { generateRandomString } from "@/modules/util.js"
import { get, writable } from 'simple-store-svelte'
import { swapProfiles, alToken, malToken, profiles } from '@/modules/settings.js'
import { platformMap } from '@/views/Settings/Settings.svelte'
import { clientID } from "@/modules/myanimelist.js"
import { click } from '@/modules/click.js'
import { toast } from 'svelte-sonner'
import { LogOut, Plus } from 'lucide-svelte'
import IPC from "@/modules/ipc"
export const profileView = writable(false)
const profileAdd = writable(false)
const currentProfile = writable(alToken || malToken)
profiles.subscribe(() => {
currentProfile.set(alToken || malToken)
})
function isAniProfile (profile) {
return profile.viewer?.data?.Viewer?.avatar
}
function currentLogout () {
swapProfiles(null)
location.reload()
}
function dropProfile (profile) {
profiles.update(profiles => {
return profiles.filter(p => p.viewer.data.Viewer.id !== profile.viewer?.data?.Viewer.id)
})
}
function switchProfile (profile) {
swapProfiles(profile)
location.reload()
}
function toggleSync(profile) {
const mainProfile = get(currentProfile)
if (profile.viewer.data.Viewer.id === mainProfile.viewer.data.Viewer.id) {
mainProfile.viewer.data.Viewer.sync = !mainProfile.viewer.data.Viewer.sync
localStorage.setItem(isAniProfile(mainProfile) ? 'ALviewer' : 'MALviewer', JSON.stringify(mainProfile))
} else {
profiles.update(profiles => {
return profiles.map(p => {
if (p.viewer.data.Viewer.id === profile.viewer.data.Viewer.id) {
p.viewer.data.Viewer.sync = !p.viewer.data.Viewer.sync
}
return p
})
})
}
}
function confirmAnilist () {
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=20321&response_type=token') // Change redirect_url to migu://auth/
supportNotify()
}
function confirmMAL () {
const state = generateRandomString(10)
const challenge = generateRandomString(50)
sessionStorage.setItem(state, challenge)
IPC.emit('open', `https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=${clientID}&state=${state}&code_challenge=${challenge}&code_challenge_method=plain`) // Change redirect_url to migu://malauth/
supportNotify()
}
function supportNotify() {
if (platformMap[window.version.platform] === 'Linux') {
toast('Support Notification', {
description: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
duration: 300000
})
}
}
</script>
<script>
let modal
function close () {
$profileView = false
$profileAdd = false
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $profileView && modal?.focus()
</script>
<div class='modal z-101' class:show={$profileView}>
{#if $profileView}
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content w-auto mw-400 d-flex justify-content-center flex-column'>
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> &times; </button>
<div class='d-flex flex-column align-items-center'>
{#if $currentProfile}
<img class='h-150 rounded-circle' src={$currentProfile.viewer.data.Viewer.avatar?.large || $currentProfile.viewer.data.Viewer.avatar?.medium || $currentProfile.viewer.data.Viewer.picture} alt='Current Profile' title='Current Profile'>
<img class='h-3 auth-icon rounded-circle' src={isAniProfile($currentProfile) ? './anilist_icon.png' : './myanimelist_icon.png'} alt={isAniProfile($currentProfile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'} title={isAniProfile($currentProfile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'}>
<p class='font-size-18 font-weight-bold'>{$currentProfile.viewer.data.Viewer.name}</p>
{/if}
</div>
{#if $profiles.length > 0}
<div class='info box pointer border-0 rounded-top-10 pt-10 pb-10 d-flex align-items-center justify-content-center text-center font-weight-bold'>
Other Profiles
</div>
{/if}
<div class='d-flex flex-column align-items-start'>
{#each $profiles as profile}
<button type='button' class='profile-item box text-left pointer border-0 d-flex align-items-center justify-content-between position-relative flex-wrap' on:click={() => switchProfile(profile)}>
<div class='d-flex align-items-center flex-wrap'>
<img class='h-50 ml-10 mt-5 mb-5 mr-10 rounded-circle bg-transparent' src={profile.viewer.data.Viewer.avatar?.large || profile.viewer.data.Viewer.avatar?.medium || profile.viewer.data.Viewer.picture} alt={profile.viewer.data.Viewer.name}>
<img class='ml-5 auth-icon rounded-circle' src={isAniProfile(profile) ? './anilist_icon.png' : './myanimelist_icon.png'} alt={isAniProfile(profile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'} title={isAniProfile(profile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'}>
<p class='text-wrap'>{profile.viewer.data.Viewer.name}</p>
</div>
<div class='controls d-flex align-items-center flex-wrap ml-10'>
<button type='button' class='custom-switch bg-transparent border-0' title='Sync List Entries' on:click|stopPropagation>
<input type='checkbox' id='sync-{profile.viewer.data.Viewer.id}' bind:checked={profile.viewer.data.Viewer.sync} on:click={() => toggleSync(profile)} />
<label for='sync-{profile.viewer.data.Viewer.id}'><br/></label>
</button>
<button type='button' class='button logout pt-5 pb-5 pl-5 pr-5 bg-transparent border-0 d-flex align-items-center justify-content-center' title='Logout' on:click|stopPropagation={() => dropProfile(profile)}>
<LogOut size='2.2rem' />
</button>
</div>
</button>
{/each}
{#if ($profileAdd || (!$currentProfile && $profiles.length <= 0)) && $profiles.length < 10}
<div class='modal-buttons box pointer border-0 info d-flex flex-column {$profiles.length > 0 ? "" : "rounded-top-10"} {$currentProfile || $profiles.length > 0 ? "align-items-center" : "bg-transparent"}'>
{#if !$currentProfile && $profiles.length <= 0}
<h5 class='modal-title'>Log In</h5>
{/if}
<div class='modal-button mb-10 d-flex justify-content-center flex-row {$currentProfile || $profiles.length > 0 ? "mt-10" : ""}'>
<img class='al-logo position-absolute rounded pointer-events-none' src='./anilist_logo.png' alt='logo' />
<button class='btn anilist w-150' type='button' on:click={confirmAnilist}></button>
</div>
<div class='modal-button mb-10 d-flex justify-content-center flex-row'>
<img class='mal-logo position-absolute rounded pointer-events-none' src='./myanimelist_logo.png' alt='logo' />
<button class='btn myanimelist w-150' type='button' on:click={confirmMAL}></button>
</div>
</div>
{:else if $profiles.length < 10}
<button type='button' class='box pointer border-0 pt-10 pb-10 d-flex align-items-center justify-content-center text-center {$profiles.length > 0 && $currentProfile ? "" : !$currentProfile ? "rounded-bottom-10" : "rounded-top-10"}' on:click={() => { $profileAdd = true }}>
<Plus class='mr-10' size='2.2rem' />
<div class='mt-2'>
Add Profile
</div>
</button>
{/if}
{#if $currentProfile}
{#if $profiles.length > 0}
<div class='box pointer border-0 pt-10 pb-10 d-flex align-items-center justify-content-center text-center'>
<div class='custom-switch' title='Must be enabled to sync list entries with other sync enabled profiles.'>
<input type='checkbox' id='sync-{$currentProfile.viewer.data.Viewer.id}' bind:checked={$currentProfile.viewer.data.Viewer.sync} on:click={() => toggleSync($currentProfile)} />
<label for='sync-{$currentProfile.viewer.data.Viewer.id}'>Sync Entries</label>
</div>
</div>
{/if}
<button type='button' class='box pointer border-0 rounded-bottom-10 pt-10 pb-10 d-flex align-items-center justify-content-center text-center' on:click={currentLogout}>
<LogOut class='mr-10' size='2.2rem' />
<div class='mt-2'>
Sign Out
</div>
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
.close {
top: 4rem !important;
left: unset !important;
right: 2.5rem !important;
}
.logout:hover {
background: #393838 !important;
}
.h-3 {
height: 3rem !important;
}
.mt-2 {
margin-top: .4rem;
}
.mw-400 {
min-width: 35rem;
}
.rounded-top-10 {
border-radius: 3rem 3rem 0 0;
}
.rounded-bottom-10 {
border-radius: 0 0 3rem 3rem;
}
.auth-icon {
position: absolute;
height: 2rem;
margin-right: 15rem;
margin-bottom: 3rem;
}
.box:hover:not(.info) {
background: #272727;
}
.box {
background: #0e0e0e;
width: 100%;
margin-bottom: .3rem;
}
.mal-logo {
height: 2rem;
margin-top: 0.8rem;
}
.al-logo {
height: 1.6rem;
margin-top: 0.82rem;
}
.anilist {
background-color: #283342 !important;
}
.myanimelist {
background-color: #2C51A2 !important;
}
.anilist:hover {
background-color: #46536c !important;
}
.myanimelist:hover {
background-color: #2861d6 !important;
}
</style>

View file

@ -1,9 +1,11 @@
<script context='module'> <script context='module'>
const badgeKeys = ['search', 'genre', 'season', 'year', 'format', 'status', 'sort'] const badgeKeys = ['title', 'search', 'genre', 'tag', 'season', 'year', 'format', 'status', 'sort', 'hideSubs', 'hideMyAnime', 'hideStatus']
const badgeDisplayNames = { title: BookUser, search: Type, genre: Drama, tag: Hash, season: CalendarRange, year: Leaf, format: Tv, status: MonitorPlay, sort: ArrowDownWideNarrow, hideMyAnime: SlidersHorizontal, hideSubs: Mic }
const sortOptions = { START_DATE_DESC: 'Release Date', SCORE_DESC: 'Score', POPULARITY_DESC: 'Popularity', TRENDING_DESC: 'Trending', UPDATED_AT_DESC: 'Updated Date', UPDATED_TIME_DESC: 'Last Updated', STARTED_ON_DESC: 'Started On', FINISHED_ON_DESC: 'Finished On', PROGRESS_DESC: 'Your Progress', USER_SCORE_DESC: 'Your Score' }
export function searchCleanup (search) { export function searchCleanup (search, badge) {
return Object.fromEntries(Object.entries(search).map((entry) => { return Object.fromEntries(Object.entries(search).map((entry) => {
return badgeKeys.includes(entry[0]) && entry return (!badge || badgeKeys.includes(entry[0])) && entry
}).filter(a => a?.[1])) }).filter(a => a?.[1]))
} }
</script> </script>
@ -14,30 +16,313 @@
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import { page } from '@/App.svelte' import { page } from '@/App.svelte'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import Helper from '@/modules/helper.js'
import { MagnifyingGlass, Image } from 'svelte-radix' import { MagnifyingGlass, Image } from 'svelte-radix'
import { Type, Drama, Leaf, MonitorPlay, Tv, ArrowDownWideNarrow, Trash2, Tags, Grid3X3, Grid2X2 } from 'lucide-svelte' import { BookUser, Type, Drama, Leaf, CalendarRange, MonitorPlay, Tv, ArrowDownWideNarrow, Filter, FilterX, Tags, Hash, SlidersHorizontal, Mic, Grid3X3, Grid2X2 } from 'lucide-svelte'
export let search export let search
let searchTextInput let searchTextInput = {
title: null,
genre: null,
tag: null
}
let form let form
$: sanitisedSearch = Object.values(searchCleanup(search)) const genreList = [
'Action',
'Adventure',
'Comedy',
'Drama',
'Ecchi',
'Fantasy',
'Horror',
'Mahou Shoujo',
'Mecha',
'Music',
'Mystery',
'Psychological',
'Romance',
'Sci-Fi',
'Slice of Life',
'Sports',
'Supernatural',
'Thriller'
]
const tagList = [
'Chuunibyou',
'Demons',
'Food',
'Heterosexual',
'Isekai',
'Iyashikei',
'Josei',
'Magic',
'Yuri',
'Love Triangle',
'Female Harem',
'Male Harem',
'Mixed Gender Harem',
'Arranged Marriage',
'Marriage',
'Martial Arts',
'Military',
'Nudity',
'Parody',
'Reincarnation',
'Satire',
'School',
'Seinen',
'Shoujo',
'Shounen',
'Slavery',
'Space',
'Super Power',
'Superhero',
'Teens\' Love',
'Unrequited Love',
'Vampire',
'Kids',
'Gender Bending',
'Body Swapping',
'Boys\' Love',
'Cute Boys Doing Cute Things',
'Cute Girls Doing Cute Things',
'Acting',
'Afterlife',
'Age Gap',
'Age Regression',
'Aliens',
'Alternate Universe',
'Amnesia',
'Angels',
'Anti-Hero',
'Archery',
'Artificial Intelligence',
'Assassins',
'Asexual',
'Augmented Reality',
'Band',
'Bar',
'Battle Royale',
'Board Game',
'Boarding School',
'Bullying',
'Calligraphy',
'CGI',
'Classic Literature',
'College',
'Cosplay',
'Crime',
'Crossdressing',
'Cult',
'Dancing',
'Death Game',
'Desert',
'Disability',
'Drawing',
'Dragons',
'Dungeon',
'Elf',
'Espionage',
'Fairy',
'Femboy',
'Female Protagonist',
'Fashion',
'Foreign',
'Full CGI',
'Fugitive',
'Gambling',
'Ghost',
'Gods',
'Goblin',
'Guns',
'Gyaru',
'Hikikomori',
'Historical',
'Homeless',
'Idol',
'Inn',
'Kaiju',
'Konbini',
'Kuudere',
'Language Barrier',
'Makeup',
'Maids',
'Male Protagonist',
'Matriarchy',
'Matchmaking',
'Mermaid',
'Monster Boy',
'Monster Girl',
'Natural Disaster',
'Necromancy',
'Ninja',
'Nun',
'Office',
'Office Lady',
'Omegaverse',
'Orphan',
'Outdoor',
'Photography',
'Pirates',
'Polyamorous',
'Post-Apocalyptic',
'Primarily Adult Cast',
'Primarily Female Cast',
'Primarily Male Cast',
'Primarily Teen Cast',
'Prison',
'Rakugo',
'Restaurant',
'Robots',
'Rural',
'Samurai',
'School Club',
'Shapeshifting',
'Shrine Maiden',
'Skeleton',
'Slapstick',
'Snowscape',
'Space',
'Spearplay',
'Succubus',
'Surreal Comedy',
'Survival',
'Swordplay',
'Teacher',
'Time Loop',
'Time Manipulation',
'Time Skip',
'Transgender',
'Tsundere',
'Twins',
'Urban',
'Urban Fantasy',
'Video Games',
'Villainess',
'Virtual World',
'VTuber',
'War',
'Werewolf',
'Witch',
'Work',
'Writing',
'Wuxia',
'Yakuza',
'Yandere',
'Youkai',
'Zombie'
]
let filteredTags = []
$: {
const searchInput = (searchTextInput.tag ? searchTextInput.tag.toLowerCase() : null)
filteredTags = tagList.filter(tag =>
(!search.tag || !search.tag.includes(tag)) && (!searchInput ||
tag.toLowerCase().includes(searchInput))
).slice(0, 20)
}
$: sanitisedSearch = Object.entries(searchCleanup(search, true)).flatMap(
([key, value]) => {
if (Array.isArray(value)) {
return value.map((item) => ({ key, value: item }))
} else {
return [{ key, value }]
}
}
)
function searchClear() { function searchClear() {
search = { search = {
title: '',
search: '', search: '',
genre: '', genre: '',
tag: '',
season: '', season: '',
year: null, year: null,
format: '', format: '',
status: '', status: '',
sort: '' sort: '',
hideSubs: false,
hideMyAnime: false,
hideStatus: ''
} }
searchTextInput.focus() searchTextInput.title.focus()
form.dispatchEvent(new Event('input', { bubbles: true })) form.dispatchEvent(new Event('input', { bubbles: true }))
$page = 'search' $page = 'search'
} }
function getSortDisplayName(value) {
return sortOptions[value] || value
}
function removeBadge(badge) {
if (badge.key === 'title') {
delete search.load
delete search.disableHide
delete search.userList
delete search.continueWatching
delete search.completedList
if (Helper.isUserSort(search)) {
search.sort = ''
}
} else if ((badge.key === 'genre' || badge.key === 'tag') && !search.userList) {
delete search.title
} else if (badge.key === 'hideMyAnime') {
delete search.hideStatus
}
if (Array.isArray(search[badge.key])) {
search[badge.key] = search[badge.key].filter(
(item) => item !== badge.value
)
if (search[badge.key].length === 0) {
search[badge.key] = ''
}
} else {
search[badge.key] = ''
}
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function toggleHideMyAnime() {
search.hideMyAnime = !search.hideMyAnime
search.hideStatus = search.hideMyAnime ? ['CURRENT', 'COMPLETED', 'DROPPED', 'PAUSED', 'REPEATING'] : ''
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function toggleSubs() {
search.hideSubs = !search.hideSubs
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function filterTags(event, type, trigger) {
const list = type === 'tag' ? tagList : genreList
const searchKey = type === 'tag' ? 'tag' : 'genre'
const inputValue = event.target.value
let bestMatch = list.find(item => item.toLowerCase() === inputValue.toLowerCase())
if ((trigger === 'keydown' && (event.key === 'Enter' || event.code === 'Enter')) || (trigger === 'input' && bestMatch)) {
if (!bestMatch || inputValue.endsWith('*')) {
bestMatch = (inputValue.endsWith('*') && inputValue.slice(0, -1)) || list.find(item => item.toLowerCase().startsWith(inputValue.toLowerCase())) || list.find(item => item.toLowerCase().endsWith(inputValue.toLowerCase()))
}
if (bestMatch && (!search[searchKey] || !search[searchKey].includes(bestMatch))) {
search[searchKey] = search[searchKey] ? [...search[searchKey], bestMatch] : [bestMatch]
searchTextInput[searchKey] = null
setTimeout(() => {
form.dispatchEvent(new Event('input', {bubbles: true}))
}, 0);
}
}
}
function clearTags() { // cannot specify genre and tag filtering with user specific sorting options when using alternative authentication.
if (!Helper.isAniAuth() && Helper.isUserSort(search)) {
search.genre = ''
search.tag = ''
}
}
function handleFile({ target }) { function handleFile({ target }) {
const { files } = target const { files } = target
if (files?.[0]) { if (files?.[0]) {
@ -45,11 +330,13 @@
description: 'You can also paste an URL to an image.', description: 'You can also paste an URL to an image.',
loading: 'Looking up anime for image...', loading: 'Looking up anime for image...',
success: 'Found anime for image!', success: 'Found anime for image!',
error: 'Couldn\'t find anime for specified image! Try to remove black bars, or use a more detailed image.' error:
'Couldn\'t find anime for specified image! Try to remove black bars, or use a more detailed image.'
}) })
target.value = null target.value = null
} }
} }
function changeCardMode(type) { function changeCardMode(type) {
$settings.cards = type $settings.cards = type
form.dispatchEvent(new Event('input', { bubbles: true })) form.dispatchEvent(new Event('input', { bubbles: true }))
@ -57,18 +344,18 @@
</script> </script>
<form class='container-fluid py-20 px-md-50 bg-dark pb-0 position-sticky top-0 search-container z-40' on:input bind:this={form}> <form class='container-fluid py-20 px-md-50 bg-dark pb-0 position-sticky top-0 search-container z-40' on:input bind:this={form}>
<div class='row' style="padding-top: var(--safe-area-top)"> <div class='row'>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'> <div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Type class='mr-10' size='3rem' /> <Type class='mr-10' size='3rem' />
Title <div>Title</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<div class='input-group-prepend'> <div class='input-group-prepend'>
<MagnifyingGlass size='2.75rem' class='input-group-text bg-dark-light pr-0' /> <MagnifyingGlass size='2.75rem' class='input-group-text bg-dark-light pr-0' />
</div> </div>
<input <input
bind:this={searchTextInput} bind:this={searchTextInput.title}
type='search' type='search'
class='form-control bg-dark-light border-left-0 text-capitalize' class='form-control bg-dark-light border-left-0 text-capitalize'
autocomplete='off' autocomplete='off'
@ -81,36 +368,61 @@
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'> <div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Drama class='mr-10' size='3rem' /> <Drama class='mr-10' size='3rem' />
Genre <div>Genres</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.genre} disabled={search.disableSearch}> <input
<option value selected>Any</option> id='genre'
<option value='Action'>Action</option> type='search'
<option value='Adventure'>Adventure</option> title={(!Helper.isAniAuth() && Helper.isUserSort(search)) ? 'Cannot use with sort: ' + sortOptions[search.sort] : ''}
<option value='Comedy'>Comedy</option> class='form-control bg-dark-light border-left-0 text-capitalize'
<option value='Drama'>Drama</option> autocomplete='off'
<option value='Ecchi'>Ecchi</option> bind:value={searchTextInput.genre}
<option value='Fantasy'>Fantasy</option> on:keydown={(event) => filterTags(event, 'genre', 'keydown')}
<option value='Horror'>Horror</option> on:input={(event) => filterTags(event, 'genre', 'input')}
<option value='Mahou Shoujo'>Mahou Shoujo</option> data-option='search'
<option value='Mecha'>Mecha</option> disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
<option value='Music'>Music</option> placeholder='Any'
<option value='Mystery'>Mystery</option> list='search-genre'/>
<option value='Psychological'>Psychological</option>
<option value='Romance'>Romance</option>
<option value='Sci-Fi'>Sci-Fi</option>
<option value='Slice of Life'>Slice of Life</option>
<option value='Sports'>Sports</option>
<option value='Supernatural'>Supernatural</option>
<option value='Thriller'>Thriller</option>
</select>
</div> </div>
<datalist id='search-genre'>
{#each genreList as genre}
{#if !search.genre || !search.genre.includes(genre) }
<option>{genre}</option>
{/if}
{/each}
</datalist>
</div> </div>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'> <div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Leaf class='mr-10' size='3rem' /> <Hash class='mr-10' size='3rem' />
Season <div>Tags</div>
</div>
<div class='input-group'>
<input
id='tag'
type='search'
title={(!Helper.isAniAuth() && Helper.isUserSort(search)) ? 'Cannot use with sort: ' + sortOptions[search.sort] : ''}
class='form-control bg-dark-light border-left-0 text-capitalize'
autocomplete='off'
bind:value={searchTextInput.tag}
on:keydown={(event) => filterTags(event, 'tag', 'keydown')}
on:input={(event) => filterTags(event, 'tag', 'input')}
data-option='search'
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
placeholder='Any'
list='search-tag'/>
</div>
<datalist id='search-tag'>
{#each filteredTags as tag}
<option>{tag}</option>
{/each}
</datalist>
</div>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<CalendarRange class='mr-10' size='3rem' />
<div>Season</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<select class='form-control bg-dark-light border-right-dark' required bind:value={search.season} disabled={search.disableSearch}> <select class='form-control bg-dark-light border-right-dark' required bind:value={search.season} disabled={search.disableSearch}>
@ -132,7 +444,7 @@
<div class='col p-10 d-flex flex-column justify-content-end'> <div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Tv class='mr-10' size='3rem' /> <Tv class='mr-10' size='3rem' />
Format <div>Format</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.format} disabled={search.disableSearch}> <select class='form-control bg-dark-light' required bind:value={search.format} disabled={search.disableSearch}>
@ -148,14 +460,14 @@
<div class='col p-10 d-flex flex-column justify-content-end'> <div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<MonitorPlay class='mr-10' size='3rem' /> <MonitorPlay class='mr-10' size='3rem' />
Status <div>Status</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.status} disabled={search.disableSearch}> <select class='form-control bg-dark-light' required bind:value={search.status} disabled={search.disableSearch}>
<option value selected>Any</option> <option value selected>Any</option>
<option value='RELEASING'>Airing</option> <option value='RELEASING'>Releasing</option>
<option value='FINISHED'>Finished</option> <option value='FINISHED'>Finished</option>
<option value='NOT_YET_RELEASED'>Not Yet Aired</option> <option value='NOT_YET_RELEASED'>Not Yet Released</option>
<option value='CANCELLED'>Cancelled</option> <option value='CANCELLED'>Cancelled</option>
</select> </select>
</div> </div>
@ -163,23 +475,63 @@
<div class='col p-10 d-flex flex-column justify-content-end'> <div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'> <div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<ArrowDownWideNarrow class='mr-10' size='3rem' /> <ArrowDownWideNarrow class='mr-10' size='3rem' />
Sort <div>Sort</div>
</div> </div>
<div class='input-group'> <div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.sort} disabled={search.disableSearch}> <select class='form-control bg-dark-light' required bind:value={search.sort} on:change={clearTags} disabled={search.disableSearch}>
<option value selected>Name</option> <option value selected>Name</option>
<option value='START_DATE_DESC'>Release Date</option> <option value='START_DATE_DESC'>Release Date</option>
<option value='SCORE_DESC'>Score</option> <option value='SCORE_DESC'>Score</option>
<option value='POPULARITY_DESC'>Popularity</option> <option value='POPULARITY_DESC'>Popularity</option>
<option value='TRENDING_DESC'>Trending</option> <option value='TRENDING_DESC'>Trending</option>
<option value='UPDATED_AT_DESC'>Updated Date</option> <option value='UPDATED_AT_DESC'>Updated Date</option>
{#if search.userList && search.title && !search.title.includes("Sequels")}
<option value='UPDATED_TIME_DESC'>Last Updated</option>
<option value='STARTED_ON_DESC'>Started On</option>
{#if search.completedList}
<option value='FINISHED_ON_DESC'>Finished On</option>
<option value='USER_SCORE_DESC'>Your Score</option>
{:else}
<option value='PROGRESS_DESC'>Your Progress</option>
{/if}
{/if}
</select> </select>
</div> </div>
</div> </div>
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button
class='btn btn-square bg-dark-light px-5 align-self-end border-0'
type='button'
title='Hide My Anime'
use:click={toggleHideMyAnime}
disabled={search.disableHide || search.disableSearch || !Helper.isAuthorized()}
class:text-primary={search.hideMyAnime}>
<label for='hide-my-anime' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<SlidersHorizontal size='1.625rem' />
</label>
</button>
</div>
</div>
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button
class='btn btn-square bg-dark-light px-5 align-self-end border-0'
type='button'
title='Dubbed Audio'
use:click={toggleSubs}
disabled={search.disableSearch}
class:text-primary={search.hideSubs}>
<label for='hide-subs' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<Mic size='1.625rem' />
</label>
</button>
</div>
</div>
<input type='file' class='d-none' id='search-image' accept='image/*' on:input|preventDefault|stopPropagation={handleFile} /> <input type='file' class='d-none' id='search-image' accept='image/*' on:input|preventDefault|stopPropagation={handleFile} />
<div class='col-auto p-10 d-flex'> <div class='col-auto p-10 d-flex'>
<div class='align-self-end'> <div class='align-self-end'>
<button class='btn btn-square bg-dark-light px-5 align-self-end border-0' type='button'> <button class='btn btn-square bg-dark-light px-5 align-self-end border-0' type='button' title='Image Search'>
<label for='search-image' class='pointer mb-0 d-flex align-items-center justify-content-center'> <label for='search-image' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<Image size='1.625rem' /> <Image size='1.625rem' />
</label> </label>
@ -188,19 +540,41 @@
</div> </div>
<div class='col-auto p-10 d-flex'> <div class='col-auto p-10 d-flex'>
<div class='align-self-end'> <div class='align-self-end'>
<button class='btn btn-square bg-dark-light d-flex align-items-center justify-content-center px-5 align-self-end border-0' type='button' use:click={searchClear} class:text-primary={!!sanitisedSearch?.length || search.disableSearch || search.clearNext}> <button class='btn btn-square bg-dark-light d-flex align-items-center justify-content-center px-5 align-self-end border-0' type='button' use:click={searchClear} disabled={sanitisedSearch.length <= 0} class:text-danger={!!sanitisedSearch?.length || search.disableSearch || search.clearNext}>
<Trash2 size='1.625rem' /> {#if !!sanitisedSearch?.length || search.disableSearch || search.clearNext}
<FilterX size='1.625rem' />
{:else}
<Filter size='1.625rem' />
{/if}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class='w-full px-10 pt-10 h-50 d-flex flex-colum align-items-center'> <div class='w-full px-10 pt-10 h-50 d-flex flex-colum align-items-center'>
<form>
<div role="button" tabindex="0">
{#if sanitisedSearch?.length} {#if sanitisedSearch?.length}
{@const filteredBadges = sanitisedSearch.filter(badge => badge.key !== 'hideStatus' && (search.userList || badge.key !== 'title'))}
<div class='d-flex flex-row align-items-center'>
{#if filteredBadges.length > 0}
<Tags class='text-dark-light mr-20' size='3rem' /> <Tags class='text-dark-light mr-20' size='3rem' />
{#each sanitisedSearch as badge}
<span class='badge bg-light border-0 py-5 px-10 text-capitalize mr-20 text-white text-nowrap'>{('' + badge).replace(/_/g, ' ').toLowerCase()}</span>
{/each}
{/if} {/if}
{#each badgeKeys as key}
{@const matchingBadges = filteredBadges.filter(badge => badge.key === key)}
{#each matchingBadges as badge}
{#if badge.key === key && (badge.key !== 'hideStatus' && (search.userList || badge.key !== 'title')) }
<div class='badge bg-light border-0 py-5 px-10 text-capitalize mr-20 text-white text-nowrap d-flex align-items-center'>
<svelte:component this={badgeDisplayNames[badge.key]} class='mr-5' size='1.8rem' />
<div class='font-size-12'>{badge.key === 'sort' ? getSortDisplayName(badge.value) : (badge.key === 'hideMyAnime' ? 'Hide My Anime' : badge.key === 'hideSubs' ? 'Dubbed' : ('' + badge.value).replace(/_/g, ' ').toLowerCase())}</div>
<button on:click={() => removeBadge(badge)} class='pointer bg-transparent border-0 text-white font-size-12 position-relative ml-10 pt-0' title='Remove Filter' type='button'>x</button>
</div>
{/if}
{/each}
{/each}
</div>
{/if}
</div>
</form>
<span class='mr-10 filled ml-auto text-dark-light pointer' class:text-muted={$settings.cards === 'small'} use:click={() => changeCardMode('small')}><Grid3X3 size='2.25rem' /></span> <span class='mr-10 filled ml-auto text-dark-light pointer' class:text-muted={$settings.cards === 'small'} use:click={() => changeCardMode('small')}><Grid3X3 size='2.25rem' /></span>
<span class='text-dark-light pointer' class:text-muted={$settings.cards === 'full'} use:click={() => changeCardMode('full')}><Grid2X2 size='2.25rem' /></span> <span class='text-dark-light pointer' class:text-muted={$settings.cards === 'full'} use:click={() => changeCardMode('full')}><Grid2X2 size='2.25rem' /></span>
</div> </div>

View file

@ -1,54 +1,39 @@
<script> <script>
import { getContext } from 'svelte' import { getContext } from 'svelte'
import { anilistClient } from '@/modules/anilist.js' import { media } from '@/views/Player/MediaHandler.svelte'
import { media } from '../views/Player/MediaHandler.svelte'
import { platformMap } from '@/views/Settings/Settings.svelte'
import { settings } from '@/modules/settings.js' import { settings } from '@/modules/settings.js'
import { toast } from 'svelte-sonner' // import { toast } from 'svelte-sonner'
import { logout } from './Logout.svelte' import { profileView } from './Profiles.svelte'
import IPC from '@/modules/ipc.js' import Helper from '@/modules/helper.js'
// import IPC from '@/modules/ipc.js'
import SidebarLink from './SidebarLink.svelte' import SidebarLink from './SidebarLink.svelte'
import { Clock, Download, Heart, Home, ListVideo, LogIn, Settings, Users } from 'lucide-svelte' import { Clock, Download, Heart, Home, ListVideo, LogIn, Settings, Users } from 'lucide-svelte'
import { MagnifyingGlass } from 'svelte-radix' import { MagnifyingGlass } from 'svelte-radix'
let updateState = '' // let updateState = ''
IPC.on('update-available', () => { // IPC.on('update-available', () => {
updateState = 'downloading' // updateState = 'downloading'
}) // })
IPC.on('update-downloaded', () => { // IPC.on('update-downloaded', () => {
updateState = 'ready' // updateState = 'ready'
}) // })
const view = getContext('view') const view = getContext('view')
export let page export let page
function handleAlLogin () {
if (anilistClient.userID?.viewer?.data?.Viewer) {
$logout = true
} else {
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=20321&response_type=token') // Change redirect_url to migu://auth/
if (platformMap[window.version.platform] === 'Linux') {
toast('Support Notification', {
description: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
duration: 300000
})
}
}
}
</script> </script>
<div class='sidebar z-30 d-md-block' class:animated={$settings.expandingSidebar}> <div class='sidebar z-30 d-md-block' class:animated={$settings.expandingSidebar}>
<div class='sidebar-overlay pointer-events-none h-full position-absolute' /> <div class='sidebar-overlay pointer-events-none h-full position-absolute' />
<div class='sidebar-menu h-full d-flex flex-column justify-content-center align-items-center m-0 pb-5' class:animate={page !== 'player'}> <div class='sidebar-menu h-full d-flex flex-column justify-content-center align-items-center m-0 pb-5' class:animate={page !== 'player'}>
<SidebarLink click={handleAlLogin} icon='login' text={anilistClient.userID?.viewer?.data?.Viewer ? 'Logout' : 'Login With AniList'} css='mt-auto' {page} image={anilistClient.userID?.viewer?.data?.Viewer?.avatar.medium}> <SidebarLink click={() => { $profileView = true }} icon='login' text={Helper.getUser() ? 'Profiles' : 'Login'} css='mt-auto' {page} image={Helper.getUserAvatar()}>
<LogIn size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' /> <LogIn size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' />
</SidebarLink> </SidebarLink>
<SidebarLink click={() => { page = 'home' }} _page='home' text='Home' {page} let:active> <SidebarLink click={() => { page = 'home'; if ($view) $view = null }} _page='home' text='Home' {page} let:active>
<Home size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' strokeWidth={active ? '3.5' : '2'} /> <Home size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' strokeWidth={active ? '3.5' : '2'} />
</SidebarLink> </SidebarLink>
<SidebarLink click={() => { page = 'search' }} _page='search' text='Search' {page} let:active> <SidebarLink click={() => { page = 'search'; if ($view) $view = null }} _page='search' text='Search' {page} let:active>
<MagnifyingGlass size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' stroke-width={active ? '2' : '0'} stroke='currentColor' /> <MagnifyingGlass size='2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' stroke-width={active ? '2' : '0'} stroke='currentColor' />
</SidebarLink> </SidebarLink>
<SidebarLink click={() => { page = 'schedule' }} _page='schedule' icon='schedule' text='Schedule' {page} let:active> <SidebarLink click={() => { page = 'schedule' }} _page='schedule' icon='schedule' text='Schedule' {page} let:active>
@ -118,6 +103,9 @@
.sidebar.animated:hover { .sidebar.animated:hover {
width: 22rem width: 22rem
} }
.sidebar.animated {
z-index: 60 !important;
}
.sidebar-overlay { .sidebar-overlay {
width: var(--sidebar-width); width: var(--sidebar-width);
transition: width .8s cubic-bezier(0.25, 0.8, 0.25, 1), left .8s cubic-bezier(0.25, 0.8, 0.25, 1) !important; transition: width .8s cubic-bezier(0.25, 0.8, 0.25, 1), left .8s cubic-bezier(0.25, 0.8, 0.25, 1) !important;

View file

@ -12,7 +12,7 @@
</script> </script>
<div class='sidebar-link sidebar-link-with-icon pointer overflow-hidden {css}' <div class='sidebar-link sidebar-link-with-icon pointer overflow-hidden {css}'
use:click={_click}> use:click={() => { _click(); if (!icon.includes("login") && !icon.includes("favorite")) { window.dispatchEvent(new Event('overlay-check')) } } }>
<span class='text-nowrap d-flex align-items-center w-full h-full'> <span class='text-nowrap d-flex align-items-center w-full h-full'>
{#if image} {#if image}
<span class='rounded d-flex'> <span class='rounded d-flex'>

View file

@ -1,24 +1,16 @@
<script> <script>
import { formatMap, setStatus, playMedia } from '@/modules/anime.js' import { formatMap, playMedia } from '@/modules/anime.js'
import { anilistClient } from '@/modules/anilist.js' import { anilistClient } from '@/modules/anilist.js'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import { alToken } from '@/modules/settings.js' import Scoring from '@/views/ViewAnime/Scoring.svelte'
import { Bookmark, Heart } from 'lucide-svelte' import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import Helper from "@/modules/helper.js"
import { Heart } from 'lucide-svelte'
export let mediaList export let mediaList
let current = mediaList[0] let current = mediaList[0]
async function toggleStatus () {
if (!current.mediaListEntry) {
// add
const res = await setStatus('PLANNING', {}, current)
current.mediaListEntry = res.data.SaveMediaListEntry
} else {
// delete
anilistClient.delete({ id: current.mediaListEntry.id })
current.mediaListEntry = undefined
}
}
function toggleFavourite () { function toggleFavourite () {
anilistClient.favourite({ id: current.id }) anilistClient.favourite({ id: current.id })
current.isFavourite = !current.isFavourite current.isFavourite = !current.isFavourite
@ -46,13 +38,13 @@
</script> </script>
{#key current} {#key current}
<img src={current.bannerImage || `https://i.ytimg.com/vi/${current.trailer?.id}/maxresdefault.jpg` || ''} alt='banner' class='img-cover w-full h-full position-absolute' /> <img src={current.bannerImage || (current.trailer?.id ? `https://i.ytimg.com/vi/${current.trailer?.id}/maxresdefault.jpg` : current.coverImage?.extraLarge || ' ')} alt='banner' class='img-cover w-full h-full position-absolute' />
{/key} {/key}
<div class='gradient-bottom h-full position-absolute top-0 w-full' /> <div class='gradient-bottom h-full position-absolute top-0 w-full' />
<div class='gradient-left h-full position-absolute top-0 w-800' /> <div class='gradient-left h-full position-absolute top-0 w-800' />
<div class='pl-20 pb-20 justify-content-end d-flex flex-column h-full banner mw-full'> <div class='pl-20 pb-20 justify-content-end d-flex flex-column h-full banner mw-full'>
<div class='text-white font-weight-bold font-size-40 title w-800 mw-full overflow-hidden'> <div class='text-white font-weight-bold font-size-40 title w-800 mw-full overflow-hidden'>
{current.title.userPreferred} {anilistClient.title(current)}
</div> </div>
<div class='details text-white text-capitalize pt-15 pb-10 d-flex w-600 mw-full'> <div class='details text-white text-capitalize pt-15 pb-10 d-flex w-600 mw-full'>
<span class='text-nowrap d-flex align-items-center'> <span class='text-nowrap d-flex align-items-center'>
@ -73,6 +65,14 @@
{current.duration + ' Minutes'} {current.duration + ' Minutes'}
</span> </span>
{/if} {/if}
<span class='text-nowrap d-flex align-items-center'>
<AudioLabel media={current} banner={true}/>
</span>
{#if current.isAdult}
<span class='text-nowrap d-flex align-items-center'>
Rated 18+
</span>
{/if}
{#if current.season || current.seasonYear} {#if current.season || current.seasonYear}
<span class='text-nowrap d-flex align-items-center'> <span class='text-nowrap d-flex align-items-center'>
{[current.season?.toLowerCase(), current.seasonYear].filter(s => s).join(' ')} {[current.season?.toLowerCase(), current.seasonYear].filter(s => s).join(' ')}
@ -94,12 +94,10 @@
use:click={() => playMedia(current)}> use:click={() => playMedia(current)}>
Watch Now Watch Now
</button> </button>
<button class='btn bg-dark-light btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!alToken}> <button class='btn bg-dark-light btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!Helper.isAniAuth()}>
<Heart fill={current.isFavourite ? 'currentColor' : 'transparent'} size='1.5rem' /> <Heart fill={current.isFavourite ? 'currentColor' : 'transparent'} size='1.5rem' />
</button> </button>
<button class='btn bg-dark-light btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleStatus} disabled={!alToken}> <Scoring media={current} />
<Bookmark fill={current.mediaListEntry ? 'currentColor' : 'transparent'} size='1.5rem' />
</button>
</div> </div>
<div class='d-flex'> <div class='d-flex'>
{#each mediaList as media} {#each mediaList as media}

View file

@ -10,6 +10,7 @@
export let card export let card
export let variables = null
const type = card.type || $settings.cards const type = card.type || $settings.cards
</script> </script>
@ -29,7 +30,7 @@
<FullSkeletonCard /> <FullSkeletonCard />
{:then media} {:then media}
{#if media} {#if media}
<FullCard media={anilistClient.mediaCache[media.id]} /> <FullCard media={anilistClient.mediaCache[media.id]} {variables} />
{/if} {/if}
{/await} {/await}
@ -39,7 +40,7 @@
<SkeletonCard /> <SkeletonCard />
{:then media} {:then media}
{#if media} {#if media}
<SmallCard media={anilistClient.mediaCache[media.id]} /> <SmallCard media={anilistClient.mediaCache[media.id]} {variables} />
{/if} {/if}
{/await} {/await}

View file

@ -1,30 +1,40 @@
<script> <script>
import { statusColorMap } from '@/modules/anime.js' import { statusColorMap } from '@/modules/anime.js'
import EpisodePreviewCard from './EpisodePreviewCard.svelte' import EpisodePreviewCard from './EpisodePreviewCard.svelte'
import { click, hoverClick } from '@/modules/click.js' import { click, hoverClick, hoverChange } from '@/modules/click.js'
import { since } from '@/modules/util.js' import { since } from '@/modules/util.js'
import { getContext, onMount } from 'svelte' import { getContext, onMount } from 'svelte'
import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js' import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js'
import { anilistClient } from '@/modules/anilist.js' import { anilistClient } from '@/modules/anilist.js'
import { SUPPORTS } from '@/modules/support.js'; import { SUPPORTS } from '@/modules/support.js';
import { Play } from 'lucide-svelte' import { Play } from 'lucide-svelte'
import { writable } from 'svelte/store'
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
export let data export let data
let preview = false let preview = false
let prompt = writable(false)
/** @type {import('@/modules/al.d.ts').Media | null} */ /** @type {import('@/modules/al.d.ts').Media | null} */
const media = data.media && anilistClient.mediaCache[data.media.id] const media = data.media && anilistClient.mediaCache[data.media.id]
const episodeThumbnail = ((!media?.mediaListEntry?.status || !(media.mediaListEntry.status === 'CURRENT' && media.mediaListEntry.progress < data.episode)) && data.episodeData?.image) || media?.bannerImage || media?.coverImage.extraLarge || ' ' const episodeThumbnail = ((!media?.mediaListEntry?.status || !(['CURRENT', 'PAUSED', 'DROPPED'].includes(media.mediaListEntry.status) && media.mediaListEntry.progress < data.episode)) && data.episodeData?.image) || media?.bannerImage || media?.coverImage.extraLarge || ' '
const view = getContext('view') const view = getContext('view')
function viewMedia () { function viewMedia () {
$view = media
}
function setClickState() {
if ($prompt === false && media?.mediaListEntry?.progress < (data.episode - 1)) {
prompt.set(true)
} else {
if (data.onclick) { if (data.onclick) {
if (SUPPORTS.isAndroid) document.querySelector('.content-wrapper').requestFullscreen() if (SUPPORTS.isAndroid) document.querySelector('.content-wrapper').requestFullscreen()
data.onclick() data.onclick()
return } else {
viewMedia()
}
} }
$view = media
} }
function setHoverState (state) { function setHoverState (state) {
preview = state preview = state
@ -39,22 +49,24 @@
onMount(() => { onMount(() => {
if (SUPPORTS.isAndroid){ if (SUPPORTS.isAndroid){
click(thisElement, viewMedia) click(thisElement, setClickState)
} else { } else {
hoverClick(thisElement, [viewMedia, setHoverState]) hoverClick(thisElement, [setClickState, setHoverState])
} }
}) })
const progress = liveAnimeEpisodeProgress(media?.id, data?.episode) const progress = liveAnimeEpisodeProgress(media?.id, data?.episode)
const watched = media?.mediaListEntry?.status === 'COMPLETED'
const completed = !watched && media?.mediaListEntry?.progress >= data?.episode
</script> </script>
<div class='d-flex p-20 pb-10 position-relative episode-card' bind:this={thisElement} on:contextmenu={viewEpisodes} role="none"> <div class='d-flex p-20 pb-10 position-relative episode-card' use:hoverChange={() => prompt.set(false)} bind:this={thisElement} on:contextmenu|preventDefault={viewEpisodes} role='none'>
{#if preview} {#if preview}
{#if !SUPPORTS.isAndroid} {#if !SUPPORTS.isAndroid}
<EpisodePreviewCard {data} /> <EpisodePreviewCard {data} bind:prompt={$prompt} />
{/if} {/if}
{/if} {/if}
<div class='item d-flex flex-column h-full pointer content-visibility-auto'> <div class='item d-flex flex-column h-full pointer content-visibility-auto' class:opacity-half={completed}>
<div class='image h-200 w-full position-relative rounded overflow-hidden d-flex justify-content-between align-items-end text-white' class:bg-black={episodeThumbnail === ' '}> <div class='image h-200 w-full position-relative rounded overflow-hidden d-flex justify-content-between align-items-end text-white' class:bg-black={episodeThumbnail === ' '}>
<img loading='lazy' src={episodeThumbnail} alt='cover' class='cover-img w-full h-full position-absolute' style:--color={media?.coverImage?.color || '#1890ff'} /> <img loading='lazy' src={episodeThumbnail} alt='cover' class='cover-img w-full h-full position-absolute' style:--color={media?.coverImage?.color || '#1890ff'} />
<Play class='mb-5 ml-5 pl-10 pb-10 z-10' fill='currentColor' size='3rem' /> <Play class='mb-5 ml-5 pl-10 pb-10 z-10' fill='currentColor' size='3rem' />
@ -63,9 +75,13 @@
{media.duration}m {media.duration}m
{/if} {/if}
</div> </div>
{#if $progress > 0} {#if completed}
<div class='progress container-fluid position-absolute' style='height: 2px; min-height: 2px;'> <div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style='width: {$progress}%' /> <div class='progress-bar w-full' />
</div>
{:else if $progress > 0}
<div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style='width: {progress}%' />
</div> </div>
{/if} {/if}
</div> </div>
@ -75,28 +91,45 @@
{#if media?.mediaListEntry?.status} {#if media?.mediaListEntry?.status}
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} /> <div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />
{/if} {/if}
{media?.title.userPreferred || data.parseObject.anime_title} {anilistClient.title(media) || data.parseObject.anime_title}
</div> </div>
<div class='text-muted font-size-12 title overflow-hidden'> <div class='text-muted font-size-12 title overflow-hidden'>
{data.episodeData?.title?.en || ''} {data.episodeData?.title?.en || ''}
</div> </div>
</div> </div>
{#if data.episode}
<div class='col-auto d-flex flex-column align-items-end text-right'> <div class='col-auto d-flex flex-column align-items-end text-right'>
<div class='text-white font-weight-bold'> <div class='text-white font-weight-bold'>
{#if data.episode}
Episode {data.episode} Episode {data.episode}
{:else if media?.format === 'MOVIE' }
Movie
{:else if data.parseObject?.anime_title?.match(/S(\d{2})/)}
Season {parseInt(data.parseObject.anime_title.match(/S(\d{2})/)[1], 10)}
{:else}
Batch
{/if}
</div>
<div class='d-flex align-items-center'>
<div class='text-nowrap font-size-12 title text-muted d-flex align-items-center'>
<AudioLabel {media} {data} banner={true} episode={true} />
</div> </div>
{#if data.date} {#if data.date}
<div class='text-muted font-size-12 title ml-5 mr-5 overflow-hidden'>
</div>
<div class='text-muted font-size-12 title overflow-hidden'> <div class='text-muted font-size-12 title overflow-hidden'>
{since(data.date)} {since(data.date)}
</div> </div>
{:else if data.similarity} {:else if data.similarity}
<div class='text-muted font-size-12 title ml-5 mr-5 overflow-hidden'>
</div>
<div class='text-muted font-size-12 title overflow-hidden'> <div class='text-muted font-size-12 title overflow-hidden'>
{Math.round(data.similarity * 100)}% {Math.round(data.similarity * 100)}%
</div> </div>
{/if} {/if}
</div> </div>
{/if} </div>
</div> </div>
</div> </div>
</div> </div>
@ -106,6 +139,9 @@
z-index: 30; z-index: 30;
/* fixes transform scaling on click causing z-index issues */ /* fixes transform scaling on click causing z-index issues */
} }
.opacity-half {
opacity: 30%;
}
.title { .title {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 1; -webkit-line-clamp: 1;

View file

@ -1,16 +1,29 @@
<script> <script>
import { statusColorMap, formatMap } from '@/modules/anime.js' import { statusColorMap, formatMap } from '@/modules/anime.js'
import { since } from '@/modules/util' import { since } from '@/modules/util.js'
import { click } from '@/modules/click.js'
import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js' import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js'
import { anilistClient } from "@/modules/anilist"
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import { getContext } from 'svelte'
import { CalendarDays, Play, Tv } from 'lucide-svelte' import { CalendarDays, Play, Tv } from 'lucide-svelte'
export let data
/** @type {import('@/modules/al.d.ts').Media | null} */
const media = data.media
const episodeThumbnail = ((!media?.mediaListEntry?.status || !(media.mediaListEntry.status === 'CURRENT' && media.mediaListEntry.progress < data.episode)) && data.episodeData?.image) || media?.bannerImage || media?.coverImage.extraLarge || ' ' export let data
export let prompt
/** @type {import('@/modules/al.d.ts').Media | null} */
const media = data.media && anilistClient.mediaCache[data.media.id]
const episodeThumbnail = ((!media?.mediaListEntry?.status || !(['CURRENT', 'PAUSED', 'DROPPED'].includes(media.mediaListEntry.status) && media.mediaListEntry.progress < data.episode)) && data.episodeData?.image) || media?.bannerImage || media?.coverImage.extraLarge || ' '
let hide = true let hide = true
const progress = liveAnimeEpisodeProgress(media?.id, data?.episode) const progress = liveAnimeEpisodeProgress(media?.id, data?.episode)
const watched = media?.mediaListEntry?.status === 'COMPLETED'
const completed = !watched && media?.mediaListEntry?.progress >= data?.episode
const view = getContext('view')
function viewMedia () {
$view = media
}
</script> </script>
<div class='position-absolute w-400 mh-400 absolute-container top-0 m-auto bg-dark-light z-30 rounded overflow-hidden pointer d-flex flex-column'> <div class='position-absolute w-400 mh-400 absolute-container top-0 m-auto bg-dark-light z-30 rounded overflow-hidden pointer d-flex flex-column'>
@ -33,41 +46,62 @@
{media.duration}m {media.duration}m
{/if} {/if}
</div> </div>
{#if $progress > 0} {#if completed}
<div class='progress container-fluid position-absolute mb-5'> <div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style='width: {$progress}%' /> <div class='progress-bar w-full' />
</div>
{:else if $progress > 0}
<div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style='width: {progress}%' />
</div> </div>
{/if} {/if}
</div> </div>
<div class='w-full d-flex flex-column flex-grow-1 px-20 pb-15'> <div class='w-full d-flex flex-column flex-grow-1 px-20 pb-15'>
<div class='row pt-15'> <div class='row pt-15'>
<div class='col pr-10'> <div class='col pr-10'>
<div class='text-white font-weight-very-bold font-size-16 title overflow-hidden' title={data.media?.title.userPreferred || data.parseObject.anime_title}> <div class='text-white font-weight-very-bold font-size-16 title overflow-hidden' title={anilistClient.title(media) || data.parseObject.anime_title}>
{#if media?.mediaListEntry?.status} {#if media?.mediaListEntry?.status}
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} /> <div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />
{/if} {/if}
{data.media?.title.userPreferred || data.parseObject.anime_title} {anilistClient.title(media) || data.parseObject.anime_title}
</div> </div>
<div class='text-muted font-size-12 title overflow-hidden' title={data.episodeData?.title?.en}> <div class='text-muted font-size-12 title overflow-hidden' title={data.episodeData?.title?.en}>
{data.episodeData?.title?.en || ''} {data.episodeData?.title?.en || ''}
</div> </div>
</div> </div>
<div class='col-auto d-flex flex-column align-items-end text-right' title={data.parseObject?.file_name} >
<div class='text-white font-weight-bold font-weight-very-bold'>
{#if data.episode} {#if data.episode}
<div class='col-auto d-flex flex-column align-items-end text-right'>
<div class='text-white font-weight-bold'>
Episode {data.episode} Episode {data.episode}
{:else if media?.format === 'MOVIE' }
Movie
{:else if data.parseObject?.anime_title?.match(/S(\d{2})/)}
Season {parseInt(data.parseObject.anime_title.match(/S(\d{2})/)[1], 10)}
{:else}
Batch
{/if}
</div>
<div class='d-flex align-items-center'>
<div class='text-nowrap font-size-12 title text-muted d-flex align-items-center'>
<AudioLabel {media} {data} banner={true} episode={true} />
</div> </div>
{#if data.date} {#if data.date}
<div class='text-muted font-size-12 title ml-5 mr-5 overflow-hidden'>
</div>
<div class='text-muted font-size-12 title overflow-hidden'> <div class='text-muted font-size-12 title overflow-hidden'>
{since(data.date)} {since(data.date)}
</div> </div>
{:else if data.similarity} {:else if data.similarity}
<div class='text-muted font-size-12 title ml-5 mr-5 overflow-hidden'>
</div>
<div class='text-muted font-size-12 title overflow-hidden'> <div class='text-muted font-size-12 title overflow-hidden'>
{Math.round(data.similarity * 100)}% {Math.round(data.similarity * 100)}%
</div> </div>
{/if} {/if}
</div> </div>
{/if} </div>
</div> </div>
<div class='w-full text-muted description overflow-hidden pt-15'> <div class='w-full text-muted description overflow-hidden pt-15'>
{data.episodeData?.description || media?.description?.replace(/<[^>]*>/g, '') || ''} {data.episodeData?.description || media?.description?.replace(/<[^>]*>/g, '') || ''}
@ -85,9 +119,22 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class='overlay position-absolute w-full h-200 z-40 d-flex flex-column justify-content-center align-items-center {prompt ? "visible" : "invisible"}'>
<p class='ml-20 mr-20 font-size-24 text-white text-center'>Your Current Progress Is At <b>Episode {media?.mediaListEntry?.progress}</b></p>
<button class='btn btn-lg btn-secondary w-250 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center mt-10'
use:click={() => {
data.onclick() || viewMedia()
}}>
<Play class='mr-10' fill='currentColor' size='1.6rem' />
Continue Anyway?
</button>
</div>
</div> </div>
<style> <style>
.overlay {
background-color: rgba(28, 28, 28, 0.9);
}
.description { .description {
display: -webkit-box !important; display: -webkit-box !important;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;

View file

@ -5,8 +5,12 @@
import { countdown } from '@/modules/util.js' import { countdown } from '@/modules/util.js'
import { SUPPORTS } from '@/modules/support.js'; import { SUPPORTS } from '@/modules/support.js';
import { page } from '@/App.svelte' import { page } from '@/App.svelte'
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import { anilistClient } from "@/modules/anilist"
import Helper from "@/modules/helper.js"
/** @type {import('@/modules/al.d.ts').Media} */ /** @type {import('@/modules/al.d.ts').Media} */
export let media export let media
export let variables = null
const view = getContext('view') const view = getContext('view')
function viewMedia () { function viewMedia () {
@ -15,11 +19,12 @@
</script> </script>
<div class='d-flex px-20 py-10 position-relative justify-content-center' use:click={viewMedia}> <div class='d-flex px-20 py-10 position-relative justify-content-center' use:click={viewMedia}>
<div class='card m-0 p-0 overflow-hidden pointer content-visibility-auto full-card' <div class='card m-0 p-0 overflow-hidden pointer content-visibility-auto full-card' class:opacity-half={variables?.continueWatching && Helper.isMalAuth() && media?.status !== 'FINISHED' && media?.mediaListEntry?.progress >= media?.nextAiringEpisode?.episode - 1}
style:--color={media.coverImage.color || '#1890ff'}> style:--color={media.coverImage.color || '#1890ff'}>
<div class='row h-full'> <div class='row h-full'>
<div class='col-4 img-col'> <div class='col-4 img-col d-inline-block position-relative'>
<img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full h-full' /> <img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full h-full' />
<AudioLabel {media} />
</div> </div>
<div class='col h-full card-grid'> <div class='col h-full card-grid'>
<div class='px-15 py-10 bg-very-dark'> <div class='px-15 py-10 bg-very-dark'>
@ -27,7 +32,7 @@
{#if media.mediaListEntry?.status} {#if media.mediaListEntry?.status}
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} /> <div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />
{/if} {/if}
{media.title.userPreferred} {anilistClient.title(media)}
</h5> </h5>
{#if $page === 'schedule'} {#if $page === 'schedule'}
<div class='py-5'> <div class='py-5'>
@ -41,8 +46,8 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<p class='text-muted m-0 text-capitalize details'> <p class='details text-muted m-0 text-capitalize d-flex flex-wrap'>
<span class='text-nowrap'> <span class='text-nowrap d-flex align-items-center'>
{#if media.format === 'TV'} {#if media.format === 'TV'}
TV Show TV Show
{:else if media.format} {:else if media.format}
@ -50,7 +55,7 @@
{/if} {/if}
</span> </span>
{#if media.episodes && media.episodes !== 1} {#if media.episodes && media.episodes !== 1}
<span class='text-nowrap'> <span class='text-nowrap d-flex align-items-center'>
{#if media.mediaListEntry?.status === 'CURRENT' && media.mediaListEntry?.progress } {#if media.mediaListEntry?.status === 'CURRENT' && media.mediaListEntry?.progress }
{media.mediaListEntry.progress} / {media.episodes} Episodes {media.mediaListEntry.progress} / {media.episodes} Episodes
{:else} {:else}
@ -58,21 +63,39 @@
{/if} {/if}
</span> </span>
{:else if media.duration} {:else if media.duration}
<span class='text-nowrap'>{media.duration + ' Minutes'}</span> <span class='text-nowrap d-flex align-items-center'>{media.duration + ' Minutes'}</span>
{/if}
<span class='text-nowrap d-flex align-items-center'>
<AudioLabel {media} banner={true}/>
</span>
{#if media.isAdult}
<span class='text-nowrap d-flex align-items-center'>
Rated 18+
</span>
{/if} {/if}
{#if media.status} {#if media.status}
<span class='text-nowrap'>{media.status?.toLowerCase().replace(/_/g, ' ')}</span> <span class='text-nowrap d-flex align-items-center'>{media.status?.toLowerCase().replace(/_/g, ' ')}</span>
{/if} {/if}
</p>
<p class='details text-muted m-0 text-capitalize d-flex flex-wrap'>
{#if media.season || media.seasonYear} {#if media.season || media.seasonYear}
<span class='text-nowrap'> <span class='text-nowrap d-flex align-items-center'>
{[media.season?.toLowerCase(), media.seasonYear].filter(s => s).join(' ')} {[media.season?.toLowerCase(), media.seasonYear].filter(s => s).join(' ')}
</span> </span>
{/if} {/if}
{#if media.averageScore}
<span class='text-nowrap d-flex align-items-center'>{media.averageScore + '%'} Rating</span>
{/if}
{#if media.stats?.scoreDistribution}
<span class='text-nowrap d-flex align-items-center'>{anilistClient.reviews(media)} Reviews</span>
{/if}
</p> </p>
</div> </div>
{#if media.description}
<div class='overflow-y-auto px-15 pb-5 bg-very-dark card-desc pre-wrap'> <div class='overflow-y-auto px-15 pb-5 bg-very-dark card-desc pre-wrap'>
{media.description?.replace(/<[^>]*>/g, '') || ''} {media.description?.replace(/<[^>]*>/g, '') || ''}
</div> </div>
{/if}
{#if media.genres.length} {#if media.genres.length}
<div class='px-15 pb-10 pt-5 genres'> <div class='px-15 pb-10 pt-5 genres'>
{#each media.genres.slice(0, 3) as genre} {#each media.genres.slice(0, 3) as genre}
@ -89,8 +112,17 @@
.pre-wrap { .pre-wrap {
white-space: pre-wrap white-space: pre-wrap
} }
.details span + span::before { .opacity-half {
opacity: 30%;
}
.details {
font-size: 1.3rem;
}
.details > span:not(:last-child)::after {
content: '•'; content: '•';
padding: .5rem;
font-size: .6rem;
align-self: center;
white-space: normal; white-space: normal;
} }
.card { .card {

View file

@ -1,11 +1,15 @@
<script> <script>
import { formatMap, setStatus, playMedia } from '@/modules/anime.js' import { formatMap, playMedia } from '@/modules/anime.js'
import { anilistClient } from '@/modules/anilist.js' import { anilistClient } from '@/modules/anilist.js'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import { alToken } from '@/modules/settings.js' import Scoring from '@/views/ViewAnime/Scoring.svelte'
import { Bookmark, Heart, Play, VolumeX, Volume2 } from 'lucide-svelte' import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import Helper from "@/modules/helper.js"
import { Heart, Play, VolumeX, Volume2, ThumbsUp, ThumbsDown } from 'lucide-svelte'
/** @type {import('@/modules/al.d.ts').Media} */ /** @type {import('@/modules/al.d.ts').Media} */
export let media export let media
export let type = null
let hide = true let hide = true
@ -26,17 +30,6 @@
return 'Watch Now' return 'Watch Now'
} }
const playButtonText = getPlayButtonText(media) const playButtonText = getPlayButtonText(media)
async function toggleStatus () {
if (!media.mediaListEntry) {
// add
const res = await setStatus('PLANNING', {}, media)
media.mediaListEntry = res.data.SaveMediaListEntry
} else {
// delete
anilistClient.delete({ id: media.mediaListEntry.id })
media.mediaListEntry = undefined
}
}
function toggleFavourite () { function toggleFavourite () {
anilistClient.favourite({ id: media.id }) anilistClient.favourite({ id: media.id })
media.isFavourite = !media.isFavourite media.isFavourite = !media.isFavourite
@ -54,9 +47,9 @@
<div class='position-absolute w-350 h-400 absolute-container top-0 bottom-0 m-auto bg-dark-light z-30 rounded overflow-hidden pointer'> <div class='position-absolute w-350 h-400 absolute-container top-0 bottom-0 m-auto bg-dark-light z-30 rounded overflow-hidden pointer'>
<div class='banner position-relative bg-black overflow-hidden'> <div class='banner position-relative bg-black overflow-hidden'>
<img src={media.bannerImage || `https://i.ytimg.com/vi/${media.trailer?.id}/hqdefault.jpg` || ' '} alt='banner' class='img-cover w-full h-full' /> <img src={media.bannerImage || (media.trailer?.id ? `https://i.ytimg.com/vi/${media.trailer?.id}/hqdefault.jpg` : media.coverImage?.extraLarge || ' ')} alt='banner' class='img-cover w-full h-full' />
{#if media.trailer?.id} {#if media.trailer?.id}
<div class='position-absolute z-10 top-0 right-0 p-15' use:click={toggleMute}> <div class='position-absolute z-10 top-0 right-0 p-15 sound' use:click={toggleMute}>
{#if muted} {#if muted}
<VolumeX size='2.2rem' fill='currentColor' /> <VolumeX size='2.2rem' fill='currentColor' />
{:else} {:else}
@ -86,8 +79,8 @@
{/if} {/if}
</div> </div>
<div class='w-full px-20'> <div class='w-full px-20'>
<div class='font-size-24 font-weight-bold text-truncate d-inline-block w-full text-white' title={media.title.userPreferred}> <div class='font-size-24 font-weight-bold text-truncate d-inline-block w-full text-white' title={anilistClient.title(media)}>
{media.title.userPreferred} {anilistClient.title(media)}
</div> </div>
<div class='d-flex flex-row pt-5'> <div class='d-flex flex-row pt-5'>
<button class='btn btn-secondary flex-grow-1 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center' <button class='btn btn-secondary flex-grow-1 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center'
@ -96,14 +89,22 @@
<Play class='pr-10 z-10' fill='currentColor' size='2.2rem' /> <Play class='pr-10 z-10' fill='currentColor' size='2.2rem' />
{playButtonText} {playButtonText}
</button> </button>
<button class='btn btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!alToken}> <button class='btn btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!Helper.isAniAuth()}>
<Heart fill={media.isFavourite ? 'currentColor' : 'transparent'} size='1.5rem' /> <Heart fill={media.isFavourite ? 'currentColor' : 'transparent'} size='1.5rem' />
</button> </button>
<button class='btn btn-square ml-10 d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleStatus} disabled={!alToken}> <Scoring {media} previewAnime={true}/>
<Bookmark fill={media.mediaListEntry ? 'currentColor' : 'transparent'} size='1.5rem' />
</button>
</div> </div>
<div class='details text-white text-capitalize pt-15 pb-10 d-flex'> <div class='details text-white text-capitalize pt-15 d-flex'>
{#if type || type === 0}
<span class='context-type text-nowrap d-flex align-items-center'>
{#if Number.isInteger(type) && type >= 0}
<ThumbsUp fill='currentColor'class='pr-5 pb-5 {type === 0 ? "text-muted" : "text-success"}' size='2rem' />
{:else if Number.isInteger(type) && type < 0}
<ThumbsDown fill='currentColor' class='text-danger pr-5 pb-5' size='2rem' />
{/if}
{(Number.isInteger(type) ? Math.abs(type).toLocaleString() + (type >= 0 ? ' likes' : ' dislikes') : type)}
</span>
{/if}
<span class='text-nowrap d-flex align-items-center'> <span class='text-nowrap d-flex align-items-center'>
{#if media.format} {#if media.format}
{formatMap[media.format]} {formatMap[media.format]}
@ -122,15 +123,33 @@
{media.duration + ' Minutes'} {media.duration + ' Minutes'}
</span> </span>
{/if} {/if}
<span class='text-nowrap d-flex align-items-center'>
<AudioLabel {media} banner={true}/>
</span>
{#if media.isAdult}
<span class='text-nowrap d-flex align-items-center'>
Rated 18+
</span>
{/if}
</div>
<div class='details text-white text-capitalize pb-10 d-flex'>
{#if media.season || media.seasonYear} {#if media.season || media.seasonYear}
<span class='text-nowrap d-flex align-items-center'> <span class='text-nowrap d-flex align-items-center'>
{[media.season?.toLowerCase(), media.seasonYear].filter(s => s).join(' ')} {[media.season?.toLowerCase(), media.seasonYear].filter(s => s).join(' ')}
</span> </span>
{/if} {/if}
{#if media.averageScore}
<span class='text-nowrap d-flex align-items-center'>{media.averageScore + '%'} Rating</span>
{/if}
{#if media.stats?.scoreDistribution}
<span class='text-nowrap d-flex align-items-center'>{anilistClient.reviews(media)} Reviews</span>
{/if}
</div> </div>
{#if media.description}
<div class='w-full h-full text-muted description overflow-hidden'> <div class='w-full h-full text-muted description overflow-hidden'>
{media.description?.replace(/<[^>]*>/g, '')} {media.description?.replace(/<[^>]*>/g, '')}
</div> </div>
{/if}
</div> </div>
</div> </div>
@ -140,9 +159,9 @@
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.details span + span::before { .details > span:not(:last-child)::after {
content: '•'; content: '•';
padding: 0 .5rem; padding: .5rem;
font-size: .6rem; font-size: .6rem;
align-self: center; align-self: center;
white-space: normal; white-space: normal;
@ -151,6 +170,9 @@
.banner { .banner {
height: 45% height: 45%
} }
.sound {
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1))
}
/* video { /* video {
object-fit: cover; object-fit: cover;
} */ } */

View file

@ -4,12 +4,17 @@
import { formatMap, statusColorMap } from '@/modules/anime.js' import { formatMap, statusColorMap } from '@/modules/anime.js'
import { hoverClick } from '@/modules/click.js' import { hoverClick } from '@/modules/click.js'
import { countdown } from '@/modules/util.js' import { countdown } from '@/modules/util.js'
import { SUPPORTS } from '@/modules/support.js'; import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import { page } from '@/App.svelte' import { page } from '@/App.svelte'
import { CalendarDays, Tv } from 'lucide-svelte' import { anilistClient } from "@/modules/anilist"
import Helper from "@/modules/helper.js"
import { CalendarDays, Tv, ThumbsUp, ThumbsDown } from 'lucide-svelte'
/** @type {import('@/modules/al.d.ts').Media} */ /** @type {import('@/modules/al.d.ts').Media} */
export let media export let media
export let type = null
export let variables = null
let preview = false let preview = false
const view = getContext('view') const view = getContext('view')
@ -23,9 +28,9 @@
<div class='d-flex p-md-20 p-15 position-relative first-check' use:hoverClick={[viewMedia, setHoverState]}> <div class='d-flex p-md-20 p-15 position-relative first-check' use:hoverClick={[viewMedia, setHoverState]}>
{#if preview} {#if preview}
<PreviewCard {media} /> <PreviewCard {media} {type} />
{/if} {/if}
<div class='item small-card d-flex flex-column h-full pointer content-visibility-auto'> <div class='item small-card d-flex flex-column h-full pointer content-visibility-auto' class:opacity-half={variables?.continueWatching && Helper.isMalAuth() && media?.status !== 'FINISHED' && media?.mediaListEntry?.progress >= media?.nextAiringEpisode?.episode - 1}>
{#if $page === 'schedule'} {#if $page === 'schedule'}
<div class='w-full text-center pb-10'> <div class='w-full text-center pb-10'>
{#if media.airingSchedule?.nodes?.[0]?.airingAt} {#if media.airingSchedule?.nodes?.[0]?.airingAt}
@ -38,13 +43,25 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="d-inline-block position-relative">
<img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full rounded' style:--color={media.coverImage.color || '#1890ff'} /> <img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full rounded' style:--color={media.coverImage.color || '#1890ff'} />
<AudioLabel {media} />
</div>
{#if type || type === 0}
<div class='context-type d-flex align-items-center'>
{#if Number.isInteger(type) && type >= 0}
<ThumbsUp fill='currentColor' class='pr-5 pb-5 {type === 0 ? "text-muted" : "text-success"}' size='2rem' />
{:else if Number.isInteger(type) && type < 0}
<ThumbsDown fill='currentColor' class='text-danger pr-5 pb-5' size='2rem' />
{/if}
{(Number.isInteger(type) ? Math.abs(type).toLocaleString() + (type >= 0 ? ' likes' : ' dislikes') : type)}
</div>
{/if}
<div class='text-white font-weight-very-bold font-size-16 pt-15 title overflow-hidden'> <div class='text-white font-weight-very-bold font-size-16 pt-15 title overflow-hidden'>
{#if media.mediaListEntry?.status} {#if media.mediaListEntry?.status}
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} /> <div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />
{/if} {/if}
{media.title.userPreferred} {anilistClient.title(media)}
</div> </div>
<div class='d-flex flex-row mt-auto pt-10 font-weight-medium justify-content-between w-full text-muted'> <div class='d-flex flex-row mt-auto pt-10 font-weight-medium justify-content-between w-full text-muted'>
<div class='d-flex align-items-center pr-5' style='margin-left: -1px'> <div class='d-flex align-items-center pr-5' style='margin-left: -1px'>
@ -64,7 +81,9 @@
z-index: 30; z-index: 30;
/* fixes transform scaling on click causing z-index issues */ /* fixes transform scaling on click causing z-index issues */
} }
.opacity-half {
opacity: 30%;
}
.title { .title {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;

View file

@ -28,7 +28,7 @@
--banner-gradient-left: linear-gradient(90deg, #17191D 0%, rgba(23, 25, 29, 0.5) 75%, rgba(25, 28, 32, 0) 100%); --banner-gradient-left: linear-gradient(90deg, #17191D 0%, rgba(23, 25, 29, 0.5) 75%, rgba(25, 28, 32, 0) 100%);
--torrent-card-gradient: linear-gradient(90deg, #17191C 32%, rgba(23, 25, 28, 0.90) 100%); --torrent-card-gradient: linear-gradient(90deg, #17191C 32%, rgba(23, 25, 28, 0.90) 100%);
--episode-card-gradient: linear-gradient(180deg, rgba(0, 0, 0, 0) 77.08%, rgba(0, 0, 0, 0.7) 100%); --episode-card-gradient: linear-gradient(180deg, rgba(0, 0, 0, 0) 77.08%, rgba(0, 0, 0, 0.7) 100%);
--episode-preview-card-gradient: linear-gradient(180deg, #0000 0%, #25292f00 80%, #25292f 95%, #25292f 100%); --episode-preview-card-gradient: linear-gradient(180deg, #0000 0%, #25292f00 80%, #25292f 100%);
--preview-card-gradient: linear-gradient(180deg, #0000 0%, #25292f00 80%, #25292fe3 95%, #25292f 100%); --preview-card-gradient: linear-gradient(180deg, #0000 0%, #25292f00 80%, #25292fe3 95%, #25292f 100%);
--section-end-gradient: linear-gradient(270deg, #17191cff 0%, #17191c00 100%); --section-end-gradient: linear-gradient(270deg, #17191cff 0%, #17191c00 100%);
--dark-color-dim-hsl: var(--dark-color-base-hue), var(--dark-color-base-saturation), 8%; --dark-color-dim-hsl: var(--dark-color-base-hue), var(--dark-color-base-saturation), 8%;
@ -118,6 +118,10 @@ a[href]:active, button:not([disabled]):active, fieldset:not([disabled]):active,
z-index: 100; z-index: 100;
} }
.z-101 {
z-index: 101;
}
.text-dark-light { .text-dark-light {
color: var(--gray-color-light); color: var(--gray-color-light);
} }

View file

@ -1,5 +1,6 @@
export type Media = { export type Media = {
id: number id: number
idMal: number
title: { title: {
romaji?: string romaji?: string
english?: string english?: string
@ -15,6 +16,10 @@ export type Media = {
duration?: number duration?: number
averageScore?: number averageScore?: number
genres?: string[] genres?: string[]
tags?: {
name: string
rank: integer
}[]
isFavourite: boolean isFavourite: boolean
coverImage?: { coverImage?: {
extraLarge: string extraLarge: string
@ -26,15 +31,16 @@ export type Media = {
isAdult?: boolean isAdult?: boolean
bannerImage?: string bannerImage?: string
synonyms?: string[] synonyms?: string[]
stats: {
scoreDistribution: {
score: number
amount: number
}[]
}
nextAiringEpisode?: { nextAiringEpisode?: {
episode: number episode: number
airingAt: number airingAt: number
} }
startDate?: {
year: number
month?: number
day?: number
}
trailer?: { trailer?: {
id: string id: string
site: string site: string
@ -50,12 +56,20 @@ export type Media = {
status?: string status?: string
customLists?: string[] customLists?: string[]
score?: number score?: number
startedAt?: {
year: number
month: number
day: number
}
completedAt?: {
year: number
month: number
day: number
}
} }
studios?: { studios?: {
edges: { nodes: {
node: {
name: string name: string
}
}[] }[]
} }
airingSchedule?: { airingSchedule?: {
@ -69,50 +83,27 @@ export type Media = {
relationType: string relationType: string
node: { node: {
id: number id: number
title: {
userPreferred: string
}
type: string type: string
status: string
format?: string format?: string
episodes?: number
synonyms?: string[]
season?: string
seasonYear?: number seasonYear?: number
startDate?: {
year: number
month?: number
day?: number
} }
endDate?: { }[]
year: number }
month: number recommendations?: {
day: number edges?: {
node: {
rating: number
mediaRecommendation: {
id: number
} }
} }
}[] }[]
} }
// recommendations?: {
// edges?: {
// node: {
// media: {
// id: number
// title: {
// userPreferred: string
// }
// coverImage?: {
// medium: string
// }
// }
// }
// }[]
// }
} }
export type Following = { export type Following = {
status: string status: string
score: number score: number
progress: number
user: { user: {
name: string name: string
avatar: { avatar: {
@ -135,6 +126,8 @@ export type MediaListMedia = {
relationType: string relationType: string
node: { node: {
id: number id: number
type: string
format?: string
} }
}[] }[]
} }
@ -144,7 +137,7 @@ export type MediaListCollection = {
lists: { lists: {
status: string status: string
entries: { entries: {
media: MediaListMedia media: Media
}[] }[]
}[] }[]
} }
@ -152,6 +145,7 @@ export type MediaListCollection = {
export type Viewer = { export type Viewer = {
avatar: { avatar: {
medium: string medium: string
large: string
} }
name: string name: string
id: number id: number

View file

@ -2,15 +2,16 @@ import lavenshtein from 'js-levenshtein'
import { writable } from 'simple-store-svelte' import { writable } from 'simple-store-svelte'
import Bottleneck from 'bottleneck' import Bottleneck from 'bottleneck'
import { alToken } from '@/modules/settings.js' import { alToken, settings } from '@/modules/settings.js'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import { sleep } from './util.js' import { sleep } from './util.js'
import Helper from '@/modules/helper.js'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import Debug from 'debug' import Debug from 'debug'
const debug = Debug('ui:anilist') const debug = Debug('ui:anilist')
const codes = { export const codes = {
400: 'Bad Request', 400: 'Bad Request',
401: 'Unauthorized', 401: 'Unauthorized',
402: 'Payment Required', 402: 'Payment Required',
@ -22,6 +23,7 @@ const codes = {
410: 'Gone', 410: 'Gone',
412: 'Precondition Failed', 412: 'Precondition Failed',
413: 'Request Entity Too Large', 413: 'Request Entity Too Large',
422: 'Unprocessable Entity',
429: 'Too Many Requests', 429: 'Too Many Requests',
500: 'Internal Server Error', 500: 'Internal Server Error',
501: 'Not Implemented', 501: 'Not Implemented',
@ -81,6 +83,10 @@ episodes,
duration, duration,
averageScore, averageScore,
genres, genres,
tags {
name,
rank
},
isFavourite, isFavourite,
coverImage { coverImage {
extraLarge, extraLarge,
@ -92,15 +98,21 @@ countryOfOrigin,
isAdult, isAdult,
bannerImage, bannerImage,
synonyms, synonyms,
studios(sort: NAME, isMain: true) {
nodes {
name
}
},
stats {
scoreDistribution {
score,
amount
}
},
nextAiringEpisode { nextAiringEpisode {
timeUntilAiring, timeUntilAiring,
episode episode
}, },
startDate {
year,
month,
day
},
trailer { trailer {
id, id,
site site
@ -115,11 +127,16 @@ mediaListEntry {
repeat, repeat,
status, status,
customLists(asArray: true), customLists(asArray: true),
score(format: POINT_10) score(format: POINT_10),
startedAt {
year,
month,
day
}, },
studios(isMain: true) { completedAt {
nodes { year,
name month,
day
} }
}, },
airingSchedule(page: 1, perPage: 1, notYetAired: true) { airingSchedule(page: 1, perPage: 1, notYetAired: true) {
@ -133,45 +150,13 @@ relations {
relationType(version:2), relationType(version:2),
node { node {
id, id,
title {userPreferred},
coverImage {medium},
type, type,
status,
format, format,
episodes, seasonYear
synonyms,
season,
seasonYear,
startDate {
year,
month,
day
},
endDate {
year,
month,
day
}
} }
} }
}` }`
// recommendations {
// edges {
// node {
// mediaRecommendation {
// id,
// title {
// userPreferred
// },
// coverImage {
// medium
// }
// }
// }
// }
// }
class AnilistClient { class AnilistClient {
limiter = new Bottleneck({ limiter = new Bottleneck({
reservoir: 90, reservoir: 90,
@ -194,7 +179,7 @@ class AnilistClient {
lastNotificationDate = Date.now() / 1000 lastNotificationDate = Date.now() / 1000
constructor () { constructor () {
debug('Initializing Anilist Client for ID ' + this.userID?.viewer?.data?.Viewer.id) debug('Initializing Anilist Client for ID ' + this.userID?.viewer?.data?.Viewer?.id)
this.limiter.on('failed', async (error, jobInfo) => { this.limiter.on('failed', async (error, jobInfo) => {
printError(error) printError(error)
@ -210,9 +195,9 @@ class AnilistClient {
}) })
if (this.userID?.viewer?.data?.Viewer) { if (this.userID?.viewer?.data?.Viewer) {
this.userLists.value = this.getUserLists() this.userLists.value = this.getUserLists({ sort: 'UPDATED_TIME_DESC' })
// update userLists every 15 mins // update userLists every 15 mins
setInterval(() => { this.userLists.value = this.getUserLists() }, 1000 * 60 * 15) setInterval(() => this.userLists.value = this.getUserLists({ sort: 'UPDATED_TIME_DESC' }), 1000 * 60 * 15)
// check notifications every 5 mins // check notifications every 5 mins
setInterval(() => { this.findNewNotifications() }, 1000 * 60 * 5) setInterval(() => { this.findNewNotifications() }, 1000 * 60 * 5)
} }
@ -265,14 +250,14 @@ class AnilistClient {
query: query.replace(/\s/g, '').replaceAll('&nbsp;', ' '), query: query.replace(/\s/g, '').replaceAll('&nbsp;', ' '),
variables: { variables: {
page: 1, page: 1,
perPage: 30, perPage: 50,
status_in: '[CURRENT,PLANNING]', status_in: '[CURRENT,PLANNING,COMPLETED,DROPPED,PAUSED,REPEATING]',
...variables ...variables
} }
}) })
} }
// @ts-ignore if (variables?.token) options.headers.Authorization = variables.token
if (alToken?.token) options.headers.Authorization = alToken.token else if (alToken?.token) options.headers.Authorization = alToken.token
return this.handleRequest(options) return this.handleRequest(options)
} }
@ -290,7 +275,7 @@ class AnilistClient {
const notifications = res.data.Page.notifications const notifications = res.data.Page.notifications
const newNotifications = notifications.filter(({ createdAt }) => createdAt > this.lastNotificationDate) const newNotifications = notifications.filter(({ createdAt }) => createdAt > this.lastNotificationDate)
this.lastNotificationDate = Date.now() / 1000 this.lastNotificationDate = Date.now() / 1000
debug(`Found ${newNotifications.length} new notifications`) debug(`Found ${newNotifications?.length} new notifications`)
for (const { media, episode, type } of newNotifications) { for (const { media, episode, type } of newNotifications) {
const options = { const options = {
title: media.title.userPreferred, title: media.title.userPreferred,
@ -306,7 +291,7 @@ class AnilistClient {
* @param {{key: string, title: string, year?: string, isAdult: boolean}[]} flattenedTitles * @param {{key: string, title: string, year?: string, isAdult: boolean}[]} flattenedTitles
**/ **/
async alSearchCompound (flattenedTitles) { async alSearchCompound (flattenedTitles) {
debug(`Searching for ${flattenedTitles.length} titles via compound search`) debug(`Searching for ${flattenedTitles?.length} titles via compound search`)
if (!flattenedTitles.length) return [] if (!flattenedTitles.length) return []
// isAdult doesn't need an extra variable, as the title is the same regardless of type, so we re-use the same variable for adult and non-adult requests // isAdult doesn't need an extra variable, as the title is the same regardless of type, so we re-use the same variable for adult and non-adult requests
/** @type {Record<`v${number}`, string>} */ /** @type {Record<`v${number}`, string>} */
@ -363,59 +348,15 @@ class AnilistClient {
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data.Page.media.find(media => media.id === id)]) return Object.entries(searchResults).map(([filename, id]) => [filename, search.data.Page.media.find(media => media.id === id)])
} }
async alEntry (filemedia) { async alEntry (lists, variables) {
// check if values exist
if (filemedia.media && alToken) {
const { media, failed } = filemedia
debug(`Checking entry for ${media.title.userPreferred}`)
debug(`Media viability: ${media.status}, Is from failed resolve: ${failed}`)
if (failed) return
if (media.status !== 'FINISHED' && media.status !== 'RELEASING') return
// check if media can even be watched, ex: it was resolved incorrectly
// some anime/OVA's can have a single episode, or some movies can have multiple episodes
const singleEpisode = ((!media.episodes && (Number(filemedia.episode) === 1 || isNaN(Number(filemedia.episode)))) || (media.format === 'MOVIE' && media.episodes === 1)) && 1 // movie check
const videoEpisode = Number(filemedia.episode) || singleEpisode
const mediaEpisode = media.episodes || media.nextAiringEpisode?.episode || singleEpisode
debug(`Episode viability: ${videoEpisode}, ${mediaEpisode}, ${singleEpisode}`)
if (!videoEpisode || !mediaEpisode) return
// check episode range, safety check if `failed` didn't catch this
if (videoEpisode > mediaEpisode) return
const lists = media.mediaListEntry?.customLists.filter(list => list.enabled).map(list => list.name) || []
const status = media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
const progress = media.mediaListEntry?.progress
debug(`User's progress: ${progress}, Media's progress: ${videoEpisode}`)
// check user's own watch progress
if (progress > videoEpisode) return
if (progress === videoEpisode && videoEpisode !== mediaEpisode && !singleEpisode) return
debug(`Updating entry for ${media.title.userPreferred}`)
const variables = {
repeat: media.mediaListEntry?.repeat || 0,
id: media.id,
status,
episode: videoEpisode,
lists
}
if (videoEpisode === mediaEpisode) {
variables.status = 'COMPLETED'
if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1
}
if (!lists.includes('Watched using Migu')) { if (!lists.includes('Watched using Migu')) {
variables.lists.push('Watched using Migu') variables.lists.push('Watched using Migu')
} }
await this.entry(variables) return await this.entry(variables)
this.userLists.value = this.getUserLists()
}
} }
async searchName (variables = {}) { async searchName (variables = {}) {
debug(`Searching name for ${variables.name}`) debug(`Searching name for ${variables?.name}`)
const query = /* js */` const query = /* js */`
query($page: Int, $perPage: Int, $sort: [MediaSort], $name: String, $status: [MediaStatus], $year: Int, $isAdult: Boolean) { query($page: Int, $perPage: Int, $sort: [MediaSort], $name: String, $status: [MediaStatus], $year: Int, $isAdult: Boolean) {
Page(page: $page, perPage: $perPage) { Page(page: $page, perPage: $perPage) {
@ -432,14 +373,13 @@ class AnilistClient {
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */ /** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
const res = await this.alRequest(query, variables) const res = await this.alRequest(query, variables)
await this.updateCache(res.data.Page.media)
this.updateCache(res.data.Page.media)
return res return res
} }
async searchIDSingle (variables) { async searchIDSingle (variables) {
debug(`Searching for ID: ${variables.id}`) debug(`Searching for ID: ${variables?.id}`)
const query = /* js */` const query = /* js */`
query($id: Int) { query($id: Int) {
Media(id: $id, type: ANIME) { Media(id: $id, type: ANIME) {
@ -450,20 +390,20 @@ class AnilistClient {
/** @type {import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>} */ /** @type {import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>} */
const res = await this.alRequest(query, variables) const res = await this.alRequest(query, variables)
this.updateCache([res.data.Media]) await this.updateCache([res.data.Media])
return res return res
} }
async searchIDS (variables) { async searchIDS (variables) {
debug(`Searching for IDs: ${variables.id.length}`) debug(`Searching for IDs: ${variables?.id?.length || variables?.idMal?.length}`)
const query = /* js */` const query = /* js */`
query($id: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $onList: Boolean, $sort: [MediaSort], $search: String, $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) { query($id: [Int], $idMal: [Int], $id_not: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $onList: Boolean, $sort: [MediaSort], $search: String, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat) {
Page(page: $page, perPage: $perPage) { Page(page: $page, perPage: $perPage) {
pageInfo { pageInfo {
hasNextPage hasNextPage
}, },
media(id_in: $id, type: ANIME, status_in: $status, onList: $onList, search: $search, sort: $sort, season: $season, seasonYear: $year, genre: $genre, format: $format) { media(id_in: $id, idMal_in: $idMal, id_not_in: $id_not, type: ANIME, status_in: $status, onList: $onList, search: $search, sort: $sort, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format) {
${queryObjects} ${queryObjects}
} }
} }
@ -472,7 +412,7 @@ class AnilistClient {
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */ /** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
const res = await this.alRequest(query, variables) const res = await this.alRequest(query, variables)
this.updateCache(res.data.Page.media) await this.updateCache(res.data.Page.media)
return res return res
} }
@ -527,7 +467,8 @@ class AnilistClient {
query { query {
Viewer { Viewer {
avatar { avatar {
medium medium,
large
}, },
name, name,
id, id,
@ -545,46 +486,31 @@ class AnilistClient {
/** @returns {Promise<import('./al.d.ts').Query<{ MediaListCollection: import('./al.d.ts').MediaListCollection }>>} */ /** @returns {Promise<import('./al.d.ts').Query<{ MediaListCollection: import('./al.d.ts').MediaListCollection }>>} */
async getUserLists (variables = {}) { async getUserLists (variables = {}) {
debug('Getting user lists') debug('Getting user lists')
const userId = this.userID?.viewer?.data?.Viewer.id variables.id = !variables.userID ? this.userID?.viewer?.data?.Viewer.id : variables.userID
variables.id = userId variables.sort = variables.sort?.replace('USER_SCORE_DESC', 'SCORE_DESC') || 'UPDATED_TIME_DESC' // doesn't exist, AniList uses SCORE_DESC for both MediaSort and MediaListSort.
const query = /* js */` const query = /* js */`
query($id: Int) { query($id: Int, $sort: [MediaListSort]) {
MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) { MediaListCollection(userId: $id, type: ANIME, sort: $sort, forceSingleCompletedList: true) {
lists { lists {
status, status,
entries { entries {
media { media {
id, ${queryObjects}
status,
mediaListEntry {
progress
},
nextAiringEpisode {
episode
},
relations {
edges {
relationType(version:2)
node {
id
}
}
}
} }
} }
} }
} }
}` }`
// this doesn't need to be cached, as SearchIDStatus is already cached, which is the only thing that uses this const res = await this.alRequest(query, variables)
return await this.alRequest(query, variables) if (!variables.token) await this.updateCache(res.data.MediaListCollection.lists.flatMap(list => list.entries.map(entry => entry.media)))
return res
} }
/** @returns {Promise<import('./al.d.ts').Query<{ MediaList: { status: string, progress: number, repeat: number }}>>} */ /** @returns {Promise<import('./al.d.ts').Query<{ MediaList: { status: string, progress: number, repeat: number }}>>} */
async searchIDStatus (variables = {}) { async searchIDStatus (variables = {}) {
variables.id = this.userID?.viewer?.data?.Viewer.id
debug(`Searching for ID status: ${variables.id}`) debug(`Searching for ID status: ${variables.id}`)
const userId = this.userID?.viewer?.data?.Viewer.id
variables.id = userId
const query = /* js */` const query = /* js */`
query($id: Int, $mediaId: Int) { query($id: Int, $mediaId: Int) {
MediaList(userId: $id, mediaId: $mediaId) { MediaList(userId: $id, mediaId: $mediaId) {
@ -620,7 +546,7 @@ class AnilistClient {
/** @type {import('./al.d.ts').PagedQuery<{ airingSchedules: { timeUntilAiring: number, airingAt: number, episode: number, media: import('./al.d.ts').Media}[]}>} */ /** @type {import('./al.d.ts').PagedQuery<{ airingSchedules: { timeUntilAiring: number, airingAt: number, episode: number, media: import('./al.d.ts').Media}[]}>} */
const res = await this.alRequest(query, variables) const res = await this.alRequest(query, variables)
this.updateCache(res.data.Page.airingSchedules?.map(({ media }) => media)) await this.updateCache(res.data.Page.airingSchedules?.map(({media}) => media))
return res return res
} }
@ -645,12 +571,12 @@ class AnilistClient {
debug(`Searching ${JSON.stringify(variables)}`) debug(`Searching ${JSON.stringify(variables)}`)
variables.sort ||= 'SEARCH_MATCH' variables.sort ||= 'SEARCH_MATCH'
const query = /* js */` const query = /* js */`
query($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $onList: Boolean, $status: MediaStatus, $status_not: MediaStatus, $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) { query($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $onList: Boolean, $status: MediaStatus, $status_not: MediaStatus, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat, $id_not: [Int], $idMal_not: [Int], $idMal: [Int]) {
Page(page: $page, perPage: $perPage) { Page(page: $page, perPage: $perPage) {
pageInfo { pageInfo {
hasNextPage hasNextPage
}, },
media(type: ANIME, search: $search, sort: $sort, onList: $onList, status: $status, status_not: $status_not, season: $season, seasonYear: $year, genre: $genre, format: $format, format_not: MUSIC) { media(id_not_in: $id_not, idMal_not_in: $idMal_not, idMal_in: $idMal, type: ANIME, search: $search, sort: $sort, onList: $onList, status: $status, status_not: $status_not, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format, format_not: MUSIC) {
${queryObjects} ${queryObjects}
} }
} }
@ -659,7 +585,7 @@ class AnilistClient {
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */ /** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
const res = await this.alRequest(query, variables) const res = await this.alRequest(query, variables)
this.updateCache(res.data.Page.media) await this.updateCache(res.data.Page.media)
return res return res
} }
@ -686,7 +612,6 @@ class AnilistClient {
mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) { mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {
status, status,
score, score,
progress,
user { user {
name, name,
avatar { avatar {
@ -700,22 +625,64 @@ class AnilistClient {
return this.alRequest(query, variables) return this.alRequest(query, variables)
} }
entry (variables) { /** @returns {Promise<import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>>} */
debug(`Updating entry for ${variables.id}`) recommendations (variables) {
debug(`Getting recommendations for ${variables.id}`)
const query = /* js */` const query = /* js */`
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int) { query($id: Int) {
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists) { Media(id: $id, type: ANIME) {
id, id,
status, idMal,
progress, studios(sort: NAME, isMain: true) {
repeat nodes {
name
}
},
recommendations {
edges {
node {
rating,
mediaRecommendation {
id
}
}
}
}
} }
}` }`
return this.alRequest(query, variables) return this.alRequest(query, variables)
} }
delete (variables) { async entry (variables) {
debug(`Updating entry for ${variables.id}`)
const query = /* js */`
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput) {
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists, startedAt: $startedAt, completedAt: $completedAt) {
id,
status,
progress,
score,
repeat,
startedAt {
year,
month,
day
},
completedAt {
year,
month,
day
}
}
}`
const res = await this.alRequest(query, variables)
if (!variables.token) this.userLists.value = this.getUserLists({sort: 'UPDATED_TIME_DESC'})
return res
}
async delete (variables) {
debug(`Deleting entry for ${variables.id}`) debug(`Deleting entry for ${variables.id}`)
const query = /* js */` const query = /* js */`
mutation($id: Int) { mutation($id: Int) {
@ -723,8 +690,9 @@ class AnilistClient {
deleted deleted
} }
}` }`
const res = await this.alRequest(query, variables)
return this.alRequest(query, variables) if (!variables.token) this.userLists.value = this.getUserLists({sort: 'UPDATED_TIME_DESC'})
return res
} }
favourite (variables) { favourite (variables) {
@ -750,9 +718,36 @@ class AnilistClient {
return this.alRequest(query, variables) return this.alRequest(query, variables)
} }
/** @param {import('./al.d.ts').Media} media */
title(media) {
const preferredTitle = media?.title.userPreferred
if (alToken) {
return preferredTitle
}
if (settings.value.titleLang === 'romaji') {
return media?.title.romaji || preferredTitle
} else {
return media?.title.english || preferredTitle
}
}
/** @param {import('./al.d.ts').Media} media */
reviews(media) {
const totalReviewers = media.stats?.scoreDistribution?.reduce((total, score) => total + score.amount, 0)
return totalReviewers ? totalReviewers.toLocaleString() : '?'
}
/** @param {import('./al.d.ts').Media[]} medias */ /** @param {import('./al.d.ts').Media[]} medias */
updateCache (medias) { async updateCache (medias) {
this.mediaCache = { ...this.mediaCache, ...Object.fromEntries(medias.map(media => [media.id, media])) } for (const media of medias) {
if (!alToken) {
// attach any alternative authorization userList information
await Helper.fillEntry(media)
}
// Update the cache
this.mediaCache[media.id] = media
}
} }
} }

View file

@ -9,6 +9,7 @@ import clipboard from './clipboard.js'
import { search, key } from '@/views/Search.svelte' import { search, key } from '@/views/Search.svelte'
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte' import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
import Helper from "@/modules/helper.js"
const imageRx = /\.(jpeg|jpg|gif|png|webp)/i const imageRx = /\.(jpeg|jpg|gif|png|webp)/i
@ -175,6 +176,8 @@ export async function anitomyscript (...args) {
obj.anime_season = seasonMatch[1] obj.anime_season = seasonMatch[1]
obj.episode_number = seasonMatch[2] obj.episode_number = seasonMatch[2]
obj.anime_title = obj.anime_title.replace(/S(\d{2})E(\d{2})/, '') obj.anime_title = obj.anime_title.replace(/S(\d{2})E(\d{2})/, '')
} else if (Array.isArray(obj.anime_season)) {
obj.anime_season = obj.anime_season[0]
} }
const yearMatch = obj.anime_title.match(/ (19[5-9]\d|20\d{2})/) const yearMatch = obj.anime_title.match(/ (19[5-9]\d|20\d{2})/)
if (yearMatch && Number(yearMatch[1]) <= (new Date().getUTCFullYear() + 1)) { if (yearMatch && Number(yearMatch[1]) <= (new Date().getUTCFullYear() + 1)) {
@ -225,12 +228,17 @@ export async function playMedia (media) {
} }
export function setStatus (status, other = {}, media) { export function setStatus (status, other = {}, media) {
const fuzzyDate = Helper.getFuzzyDate(media, status)
const variables = { const variables = {
id: media.id, id: media.id,
idMal: media.idMal,
status, status,
score: media.mediaListEntry?.score || 0,
repeat: media.mediaListEntry?.repeat || 0,
...fuzzyDate,
...other ...other
} }
return anilistClient.entry(variables) return Helper.entry(media, variables)
} }
const episodeMetadataMap = {} const episodeMetadataMap = {}

View file

@ -0,0 +1,63 @@
import { toast } from 'svelte-sonner'
import { writable } from 'simple-store-svelte'
import { codes } from '@/modules/anilist.js'
import Debug from 'debug'
const debug = Debug('ui:animedubs')
/*
* MAL (MyAnimeList) Dubs (Mal-Dubs)
* Dub information is returned as MyAnimeList ids.
*/
class MALDubs {
/** @type {import('simple-store-svelte').Writable<ReturnType<MALDubs['getDubs']>>} */
dubLists = writable()
constructor() {
this.getMALDubs()
// update dubLists every 60 mins
setInterval(() => {
this.getMALDubs()
}, 1000 * 60 * 60)
}
async getMALDubs() {
debug('Getting MyAnimeList Dubs IDs')
let res = {}
try {
res = await fetch('https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/data/dubInfo.json')
} catch (e) {
if (!res || res.status !== 404) throw e
}
if (!res.ok && (res.status === 429 || res.status === 500)) {
throw res
}
let json = null
try {
json = await res.json()
} catch (error) {
if (res.ok) this.printError(error)
}
if (!res.ok) {
if (json) {
for (const error of json?.errors || []) {
this.printError(error)
}
} else {
this.printError(res)
}
}
this.dubLists.value = await json
return json
}
printError(error) {
debug(`Error: ${error.status || 429} - ${error.message || codes[error.status || 429]}`)
toast.error('Dub Caching Failed', {
description: `Failed to load dub information!\nTry again in a minute.\n${error.status || 429} - ${error.message || codes[error.status || 429]}`,
duration: 3000
})
}
}
export const malDubs = new MALDubs()

View file

@ -76,12 +76,12 @@ export default new class AnimeResolver {
return titleObjects return titleObjects
}).flat() }).flat()
debug(`Finding ${titleObjects.length} titles: ${titleObjects.map(obj => obj.title).join(', ')}`) debug(`Finding ${titleObjects?.length} titles: ${titleObjects?.map(obj => obj.title).join(', ')}`)
for (const chunk of chunks(titleObjects, 60)) { for (const chunk of chunks(titleObjects, 60)) {
// single title has a complexity of 8.1, al limits complexity to 500, so this can be at most 62, undercut it to 60, al pagination is 50, but at most we'll do 30 titles since isAduld duplicates each title // single title has a complexity of 8.1, al limits complexity to 500, so this can be at most 62, undercut it to 60, al pagination is 50, but at most we'll do 30 titles since isAduld duplicates each title
for (const [key, media] of await anilistClient.alSearchCompound(chunk)) { for (const [key, media] of await anilistClient.alSearchCompound(chunk)) {
debug(`Found ${key} as ${media.id}: ${media.title.userPreferred}`) debug(`Found ${key} as ${media?.id}: ${media?.title?.userPreferred}`)
this.animeNameCache[key] = media this.animeNameCache[key] = media
} }
} }
@ -125,7 +125,7 @@ export default new class AnimeResolver {
let media = this.animeNameCache[this.getCacheKeyForTitle(parseObj)] let media = this.animeNameCache[this.getCacheKeyForTitle(parseObj)]
// resolve episode, if movie, dont. // resolve episode, if movie, dont.
const maxep = media?.nextAiringEpisode?.episode || media?.episodes const maxep = media?.nextAiringEpisode?.episode || media?.episodes
debug(`Resolving ${parseObj.anime_title} ${parseObj.episode_number} ${maxep} ${media?.title.userPreferred} ${media?.format}`) debug(`Resolving ${parseObj?.anime_title} ${parseObj?.episode_number} ${maxep} ${media?.title?.userPreferred} ${media?.format}`)
if ((media?.format !== 'MOVIE' || maxep) && parseObj.episode_number) { if ((media?.format !== 'MOVIE' || maxep) && parseObj.episode_number) {
if (Array.isArray(parseObj.episode_number)) { if (Array.isArray(parseObj.episode_number)) {
// is an episode range // is an episode range
@ -141,18 +141,24 @@ export default new class AnimeResolver {
// parent check is to break out of those incorrectly resolved OVA's // parent check is to break out of those incorrectly resolved OVA's
// if we used anime season to resolve anime name, then there's no need to march into prequel! // if we used anime season to resolve anime name, then there's no need to march into prequel!
const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL')?.node || ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node)) const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL')?.node || ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node))
debug(`Prequel ${prequel && prequel.id}:${prequel && prequel.title.userPreferred}`) debug(`Prequel ${prequel?.id}:${prequel?.title?.userPreferred}`)
const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media
debug(`Root ${root && root.id}:${root && root.title.userPreferred}`) debug(`Root ${root?.id}:${root?.title?.userPreferred}`)
// if highest value is bigger than episode count or latest streamed episode +1 for safety, parseint to math.floor a number like 12.5 - specials - in 1 go // if highest value is bigger than episode count or latest streamed episode +1 for safety, parseint to math.floor a number like 12.5 - specials - in 1 go
const result = await this.resolveSeason({ media: root || media, episode: parseObj.episode_number[1], increment: !parseObj.anime_season ? null : true }) let result = await this.resolveSeason({ media: root || media, episode: parseObj.episode_number[1], increment: !parseObj.anime_season ? null : true })
debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
// last ditch attempt to resolve the correct episode count, resolves most issues especially with Misfit of a Demon King.
if (result.failed && parseObj.anime_season) {
result = await this.resolveSeason({ media: root || media, episode: parseObj.episode_number[1] })
}
debug(`Found rootMedia for ${parseObj?.anime_title}: ${result?.rootMedia?.id}:${result?.rootMedia?.title?.userPreferred} from ${media?.id}:${media?.title?.userPreferred}`)
media = result.rootMedia media = result.rootMedia
const diff = parseObj.episode_number[1] - result.episode const diff = parseObj.episode_number[1] - result.episode
episode = `${parseObj.episode_number[0] - diff} ~ ${result.episode}` episode = `${parseObj.episode_number[0] - diff} ~ ${result.episode}`
failed = result.failed failed = result.failed
if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media?.title.userPreferred}`) if (failed) debug(`Failed to resolve ${parseObj?.anime_title} ${parseObj?.episode_number} ${media?.title?.userPreferred}`)
} else { } else {
// cant find ep count or range seems fine // cant find ep count or range seems fine
episode = `${Number(parseObj.episode_number[0])} ~ ${Number(parseObj.episode_number[1])}` episode = `${Number(parseObj.episode_number[0])} ~ ${Number(parseObj.episode_number[1])}`
@ -162,24 +168,30 @@ export default new class AnimeResolver {
if (maxep && parseInt(parseObj.episode_number) > maxep) { if (maxep && parseInt(parseObj.episode_number) > maxep) {
// see big comment above // see big comment above
const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL')?.node || ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node)) const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL')?.node || ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node))
debug(`Prequel ${prequel && prequel.id}:${prequel && prequel.title.userPreferred}`) debug(`Prequel ${prequel?.id}:${prequel?.title?.userPreferred}`)
const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media
debug(`Root ${root && root.id}:${root && root.title.userPreferred}`) debug(`Root ${root?.id}:${root?.title?.userPreferred}`)
// value bigger than episode count // value bigger than episode count
const result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number), increment: !parseObj.anime_season ? null : true }) let result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number), increment: !parseObj.anime_season ? null : true })
debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
// last ditch attempt, see above
if (result.failed && parseObj.anime_season) {
result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number) })
}
debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia?.id}:${result.rootMedia?.title?.userPreferred} from ${media.id}:${media.title?.userPreferred}`)
media = result.rootMedia media = result.rootMedia
episode = result.episode episode = result.episode
failed = result.failed failed = result.failed
if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media?.title.userPreferred}`) if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media?.title?.userPreferred}`)
} else { } else {
// cant find ep count or episode seems fine // cant find ep count or episode seems fine
episode = Number(parseObj.episode_number) episode = Number(parseObj.episode_number)
} }
} }
} }
debug(`Resolved ${parseObj.anime_title} ${parseObj.episode_number} ${episode} ${media?.id}:${media?.title.userPreferred}`) debug(`Resolved ${parseObj.anime_title} ${parseObj.episode_number} ${episode} ${media?.id}:${media?.title?.userPreferred}`)
fileAnimes.push({ fileAnimes.push({
episode: episode || parseObj.episode_number, episode: episode || parseObj.episode_number,
parseObject: parseObj, parseObject: parseObj,

View file

@ -45,6 +45,19 @@ export function click (node, cb = noop) {
} }
} }
/**
* Adds hover event listener to the specified node.
* @param {HTMLElement} node - The node to attach the click event listener to.
* @param {Function} [hoverUpdate=noop] - The callback function to be executed on hover.
*/
export function hoverChange (node, hoverUpdate = noop) {
node.tabIndex = 0
node.role = 'button'
node.addEventListener('pointerleave', e => {
hoverUpdate()
})
}
// TODO: this needs to be re-written.... again... it should detect pointer type and have separate functionality for mouse and touch and none for dpad // TODO: this needs to be re-written.... again... it should detect pointer type and have separate functionality for mouse and touch and none for dpad
/** /**
* Adds hover and click event listeners to the specified node. * Adds hover and click event listeners to the specified node.

View file

@ -39,7 +39,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
throw new Error('No torrent sources configured. Add extensions in settings.') throw new Error('No torrent sources configured. Add extensions in settings.')
} }
debug(`Fetching sources for ${media.id}:${media.title.userPreferred} ${episode} ${batch} ${movie} ${resolution}`) debug(`Fetching sources for ${media?.id}:${media?.title?.userPreferred} ${episode} ${batch} ${movie} ${resolution}`)
const aniDBMeta = await ALToAniDB(media) const aniDBMeta = await ALToAniDB(media)
const anidbAid = aniDBMeta?.mappings?.anidb_id const anidbAid = aniDBMeta?.mappings?.anidb_id
@ -60,7 +60,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
const { results, errors } = await worker.query(options, { movie, batch }, settings.value.sources) const { results, errors } = await worker.query(options, { movie, batch }, settings.value.sources)
debug(`Found ${results.length} results`) debug(`Found ${results?.length} results`)
for (const error of errors) { for (const error of errors) {
debug(`Source Fetch Failed: ${error}`) debug(`Source Fetch Failed: ${error}`)
@ -82,7 +82,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
async function updatePeerCounts (entries) { async function updatePeerCounts (entries) {
const id = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString() const id = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString()
debug(`Updating peer counts for ${entries.length} entries`) debug(`Updating peer counts for ${entries?.length} entries`)
const updated = await Promise.race([ const updated = await Promise.race([
new Promise(resolve => { new Promise(resolve => {
@ -141,14 +141,14 @@ function getRelation (list, type) {
* @param {{episodes: any, episodeCount: number, specialCount: number}} param1 * @param {{episodes: any, episodeCount: number, specialCount: number}} param1
* */ * */
async function ALtoAniDBEpisode ({ media, episode }, { episodes, episodeCount, specialCount }) { async function ALtoAniDBEpisode ({ media, episode }, { episodes, episodeCount, specialCount }) {
debug(`Fetching AniDB episode for ${media.id}:${media.title.userPreferred} ${episode}`) debug(`Fetching AniDB episode for ${media?.id}:${media?.title?.userPreferred} ${episode}`)
if (!episode || !Object.values(episodes).length) return if (!episode || !Object.values(episodes).length) return
// if media has no specials or their episode counts don't match // if media has no specials or their episode counts don't match
if (!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) { if (!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) {
debug('No specials found, or episode count matches between AL and AniDB') debug('No specials found, or episode count matches between AL and AniDB')
return episodes[Number(episode)] return episodes[Number(episode)]
} }
debug(`Episode count mismatch between AL and AniDB for ${media.id}:${media.title.userPreferred}`) debug(`Episode count mismatch between AL and AniDB for ${media?.id}:${media?.title?.userPreferred}`)
const res = await anilistClient.episodeDate({ id: media.id, ep: episode }) const res = await anilistClient.episodeDate({ id: media.id, ep: episode })
// TODO: if media only has one episode, and airdate doesn't exist use start/release/end dates // TODO: if media only has one episode, and airdate doesn't exist use start/release/end dates
const alDate = new Date((res.data.AiringSchedule?.airingAt || 0) * 1000) const alDate = new Date((res.data.AiringSchedule?.airingAt || 0) * 1000)

363
common/modules/helper.js Normal file
View file

@ -0,0 +1,363 @@
import { alToken, malToken, isAuthorized } from '@/modules/settings.js'
import { anilistClient, codes } from '@/modules/anilist.js'
import { malClient } from '@/modules/myanimelist.js'
import { malDubs } from "@/modules/animedubs.js"
import { profiles } from '@/modules/settings.js'
import { toast } from 'svelte-sonner'
import { get } from 'svelte/store'
import Fuse from 'fuse.js'
import Debug from 'debug'
const debug = Debug('ui:helper')
export default class Helper {
static statusName = {
CURRENT: 'Watching',
PLANNING: 'Planning',
COMPLETED: 'Completed',
PAUSED: 'Paused',
DROPPED: 'Dropped',
REPEATING: 'Rewatching'
}
static sortMap(sort) {
switch(sort) {
case 'UPDATED_TIME_DESC':
return 'list_updated_at'
case 'STARTED_ON_DESC':
return 'list_start_date_nan' // doesn't exist, therefore we use custom logic.
case 'FINISHED_ON_DESC':
return 'list_finish_date_nan' // doesn't exist, therefore we use custom logic.
case 'PROGRESS_DESC':
return 'list_progress_nan' // doesn't exist, therefore we use custom logic.
case 'USER_SCORE_DESC':
return 'list_score'
}
}
static statusMap(status) {
switch(status) {
// MyAnimeList to AniList
case 'watching':
return 'CURRENT'
case 'rewatching':
return 'REPEATING' // rewatching is determined by is_rewatching boolean (no individual list)
case 'plan_to_watch':
return 'PLANNING'
case 'completed':
return 'COMPLETED'
case 'dropped':
return 'DROPPED'
case 'on_hold':
return 'PAUSED'
// AniList to MyAnimeList
case 'CURRENT':
return 'watching'
case 'PLANNING':
return 'plan_to_watch'
case 'COMPLETED':
return 'completed'
case 'DROPPED':
return 'dropped'
case 'PAUSED':
return 'on_hold'
case 'REPEATING':
return 'watching' // repeating is determined by is_rewatching boolean (no individual list)
}
}
static airingMap(status) {
switch(status) {
case 'finished_airing':
return 'FINISHED'
case 'currently_airing':
return 'RELEASING'
case 'not_yet_aired':
return 'NOT_YET_RELEASED'
}
}
static getFuzzyDate(media, status) {
const updatedDate = new Date()
const fuzzyDate = {
year: updatedDate.getFullYear(),
month: updatedDate.getMonth() + 1,
day: updatedDate.getDate()
}
const startedAt = media.mediaListEntry?.startedAt?.year && media.mediaListEntry?.startedAt?.month && media.mediaListEntry?.startedAt?.day ? media.mediaListEntry.startedAt : (['CURRENT', 'REPEATING'].includes(status) ? fuzzyDate : undefined)
const completedAt = media.mediaListEntry?.completedAt?.year && media.mediaListEntry?.completedAt?.month && media.mediaListEntry?.completedAt?.day ? media.mediaListEntry.completedAt : (status === 'COMPLETED' ? fuzzyDate : undefined)
return {startedAt, completedAt}
}
static sanitiseObject (object = {}) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
static isAniAuth() {
return alToken
}
static isMalAuth() {
return malToken
}
static isAuthorized() {
return isAuthorized()
}
static getClient() {
return this.isAniAuth() ? anilistClient : malClient
}
static getUser() {
return this.getClient().userID?.viewer?.data?.Viewer
}
static getUserAvatar() {
if (anilistClient.userID?.viewer?.data?.Viewer) {
return anilistClient.userID.viewer.data.Viewer.avatar.large || anilistClient.userID.viewer.data.Viewer.avatar.medium
} else if (malClient.userID?.viewer?.data?.Viewer) {
return malClient.userID.viewer.data.Viewer.picture
}
}
static isUserSort(variables) {
return ['UPDATED_TIME_DESC', 'STARTED_ON_DESC', 'FINISHED_ON_DESC', 'PROGRESS_DESC', 'USER_SCORE_DESC'].includes(variables?.sort)
}
static userLists(variables) {
return (!this.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC')
? this.getClient().userLists.value
: this.getClient().getUserLists({sort: (this.isAniAuth() ? variables.sort : this.sortMap(variables.sort))})
}
static async entry(media, variables) {
let res
if (!variables.token) {
res = await this.getClient().entry(variables)
media.mediaListEntry = res?.data?.SaveMediaListEntry
} else {
if (variables.anilist) {
res = await anilistClient.entry(variables)
} else {
res = await malClient.entry(variables)
}
}
return res
}
static async delete(variables) {
if (!variables.token) {
return await this.getClient().delete(variables)
} else {
if (variables.anilist) {
return await anilistClient.delete(variables)
} else {
return await malClient.delete(variables)
}
}
}
static matchTitle(media, phrase, keys) {
if (!phrase) {
return true
}
const options = {
includeScore: true,
threshold: 0.4,
keys: keys
}
return new Fuse([media], options).search(phrase).length > 0
}
/*
* This exists to fill in any queried AniList media with the user list media data from alternate authorizations.
*/
static async fillEntry(media) {
if (this.isMalAuth()) {
debug(`Filling MyAnimeList entry data for ${media?.id} (AniList)`)
const userLists = await malClient.userLists.value
const malEntry = userLists.data.MediaList.find(({ node }) => node.id === media.idMal)
if (malEntry) {
const start_date = malEntry.node.my_list_status.start_date ? new Date(malEntry.node.my_list_status.start_date) : undefined
const finish_date = malEntry.node.my_list_status.finish_date ? new Date(malEntry.node.my_list_status.finish_date) : undefined
const startedAt = start_date ? {
year: start_date.getFullYear(),
month: start_date.getMonth() + 1,
day: start_date.getDate()
} : undefined
const completedAt = finish_date ? {
year: finish_date.getFullYear(),
month: finish_date.getMonth() + 1,
day: finish_date.getDate()
} : undefined
media.mediaListEntry = {
id: media.id,
progress: malEntry.node.my_list_status.num_episodes_watched,
repeat: malEntry.node.my_list_status.number_times_rewatched,
status: this.statusMap(malEntry.node.my_list_status?.is_rewatching ? 'rewatching' : malEntry.node.my_list_status?.status),
customLists: [],
score: malEntry.node.my_list_status.score,
startedAt,
completedAt
}
}
}
}
static async updateEntry(filemedia) {
// check if values exist
if (filemedia.media && this.isAuthorized()) {
const { media, failed } = filemedia
debug(`Checking entry for ${media?.title?.userPreferred}`)
debug(`Media viability: ${media?.status}, Is from failed resolve: ${failed}`)
if (failed) return
if (media.status !== 'FINISHED' && media.status !== 'RELEASING') return
// check if media can even be watched, ex: it was resolved incorrectly
// some anime/OVA's can have a single episode, or some movies can have multiple episodes
const singleEpisode = ((!media.episodes && (Number(filemedia.episode) === 1 || isNaN(Number(filemedia.episode)))) || (media.format === 'MOVIE' && media.episodes === 1)) && 1 // movie check
const videoEpisode = Number(filemedia.episode) || singleEpisode
const mediaEpisode = media.episodes || media.nextAiringEpisode?.episode || singleEpisode
debug(`Episode viability: ${videoEpisode}, ${mediaEpisode}, ${singleEpisode}`)
if (!videoEpisode || !mediaEpisode) return
// check episode range, safety check if `failed` didn't catch this
if (videoEpisode > mediaEpisode) return
const lists = media.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || []
const status = media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
const progress = media.mediaListEntry?.progress
debug(`User's progress: ${progress}, Media's progress: ${videoEpisode}`)
// check user's own watch progress
if (progress > videoEpisode) return
if (progress === videoEpisode && videoEpisode !== mediaEpisode && !singleEpisode) return
debug(`Updating entry for ${media.title.userPreferred}`)
const variables = {
repeat: media.mediaListEntry?.repeat || 0,
id: media.id,
status,
score: (media.mediaListEntry?.score ? media.mediaListEntry?.score : 0),
episode: videoEpisode,
lists
}
if (videoEpisode === mediaEpisode) {
variables.status = 'COMPLETED'
if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1
}
Object.assign(variables, this.getFuzzyDate(media, status))
let res
const description = `Title: ${anilistClient.title(media)}\nStatus: ${this.statusName[variables.status]}\nEpisode: ${videoEpisode} / ${media.episodes ? media.episodes : '?'}`
if (this.isAniAuth()) {
res = await anilistClient.alEntry(lists, variables)
} else if (this.isMalAuth()) {
res = await malClient.malEntry(media, variables)
}
this.listToast(res, description, false)
if (this.getUser().sync) { // handle profile entry syncing
const mediaId = media.id
for (const profile of get(profiles)) {
if (profile.viewer?.data?.Viewer.sync) {
let res
if (profile.viewer?.data?.Viewer?.avatar) {
const currentLists = (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || []
res = await anilistClient.alEntry(currentLists, {...variables, token: profile.token})
} else {
res = await malClient.malEntry(media, {...variables, token: profile.token})
}
this.listToast(res, description, profile)
}
}
}
}
}
static listToast(res, description, profile){
const who = (profile ? ' for ' + profile.viewer.data.Viewer.name + (profile.viewer?.data?.Viewer?.avatar ? ' (AniList)' : ' (MyAnimeList)') : '')
if (res?.data?.mediaListEntry || res?.data?.SaveMediaListEntry) {
debug(`List Updated ${who}: ${description.replace(/\n/g, ', ')}`)
if (!profile) {
toast.success('List Updated', {
description,
duration: 6000
})
}
} else {
const error = `\n${429} - ${codes[429]}`
debug(`Error: Failed to update user list${who} with: ${description.replace(/\n/g, ', ')} ${error}`)
toast.error('Failed to Update List' + who, {
description: description + error,
duration: 9000
})
}
}
static getPaginatedMediaList(page, perPage, variables, mediaList) {
debug('Getting custom paged media list')
const ids = this.isAniAuth() ? mediaList.filter(({ media }) => {
if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(media.idMal)) &&
this.matchTitle(media, variables.search, ['title.userPreferred', 'title.english', 'title.romaji', 'title.native']) &&
(!variables.genre || variables.genre.map(genre => genre.trim().toLowerCase()).every(genre => media.genres.map(genre => genre.trim().toLowerCase()).includes(genre))) &&
(!variables.tag || variables.tag.map(tag => tag.trim().toLowerCase()).every(tag => media.tags.map(tag => tag.name.trim().toLowerCase()).includes(tag))) &&
(!variables.season || variables.season === media.season) &&
(!variables.year || variables.year === media.seasonYear) &&
(!variables.format || variables.format === media.format) &&
(!variables.status || variables.status === media.status) &&
(!variables.continueWatching || (media.status === 'FINISHED' || media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1))) {
return true
}
}).map(({ media }) => (this.isUserSort(variables) ? media : media.id)) : mediaList.filter(({ node }) => {
if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(node.id)) &&
this.matchTitle(node, variables.search, ['title', 'alternative_titles.en', 'alternative_titles.ja']) &&
(!variables.season || variables.season.toLowerCase() === node.start_season?.season.toLowerCase()) &&
(!variables.year || variables.year === node.start_season?.year) &&
(!variables.format || (variables.format !== 'TV_SHORT' && variables.format === node.media_type.toUpperCase()) || (variables.format === 'TV_SHORT' && node.average_episode_duration < 1200)) &&
(!variables.status || variables.status === 'CANCELLED' || variables.status === this.airingMap(node.status))) {
// api does provide airing episode or tags, additionally genres are inaccurate and tags do not exist.
return true
}
}).map(({ node }) => node.id)
if (!ids.length) return {}
if (this.isUserSort(variables)) {
debug(`Handling page media list with user specific sorting ${variables.sort}`)
const updatedVariables = { ...variables }
delete updatedVariables.sort // delete user sort as you can't sort by user specific sorting on AniList when logged into MyAnimeList.
const startIndex = (perPage * (page - 1))
const endIndex = startIndex + perPage
const paginatedIds = ids.slice(startIndex, endIndex)
const hasNextPage = ids.length > endIndex
const idIndexMap = paginatedIds.reduce((map, id, index) => { map[id] = index; return map }, {})
return this.isAniAuth() ? {
data: {
Page: {
pageInfo: {
hasNextPage: hasNextPage
},
media: paginatedIds
}
}
} : anilistClient.searchIDS({ page: 1, perPage, idMal: paginatedIds, ...this.sanitiseObject(updatedVariables) }).then(res => {
res.data.Page.pageInfo.hasNextPage = hasNextPage
res.data.Page.media = res.data.Page.media.sort((a, b) => { return idIndexMap[a.idMal] - idIndexMap[b.idMal] })
return res
})
} else {
debug(`Handling page media list with non-specific sorting ${variables.sort}`)
return anilistClient.searchIDS({ page, perPage, ...({[this.isAniAuth() ? 'id' : 'idMal']: ids}), ...this.sanitiseObject(variables) }).then(res => {
return res
})
}
}
}

51
common/modules/mal.d.ts vendored Normal file
View file

@ -0,0 +1,51 @@
export type Media = {
id: number
title: string
alternative_titles: {
synonyms: string[]
en: string
ja: string
}
rank?: number
nsfw?: string
media_type: string
status: string
my_list_status?: AnimeListStatus
start_season?: {
year: number
season: string
}
average_episode_duration?: number
related_anime?: {
node: Media
relation_type: string
relation_type_formatted: string
}[]
}
export type AnimeListStatus = {
status: string
score: number
num_episodes_watched: number
is_rewatching: boolean
start_date?: string
finish_date?: string
priority: number
number_times_rewatched: number
rewatch_value: number
updated_at: number
}
export type Viewer = {
id: number
name: string
picture: string
}
export type MediaList = {
node: Media
}[];
export type Query<T> = {
data: T
}

View file

@ -0,0 +1,298 @@
import { writable } from 'simple-store-svelte'
import Bottleneck from 'bottleneck'
import { malToken, refreshMalToken } from '@/modules/settings.js'
import { codes } from "@/modules/anilist.js"
import { toast } from 'svelte-sonner'
import { sleep } from "@/modules/util.js";
import Helper from '@/modules/helper.js'
import Debug from 'debug'
const debug = Debug('ui:myanimelist')
export const clientID = '813abe41d8df73cbc1fb39db129e8a87' // app type MUST be set to other, do not generate a seed.
function printError (error) {
debug(`Error: ${error.status || error || 429} - ${error.message || codes[error.status || error || 429]}`)
toast.error('Search Failed', {
description: `Failed making request to MyAnimeList!\nTry again in a minute.\n${error.status || error || 429} - ${error.message || codes[error.status || error || 429]}`,
duration: 3000
})
}
const queryFields = [
'synopsis',
'alternative_titles',
'mean',
'rank',
'popularity',
'num_list_users',
'num_scoring_users',
'related_anime',
'media_type',
'num_episodes',
'status',
'my_list_status',
'start_date',
'end_date',
'start_season',
'broadcast',
'studios',
'authors{first_name,last_name}',
'source',
'genres',
'average_episode_duration',
'rating'
]
class MALClient {
limiter = new Bottleneck({
reservoir: 20,
reservoirRefreshAmount: 20,
reservoirRefreshInterval: 4 * 1000,
maxConcurrent: 2,
minTime: 1000
})
rateLimitPromise = null
/** @type {import('simple-store-svelte').Writable<ReturnType<MALClient['getUserLists']>>} */
userLists = writable()
userID = malToken
constructor () {
debug('Initializing MyAnimeList Client for ID ' + this.userID?.viewer?.data?.Viewer?.id)
this.limiter.on('failed', async (error, jobInfo) => {
printError(error)
if (error.status === 500) return 1
if (!error.statusText) {
if (!this.rateLimitPromise) this.rateLimitPromise = sleep(5 * 1000).then(() => { this.rateLimitPromise = null })
return 5 * 1000
}
const time = ((error.headers.get('retry-after') || 5) + 1) * 1000
if (!this.rateLimitPromise) this.rateLimitPromise = sleep(time).then(() => { this.rateLimitPromise = null })
return time
})
if (this.userID?.viewer?.data?.Viewer) {
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
// update userLists every 15 mins
setInterval(() => {
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
}, 1000 * 60 * 15)
}
}
/**
* @param {Record<string, any>} query
* @param {Record<string, any>} body
* @returns {Promise<import('./mal').Query<any>>}
*/
malRequest (query, body = {}) {
/** @type {RequestInit} */
const options = {
method: `${query.type}`,
headers: {
'Authorization': `Bearer ${query.token ? query.token : this.userID.token}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
if (Object.keys(body).length > 0) {
options.body = new URLSearchParams(body)
}
return this.handleRequest(query, options)
}
/**
* @param {Record<string, any>} query
* @param {Record<string, any>} options
* @returns {Promise<import('./mal').Query<any>>}
*/
handleRequest = this.limiter.wrap(async (query, options) => {
await this.rateLimitPromise
let res = {}
try {
res = await fetch(`https://api.myanimelist.net/v2/${query.path}`, options)
} catch (e) {
if (!res || res.status !== 404) throw e
}
if (!res.ok && (res.status === 429 || res.status === 500)) {
throw res
}
let json = null
try {
json = await res.json()
} catch (error) {
if (res.ok) printError(error)
}
if (!res.ok && res.status !== 404) {
if (json) {
for (const error of json?.errors || [json?.error] || []) {
let code = error
switch (error) {
case 'forbidden':
code = 403
break
case 'invalid_token':
code = 401
const oauth = await refreshMalToken(query.token ? query.token : this.userID.token) // refresh authorization token as it typically expires every 31 days.
if (oauth) {
if (!query.token) {
this.userID = malToken
}
options.headers = {
'Authorization': `Bearer ${oauth.access_token}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
return this.handleRequest(query, options)
}
break
case 'invalid_content':
code = 422
break
default:
code = res.status
}
printError(code)
}
} else {
printError(res)
}
}
return json
})
async malEntry (media, variables) {
variables.idMal = media.idMal
const res = await malClient.entry(variables)
if (!variables.token) media.mediaListEntry = res?.data?.SaveMediaListEntry
return res
}
/** @returns {Promise<import('./mal').Query<{ MediaList: import('./mal').MediaList }>>} */
async getUserLists (variables) {
debug('Getting user lists')
const limit = 1000 // max possible you can fetch
let offset = 0
let allMediaList = []
let hasNextPage = true
// Check and replace specific sort values
const customSorts = ['list_start_date_nan', 'list_finish_date_nan', 'list_progress_nan']
if (customSorts.includes(variables.sort)) {
variables.originalSort = variables.sort
variables.sort = 'list_updated_at'
}
while (hasNextPage) {
const query = {
type: 'GET',
path: `users/@me/animelist?fields=${queryFields}&nsfw=true&limit=${limit}&offset=${offset}&sort=${variables.sort}`
}
const res = await this.malRequest(query)
allMediaList = allMediaList.concat(res?.data)
if (res?.data?.length < limit) {
hasNextPage = false
} else {
offset += limit
}
}
// Custom sorting based on original variables.sort value
if (variables.originalSort === 'list_start_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.start_date) - new Date(a.node.my_list_status.start_date)
})
} else if (variables.originalSort === 'list_finish_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.finish_date) - new Date(a.node.my_list_status.finish_date)
})
} else if (variables.originalSort === 'list_progress_nan') {
allMediaList.sort((a, b) => {
return b.node.my_list_status.num_episodes_watched - a.node.my_list_status.num_episodes_watched
})
}
return {
data: {
MediaList: allMediaList
}
}
}
/** @returns {Promise<import('./mal').Query<{ Viewer: import('./mal').Viewer }>>} */
async viewer (token) {
debug('Getting viewer')
const query = {
type: 'GET',
path: 'users/@me',
token
}
return {
data: {
Viewer: await this.malRequest(query)
}
}
}
async entry (variables) {
debug(`Updating entry for ${variables.idMal}`)
const query = {
type: 'PUT',
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const padNumber = (num) => num !== undefined && num !== null ? String(num).padStart(2, '0') : null
const start_date = variables.startedAt?.year && variables.startedAt.month && variables.startedAt.day ? `${variables.startedAt.year}-${padNumber(variables.startedAt.month)}-${padNumber(variables.startedAt.day)}` : null
const finish_date = variables.completedAt?.year && variables.completedAt.month && variables.completedAt.day ? `${variables.completedAt.year}-${padNumber(variables.completedAt.month)}-${padNumber(variables.completedAt.day)}` : null
const updateData = {
status: Helper.statusMap(variables.status),
is_rewatching: variables.status?.includes('REPEATING'),
num_watched_episodes: variables.episode || 0,
num_times_rewatched: variables.repeat || 0,
score: variables.score || 0
}
if (start_date) {
updateData.start_date = start_date
}
if (finish_date) {
updateData.finish_date = finish_date
}
const res = await this.malRequest(query, updateData)
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res ? {
data: {
SaveMediaListEntry: {
id: variables.id,
status: variables.status,
progress: variables.episode,
score: variables.score,
repeat: variables.repeat,
startedAt: variables.startedAt,
completedAt: variables.completedAt,
customLists: []
}
}
} : res
}
async delete (variables) {
debug(`Deleting entry for ${variables.idMal}`)
const query = {
type: 'DELETE',
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const res = await this.malRequest(query)
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res
}
}
export const malClient = new MALClient()

View file

@ -15,7 +15,7 @@ export default class Parser {
destroyed = false destroyed = false
constructor (client, file) { constructor (client, file) {
debug('Initializing parser for file: ' + file.name) debug('Initializing parser for file: ' + file?.name)
this.client = client this.client = client
this.file = file this.file = file
this.metadata = new Metadata(file) this.metadata = new Metadata(file)
@ -33,18 +33,18 @@ export default class Parser {
this.metadata.getChapters().then(chapters => { this.metadata.getChapters().then(chapters => {
if (this.destroyed) return if (this.destroyed) return
debug(`Found ${chapters.length} chapters`) debug(`Found ${chapters?.length} chapters`)
this.client.dispatch('chapters', chapters) this.client.dispatch('chapters', chapters)
}) })
this.metadata.getAttachments().then(files => { this.metadata.getAttachments().then(files => {
if (this.destroyed) return if (this.destroyed) return
debug(`Found ${files.length} attachments`) debug(`Found ${files?.length} attachments`)
for (const file of files) { for (const file of files) {
if (fontRx.test(file.filename) || file.mimetype?.toLowerCase().includes('font')) { if (fontRx.test(file.filename) || file.mimetype?.toLowerCase().includes('font')) {
const data = hex2bin(arr2hex(file.data)) const data = hex2bin(arr2hex(file.data))
if (SUPPORTS.isAndroid && data.length > 15_000_000) { if (SUPPORTS.isAndroid && data.length > 15_000_000) {
debug('Skipping large font file on Android: ' + file.filename) debug('Skipping large font file on Android: ' + file?.filename)
continue continue
} }
this.client.dispatch('file', data) this.client.dispatch('file', data)
@ -64,7 +64,7 @@ export default class Parser {
cb(this.metadata.parseStream(iterator)) cb(this.metadata.parseStream(iterator))
}) })
} else { } else {
debug('Unsupported file format: ' + this.file.name) debug('Unsupported file format: ' + this.file?.name)
} }
} }

View file

@ -107,7 +107,7 @@ class RSSMediaManager {
const res = await Promise.all(await results) const res = await Promise.all(await results)
const newReleases = res.filter(({ date }) => date > oldDate) const newReleases = res.filter(({ date }) => date > oldDate)
debug(`Found ${newReleases.length} new releases, notifying...`) debug(`Found ${newReleases?.length} new releases, notifying...`)
for (const { media, parseObject, episode } of newReleases) { for (const { media, parseObject, episode } of newReleases) {
const options = { const options = {
@ -133,7 +133,7 @@ class RSSMediaManager {
try { try {
res.episodeData = (await getEpisodeMetadataForMedia(res.media))?.[res.episode] res.episodeData = (await getEpisodeMetadataForMedia(res.media))?.[res.episode]
} catch (e) { } catch (e) {
debug(`Warn: failed fetching episode metadata for ${res.media.title.userPreferred} episode ${res.episode}: ${e.stack}`) debug(`Warn: failed fetching episode metadata for ${res.media.title?.userPreferred} episode ${res.episode}: ${e.stack}`)
} }
} }
res.date = items[i].date res.date = items[i].date

View file

@ -28,6 +28,15 @@ export default function (t, { speed = 120, smooth = 10 } = {}) {
return deltaTime / 14 return deltaTime / 14
} }
t.addEventListener('scrolltop', () => {
pos = 0
t.scrollTop = scrollTop
if (!moving) {
lastTime = null
update()
}
})
t.addEventListener('pointerup', () => { pos = scrollTop = t.scrollTop }) t.addEventListener('pointerup', () => { pos = scrollTop = t.scrollTop })
function update () { function update () {

View file

@ -1,7 +1,9 @@
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js' import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
import { malDubs } from "@/modules/animedubs.js"
import { writable } from 'simple-store-svelte' import { writable } from 'simple-store-svelte'
import { settings, alToken } from '@/modules/settings.js' import { settings } from '@/modules/settings.js'
import { RSSManager } from '@/modules/rss.js' import { RSSManager } from '@/modules/rss.js'
import Helper from '@/modules/helper.js'
export const hasNextPage = writable(true) export const hasNextPage = writable(true)
@ -24,8 +26,13 @@ export default class SectionsManager {
static createFallbackLoad (variables, type) { static createFallbackLoad (variables, type) {
return (page = 1, perPage = 50, search = variables) => { return (page = 1, perPage = 50, search = variables) => {
const options = { page, perPage, ...SectionsManager.sanitiseObject(search) } const hideSubs = search.hideSubs ? { idMal: malDubs.dubLists.value.dubbed } : {}
const res = anilistClient.search(options) const res = (search.hideMyAnime && Helper.isAuthorized()) ? Helper.userLists(search).then(res => {
// anilist queries do not support mix and match, you have to use the same id includes as excludes, id_not_in cannot be used with idMal_in.
const hideMyAnime = Helper.isAniAuth() ? { [Object.keys(hideSubs).length > 0 ? 'idMal_not' : 'id_not']: Array.from(new Set(res.data.MediaListCollection.lists.filter(({ status }) => search.hideStatus.includes(status)).flatMap(list => list.entries.map(({ media }) => (Object.keys(hideSubs).length > 0 ? media.idMal : media.id))))) }
: {idMal_not: res.data.MediaList.filter(({ node }) => search.hideStatus.includes(Helper.statusMap(node.my_list_status.status))).map(({ node }) => node.id)}
return anilistClient.search({ page, perPage, ...hideSubs, ...hideMyAnime, ...SectionsManager.sanitiseObject(search) })
}) : anilistClient.search({ page, perPage, ...hideSubs, ...SectionsManager.sanitiseObject(search) })
return SectionsManager.wrapResponse(res, perPage, type) return SectionsManager.wrapResponse(res, perPage, type)
} }
} }
@ -37,18 +44,12 @@ export default class SectionsManager {
return Array.from({ length }, (_, i) => ({ type, data: SectionsManager.fromPending(res, i) })) return Array.from({ length }, (_, i) => ({ type, data: SectionsManager.fromPending(res, i) }))
} }
static sanitiseObject (object = {}) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
static async fromPending (arr, i) { static async fromPending (arr, i) {
const { data } = await arr const { data } = await arr
return data?.Page.media[i] return data?.Page.media[i]
} }
static sanitiseObject = Helper.sanitiseObject
} }
// list of all possible home screen sections // list of all possible home screen sections
@ -82,114 +83,114 @@ function createSections () {
}), }),
// user specific sections // user specific sections
{ {
title: 'Continue Watching', title: 'Sequels You Missed', variables : { sort: 'POPULARITY_DESC', userList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(async res => { if (Helper.isMalAuth()) return {} // not going to bother handling this, see below.
const mediaList = res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => { const res = Helper.userLists(variables).then(res => {
return (status === 'CURRENT' || status === 'REPEATING') ? filtered.concat(entries) : filtered
}, [])
const ids = mediaList.filter(({ media }) => {
if (media.status === 'FINISHED') return true
return media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1
}).map(({ media }) => media.id)
if (!ids.length) return {}
// if custom search is used, respect it, otherwise sort by last updated
if (Object.values(variables).length !== 0) {
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
}
const index = (page - 1) * perPage
const idsRes = await anilistClient.searchIDS({ page, perPage, id: ids.slice(index, index + perPage), ...SectionsManager.sanitiseObject(variables) })
idsRes.data.Page.media.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))
return idsRes
})
return SectionsManager.wrapResponse(res, perPage)
},
hide: !alToken
},
{
title: 'Sequels You Missed',
load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => {
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
const excludeIds = res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => { return (['CURRENT', 'REPEATING', 'COMPLETED', 'DROPPED', 'PAUSED'].includes(status)) ? filtered.concat(entries) : filtered}, []).map(({ media }) => media.id) || []
if (!mediaList) return {} if (!mediaList) return {}
const ids = mediaList.flatMap(({ media }) => { const ids = mediaList.flatMap(({ media }) => {
return media.relations.edges.filter(edge => edge.relationType === 'SEQUEL') return media.relations.edges.filter(edge => edge.relationType === 'SEQUEL')
}).map(({ node }) => node.id) }).map(({ node }) => node.id)
if (!ids.length) return {} if (!ids.length) return {}
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables), status: ['FINISHED', 'RELEASING'], onList: false }) return anilistClient.searchIDS({ page, perPage, id: ids, id_not: excludeIds, ...SectionsManager.sanitiseObject(variables), status: ['FINISHED', 'RELEASING'] })
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized() || Helper.isMalAuth() // disable this section when authenticated with MyAnimeList. API for userLists fail to return relations and likely will never be fixed on their end.
}, },
{ {
title: 'Your List', title: 'Continue Watching', variables: { sort: 'UPDATED_TIME_DESC', userList: true, continueWatching: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => { const res = Helper.userLists(variables).then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries.map(({ media }) => media.id) const mediaList = Helper.isAniAuth() ? res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => {
if (!ids) return {} return (status === 'CURRENT' || status === 'REPEATING') ? filtered.concat(entries) : filtered
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) }) }, []) : res.data.MediaList.filter(({ node }) => (node.my_list_status.status === Helper.statusMap('CURRENT') || node.my_list_status.is_rewatching))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized()
}, },
{ {
title: 'Completed List', title: 'Watching List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => { const res = Helper.userLists(variables).then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries.map(({ media }) => media.id) const mediaList = Helper.isAniAuth()
if (!ids) return {} ? res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) }) : res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('CURRENT'))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized()
}, },
{ {
title: 'Paused List', title: 'Completed List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, completedList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => { const res = Helper.userLists(variables).then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries.map(({ media }) => media.id) const mediaList = Helper.isAniAuth()
if (!ids) return {} ? res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) }) : res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('COMPLETED'))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized()
}, },
{ {
title: 'Dropped List', title: 'Planning List', variables : { test: 'Planning', sort: 'POPULARITY_DESC', userList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => { const res = Helper.userLists(variables).then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'DROPPED')?.entries.map(({ media }) => media.id) const mediaList = Helper.isAniAuth()
if (!ids) return {} ? res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) }) : res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('PLANNING'))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized()
}, },
{ {
title: 'Currently Watching List', title: 'Paused List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => { load: (page = 1, perPage = 50, variables = {}) => {
const res = anilistClient.userLists.value.then(res => { const res = Helper.userLists(variables).then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries.map(({ media }) => media.id) const mediaList = Helper.isAniAuth()
if (!ids) return {} ? res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) }) : res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('PAUSED'))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
}) })
return SectionsManager.wrapResponse(res, perPage) return SectionsManager.wrapResponse(res, perPage)
}, },
hide: !alToken hide: !Helper.isAuthorized()
},
{
title: 'Dropped List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
load: (page = 1, perPage = 50, variables = {}) => {
const res = Helper.userLists(variables).then(res => {
const mediaList = Helper.isAniAuth()
? res.data.MediaListCollection.lists.find(({ status }) => status === 'DROPPED')?.entries
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('DROPPED'))
if (!mediaList) return {}
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
})
return SectionsManager.wrapResponse(res, perPage)
},
hide: !Helper.isAuthorized()
}, },
// common, non-user specific sections // common, non-user specific sections
{ title: 'Popular This Season', variables: { sort: 'POPULARITY_DESC', season: currentSeason, year: currentYear } }, { title: 'Popular This Season', variables: { sort: 'POPULARITY_DESC', season: currentSeason, year: currentYear, hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'Trending Now', variables: { sort: 'TRENDING_DESC' } }, { title: 'Trending Now', variables: { sort: 'TRENDING_DESC', hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'All Time Popular', variables: { sort: 'POPULARITY_DESC' } }, { title: 'All Time Popular', variables: { sort: 'POPULARITY_DESC', hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'Romance', variables: { sort: 'TRENDING_DESC', genre: 'Romance' } }, { title: 'Romance', variables: { sort: 'TRENDING_DESC', genre: ['Romance'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'Action', variables: { sort: 'TRENDING_DESC', genre: 'Action' } }, { title: 'Action', variables: { sort: 'TRENDING_DESC', genre: ['Action'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'Adventure', variables: { sort: 'TRENDING_DESC', genre: 'Adventure' } }, { title: 'Adventure', variables: { sort: 'TRENDING_DESC', genre: ['Adventure'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
{ title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: 'Fantasy' } } { title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: ['Fantasy'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } }
] ]
} }

View file

@ -1,14 +1,16 @@
import { writable } from 'simple-store-svelte' import { get, writable } from 'simple-store-svelte'
import { defaults } from './util.js' import { defaults } from './util.js'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import { anilistClient } from './anilist.js'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import Debug from 'debug' import Debug from 'debug'
const debug = Debug('ui:anilist') const debug = Debug('ui:anilist')
export let profiles = writable(JSON.parse(localStorage.getItem('profiles')) || [])
/** @type {{viewer: import('./al').Query<{Viewer: import('./al').Viewer}>, token: string} | null} */ /** @type {{viewer: import('./al').Query<{Viewer: import('./al').Viewer}>, token: string} | null} */
export let alToken = JSON.parse(localStorage.getItem('ALviewer')) || null export let alToken = JSON.parse(localStorage.getItem('ALviewer')) || null
/** @type {{viewer: import('./mal').Query<{Viewer: import('./mal').Viewer}>, token: string} | null} */
export let malToken = JSON.parse(localStorage.getItem('MALviewer')) || null
let storedSettings = { ...defaults } let storedSettings = { ...defaults }
@ -19,7 +21,7 @@ try {
} catch (e) {} } catch (e) {}
try { try {
scopedDefaults = { scopedDefaults = {
homeSections: [...(storedSettings.rssFeedsNew || defaults.rssFeedsNew).map(([title]) => title), 'Continue Watching', 'Sequels You Missed', 'Your List', 'Popular This Season', 'Trending Now', 'All Time Popular', 'Romance', 'Action', 'Adventure', 'Fantasy', 'Comedy'] homeSections: [...(storedSettings.rssFeedsNew || defaults.rssFeedsNew).map(([title]) => title), 'Continue Watching', 'Sequels You Missed', 'Planning List', 'Popular This Season', 'Trending Now', 'All Time Popular', 'Romance', 'Action', 'Adventure', 'Fantasy']
} }
} catch (e) { } catch (e) {
resetSettings() resetSettings()
@ -35,26 +37,45 @@ settings.subscribe(value => {
localStorage.setItem('settings', JSON.stringify(value)) localStorage.setItem('settings', JSON.stringify(value))
}) })
profiles.subscribe(value => {
localStorage.setItem('profiles', JSON.stringify(value))
})
export function resetSettings () { export function resetSettings () {
settings.value = { ...defaults, ...scopedDefaults } settings.value = { ...defaults, ...scopedDefaults }
} }
export function isAuthorized() {
return alToken || malToken
}
window.addEventListener('paste', ({ clipboardData }) => { window.addEventListener('paste', ({ clipboardData }) => {
if (clipboardData.items?.[0]) { if (clipboardData.items?.[0]) {
if (clipboardData.items[0].type === 'text/plain' && clipboardData.items[0].kind === 'string') { if (clipboardData.items[0].type === 'text/plain' && clipboardData.items[0].kind === 'string') {
clipboardData.items[0].getAsString(text => { clipboardData.items[0].getAsString(text => {
if (text.includes("access_token=")) { // is an AniList token
let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0] let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0]
if (token) { if (token) {
if (token.endsWith('/')) token = token.slice(0, -1) if (token.endsWith('/')) token = token.slice(0, -1)
handleToken(token) handleToken(token)
} }
} else if (text.includes("code=") && text.includes("&state")) { // is a MyAnimeList authorization
let code = line.split('code=')[1].split('&state')[0]
let state = line.split('&state=')[1]
if (code && state) {
if (code.endsWith('/')) code = code.slice(0, -1)
if (state.endsWith('/')) state = state.slice(0, -1)
if (state.includes('%')) state = decodeURIComponent(state)
handleMalToken(code, state)
}
}
}) })
} }
} }
}) })
IPC.on('altoken', handleToken) IPC.on('altoken', handleToken)
async function handleToken (token) { async function handleToken (token) {
alToken = { token, viewer: null } const { anilistClient} = await import('./anilist.js')
const viewer = await anilistClient.viewer({token}) const viewer = await anilistClient.viewer({token})
if (!viewer.data?.Viewer) { if (!viewer.data?.Viewer) {
toast.error('Failed to sign in with AniList. Please try again.', {description: JSON.stringify(viewer)}) toast.error('Failed to sign in with AniList. Please try again.', {description: JSON.stringify(viewer)})
@ -65,6 +86,152 @@ async function handleToken (token) {
if (!lists.includes('Watched using Migu')) { if (!lists.includes('Watched using Migu')) {
await anilistClient.customList({lists}) await anilistClient.customList({lists})
} }
localStorage.setItem('ALviewer', JSON.stringify({ token, viewer })) swapProfiles({token, viewer})
location.reload() location.reload()
} }
IPC.on('maltoken', handleMalToken)
async function handleMalToken (code, state) {
const { clientID, malClient } = await import('./myanimelist.js')
if (!state || !code) {
toast.error('Failed to sign in with MyAnimeList. Please try again.')
debug(`Failed to get the state and code from MyAnimeList.`)
return
}
const response = await fetch('https://myanimelist.net/v1/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientID,
grant_type: 'authorization_code',
code: code,
code_verifier: sessionStorage.getItem(state)
})
})
if (!response.ok) {
toast.error('Failed to sign in with MyAnimeList. Please try again.', { description: JSON.stringify(response.status) })
debug(`Failed to get MyAnimeList User Token: ${JSON.stringify(response)}`)
return
}
const oauth = await response.json()
const viewer = await malClient.viewer(oauth.access_token)
if (!viewer?.data?.Viewer?.id) {
toast.error('Failed to sign in with MyAnimeList. Please try again.', { description: JSON.stringify(viewer) })
debug(`Failed to sign in with MyAnimeList: ${JSON.stringify(viewer)}`)
return
} else if (!viewer?.data?.Viewer?.picture) {
viewer.data.Viewer.picture = 'https://cdn.myanimelist.net/images/kaomoji_mal_white.png' // set default image if user doesn't have an image.
}
swapProfiles({ token: oauth.access_token, refresh: oauth.refresh_token, viewer })
location.reload()
}
export async function refreshMalToken (token) {
const { clientID } = await import('./myanimelist.js')
const refresh = malToken?.token === token ? malToken.refresh : get(profiles).find(profile => profile.token === token)?.refresh
let response
if (!refresh || !(refresh.length > 0)) {
response = await fetch('https://myanimelist.net/v1/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientID,
grant_type: 'refresh_token',
refresh_token: refresh
})
})
}
if (!refresh || !(refresh.length > 0) || !response.ok) {
toast.error('Failed to re-authenticate with MyAnimeList. You will need to log in again.', { description: JSON.stringify(response.status) })
debug(`Failed to refresh MyAnimeList User Token ${ !refresh || !(refresh.length > 0) ? 'as the refresh token could not be fetched!' : ': ' + JSON.stringify(response)}`)
if (malToken?.token === token) {
swapProfiles(null)
location.reload()
} else {
profiles.update(profiles =>
profiles.filter(profile => profile.token !== token)
)
}
return
}
const oauth = await response.json()
if (malToken?.token === token) {
const viewer = malToken.viewer
malToken = { token: oauth.access_token, refresh: oauth.refresh_token, viewer: viewer }
localStorage.setItem('MALviewer', JSON.stringify({ token: oauth.access_token, refresh: oauth.refresh_token, viewer }))
} else {
profiles.update(profiles =>
profiles.map(profile => {
if (profile.token === token) {
return { ...profile, token: oauth.access_token, refresh: oauth.refresh_token }
}
return profile
})
)
}
return oauth
}
export function swapProfiles(profile) {
const currentProfile = isAuthorized()
const newProfile = profile !== null && !get(profiles).some(p => p.viewer?.data?.Viewer?.id === currentProfile?.viewer?.data?.Viewer?.id)
if (currentProfile) {
const torrent = localStorage.getItem('torrent')
const lastFinished = localStorage.getItem('lastFinished')
const settings = localStorage.getItem('settings')
if (torrent) currentProfile.viewer.data.Viewer.torrent = torrent
if (lastFinished) currentProfile.viewer.data.Viewer.lastFinished = lastFinished
if (settings) currentProfile.viewer.data.Viewer.settings = settings
if (newProfile) profiles.update(currentProfiles => [currentProfile, ...currentProfiles])
}
localStorage.removeItem(alToken ? 'ALviewer' : 'MALviewer')
if (profile === null && get(profiles).length > 0) {
let firstProfile
profiles.update(profiles => {
firstProfile = profiles[0]
setViewer(firstProfile)
return profiles.slice(1)
})
} else if (profile !== null) {
setViewer(profile)
profiles.update(profiles =>
profiles.filter(p => p.viewer?.data?.Viewer?.id !== profile.viewer?.data?.Viewer?.id)
)
} else {
alToken = null
malToken = null
}
}
function setViewer (profile) {
const { torrent, lastFinished, settings } = profile?.viewer?.data?.Viewer
if (torrent) {
localStorage.setItem('torrent', torrent)
} else if (isAuthorized()) {
localStorage.removeItem('torrent')
}
if (lastFinished) {
localStorage.setItem('lastFinished', lastFinished)
} else if (isAuthorized()) {
localStorage.removeItem('lastFinished')
}
if (settings) {
localStorage.setItem('settings', settings)
} else if (isAuthorized()) {
localStorage.setItem('settings', writable({ ...defaults, ...scopedDefaults}))
}
if (profile?.viewer?.data?.Viewer?.avatar) {
alToken = profile
malToken = null
} else {
malToken = profile
alToken = null
}
localStorage.setItem(profile.viewer?.data?.Viewer?.avatar ? 'ALviewer' : 'MALviewer', JSON.stringify(profile))
}

View file

@ -1,4 +1,5 @@
import { SUPPORTS } from '@/modules/support.js' import { SUPPORTS } from '@/modules/support.js'
import levenshtein from 'js-levenshtein'
export function countdown (s) { export function countdown (s) {
const d = Math.floor(s / (3600 * 24)) const d = Math.floor(s / (3600 * 24))
@ -93,6 +94,32 @@ export function generateRandomHexCode (len) {
return hexCode return hexCode
} }
export function generateRandomString(length) {
let string = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
for (let i = 0; i < length; i++) {
string += possible.charAt(Math.floor(Math.random() * possible.length))
}
return string
}
export function matchPhrase(search, phrase, threshold) {
if (!search) return false
const normalizedSearch = search.toLowerCase().replace(/[^\w\s]/g, '')
phrase = Array.isArray(phrase) ? phrase : [phrase]
for (let p of phrase) {
const normalizedPhrase = p.toLowerCase().replace(/[^\w\s]/g, '')
if (normalizedSearch.includes(normalizedPhrase)) return true
const wordsInFileName = normalizedSearch.split(/\s+/)
for (let word of wordsInFileName) {
if (levenshtein(word, normalizedPhrase) <= threshold) return true
}
}
return false
}
export function throttle (fn, time) { export function throttle (fn, time) {
let wait = false let wait = false
return (...args) => { return (...args) => {
@ -127,7 +154,6 @@ export const defaults = {
playerAutocomplete: true, playerAutocomplete: true,
playerAutoSkip: false, playerAutoSkip: false,
playerDeband: false, playerDeband: false,
playerSeek: 5,
rssQuality: '1080', rssQuality: '1080',
rssFeedsNew: SUPPORTS.extensions ? [['New Releases', 'ASW [Small Size]']] : [], rssFeedsNew: SUPPORTS.extensions ? [['New Releases', 'ASW [Small Size]']] : [],
rssAutoplay: false, rssAutoplay: false,
@ -157,6 +183,9 @@ export const defaults = {
showDetailsInRPC: true, showDetailsInRPC: true,
smoothScroll: false, smoothScroll: false,
cards: 'small', cards: 'small',
cardAudio: false,
titleLang: 'english',
hideMyAnime: false,
expandingSidebar: !SUPPORTS.isAndroid, expandingSidebar: !SUPPORTS.isAndroid,
torrentPathNew: undefined, torrentPathNew: undefined,
font: undefined, font: undefined,
@ -165,7 +194,8 @@ export const defaults = {
extensions: SUPPORTS.extensions ? ['anisearch'] : [], extensions: SUPPORTS.extensions ? ['anisearch'] : [],
sources: {}, sources: {},
enableExternal: false, enableExternal: false,
playerPath: '' playerPath: '',
playerSeek: 5
} }
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt'] export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']

View file

@ -143,7 +143,7 @@ export default class TorrentClient extends WebTorrent {
} }
async torrentReady (torrent) { async torrentReady (torrent) {
debug('Got torrent metadata: ' + torrent.name) debug('Got torrent metadata: ' + torrent?.name)
const files = torrent.files.map(file => { const files = torrent.files.map(file => {
return { return {
infoHash: torrent.infoHash, infoHash: torrent.infoHash,
@ -196,7 +196,7 @@ export default class TorrentClient extends WebTorrent {
const subfiles = files.filter(file => { const subfiles = files.filter(file => {
return subRx.test(file.name) && (videoFiles.length === 1 ? true : file.name.includes(videoName)) return subRx.test(file.name) && (videoFiles.length === 1 ? true : file.name.includes(videoName))
}) })
debug(`Found ${subfiles.length} subtitle files`) debug(`Found ${subfiles?.length} subtitle files`)
for (const file of subfiles) { for (const file of subfiles) {
const data = await file.arrayBuffer() const data = await file.arrayBuffer()
if (targetFile !== this.current) return if (targetFile !== this.current) return

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View file

@ -2,6 +2,8 @@
import SectionsManager from '@/modules/sections.js' import SectionsManager from '@/modules/sections.js'
import Search, { search } from './Search.svelte' import Search, { search } from './Search.svelte'
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js' import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
import { malDubs } from "@/modules/animedubs.js"
import Helper from "@/modules/helper.js"
const vars = { format: 'TV', season: currentSeason, year: currentYear } const vars = { format: 'TV', season: currentSeason, year: currentYear }
@ -9,20 +11,26 @@
const variables = { ..._variables } const variables = { ..._variables }
const results = { data: { Page: { media: [], pageInfo: { hasNextPage: false } } } } const results = { data: { Page: { media: [], pageInfo: { hasNextPage: false } } } }
const opts = { ...vars, ...SectionsManager.sanitiseObject(variables) } const opts = { ...vars, ...SectionsManager.sanitiseObject(variables) }
const hideSubs = variables.hideSubs ? { idMal: malDubs.dubLists.value.dubbed } : {}
const hideMyAnime = (variables.hideMyAnime && Helper.isAuthorized()) ? {[Helper.isAniAuth() ? 'id_not' : 'idMal_not']:
await Helper.userLists(variables).then(res => {
return Helper.isAniAuth()
? Array.from(new Set(res.data.MediaListCollection.lists.filter(({ status }) => variables.hideStatus.includes(status)).flatMap(list => list.entries.map(({ media }) => media.id))))
: res.data.MediaList.filter(({ node }) => variables.hideStatus.includes(Helper.statusMap(node.my_list_status.status))).map(({ node }) => node.id)
})} : {}
for (let page = 1, hasNextPage = true; hasNextPage && page < 5; ++page) { for (let page = 1, hasNextPage = true; hasNextPage && page < 5; ++page) {
const res = await anilistClient.search({ ...opts, page, perPage: 50 }) const res = await anilistClient.search({ ...opts, ...hideSubs, ...hideMyAnime, page, perPage: 50 })
hasNextPage = res.data.Page.pageInfo.hasNextPage hasNextPage = res.data.Page.pageInfo.hasNextPage
results.data.Page.media = results.data.Page.media.concat(res.data.Page.media) results.data.Page.media = results.data.Page.media.concat(res.data.Page.media)
} }
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL'] const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
const season = seasons.at(seasons.indexOf(vars.season) - 1) const season = seasons.at(seasons.indexOf(vars.season) - 1)
const year = vars.season === 'WINTER' ? vars.year - 1 : vars.year const year = vars.season === 'WINTER' ? vars.year - 1 : vars.year
const res = await anilistClient.search({ format: 'TV', ...SectionsManager.sanitiseObject(variables), year, season, status: 'RELEASING', page: 1, perPage: 50 }) const res = await anilistClient.search({ ...hideSubs, ...hideMyAnime, format: 'TV', ...SectionsManager.sanitiseObject(variables), year, season, status: 'RELEASING', page: 1, perPage: 50 })
results.data.Page.media = results.data.Page.media.concat(res.data.Page.media) results.data.Page.media = results.data.Page.media.concat(res.data.Page.media)
// filter out entries without airing schedule and duplicates [only allow first occurence] // filter out entries without airing schedule and duplicates [only allow first occurrence]
results.data.Page.media = results.data.Page.media.filter((media, index, self) => media.airingSchedule?.nodes?.[0]?.airingAt && self.findIndex(m => m.id === media.id) === index) results.data.Page.media = results.data.Page.media.filter((media, index, self) => media.airingSchedule?.nodes?.[0]?.airingAt && self.findIndex(m => m.id === media.id) === index)
results.data.Page.media.sort((a, b) => a.airingSchedule?.nodes?.[0]?.airingAt - b.airingSchedule?.nodes?.[0]?.airingAt) results.data.Page.media.sort((a, b) => a.airingSchedule?.nodes?.[0]?.airingAt - b.airingSchedule?.nodes?.[0]?.airingAt)

View file

@ -2,6 +2,7 @@
import SectionsManager, { sections } from '@/modules/sections.js' import SectionsManager, { sections } from '@/modules/sections.js'
import { settings } from '@/modules/settings.js' import { settings } from '@/modules/settings.js'
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js' import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
import Helper from '@/modules/helper.js'
const bannerData = anilistClient.search({ method: 'Search', sort: 'POPULARITY_DESC', perPage: 15, onList: false, season: currentSeason, year: currentYear, status_not: 'NOT_YET_RELEASED' }) const bannerData = anilistClient.search({ method: 'Search', sort: 'POPULARITY_DESC', perPage: 15, onList: false, season: currentSeason, year: currentYear, status_not: 'NOT_YET_RELEASED' })
@ -15,14 +16,13 @@
for (const sectionTitle of settings.value.homeSections) manager.add(mappedSections[sectionTitle]) for (const sectionTitle of settings.value.homeSections) manager.add(mappedSections[sectionTitle])
if (anilistClient.userID?.viewer?.data?.Viewer) { if (Helper.getUser()) {
const userSections = ['Continue Watching', 'Sequels You Missed', 'Your List', 'Completed List', 'Paused List', 'Dropped List', 'Currently Watching List'] const userSections = ['Continue Watching', 'Sequels You Missed', 'Planning List', 'Completed List', 'Paused List', 'Dropped List', 'Watching List']
Helper.getClient().userLists.subscribe(value => {
anilistClient.userLists.subscribe(value => {
if (!value) return if (!value) return
for (const section of manager.sections) { for (const section of manager.sections) {
// remove preview value, to force UI to re-request data, which updates it once in viewport // remove preview value, to force UI to re-request data, which updates it once in viewport
if (userSections.includes(section.title)) section.preview.value = section.load(1, 15) if (userSections.includes(section.title)) section.preview.value = section.load(1, 15, section.variables)
} }
}) })
} }

View file

@ -15,7 +15,7 @@
function deferredLoad (element) { function deferredLoad (element) {
const observer = new IntersectionObserver(([entry]) => { const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
if (!opts.preview.value) opts.preview.value = opts.load(1, 15) if (!opts.preview.value) opts.preview.value = opts.load(1, 15, { ...opts.variables })
observer.unobserve(element) observer.unobserve(element)
} }
}, { threshold: 0 }) }, { threshold: 0 })
@ -27,7 +27,8 @@
function _click () { function _click () {
$search = { $search = {
...opts.variables, ...opts.variables,
load: opts.load load: opts.load,
title: opts.title,
} }
$page = 'search' $page = 'search'
} }
@ -46,7 +47,7 @@
class:fader={!SUPPORTS.isAndroid} class:fader={!SUPPORTS.isAndroid}
> >
{#each $preview || fakecards as card} {#each $preview || fakecards as card}
<Card {card} /> <Card {card} variables={{...opts.variables}} />
{/each} {/each}
{#if $preview?.length} {#if $preview?.length}
<ErrorCard promise={$preview[0].data} /> <ErrorCard promise={$preview[0].data} />

View file

@ -5,6 +5,7 @@
import { tick } from 'svelte' import { tick } from 'svelte'
import { state } from '../WatchTogether/WatchTogether.svelte' import { state } from '../WatchTogether/WatchTogether.svelte'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import { anilistClient } from "@/modules/anilist.js"
import Debug from 'debug' import Debug from 'debug'
const debug = Debug('ui:mediahandler') const debug = Debug('ui:mediahandler')
@ -63,7 +64,7 @@
const np = { const np = {
media, media,
title: media?.title.userPreferred || parseObject.anime_title, title: anilistClient.title(media) || parseObject.anime_title,
episode: ep, episode: ep,
episodeTitle: streamingEpisode && episodeRx.exec(streamingEpisode.title)[2], episodeTitle: streamingEpisode && episodeRx.exec(streamingEpisode.title)[2],
thumbnail: streamingEpisode?.thumbnail || media?.coverImage.extraLarge thumbnail: streamingEpisode?.thumbnail || media?.coverImage.extraLarge
@ -112,11 +113,11 @@
} }
function fileListToDebug (files) { function fileListToDebug (files) {
return files.map(({ name, media, url }) => `\n${name} ${media?.parseObject.anime_title} ${media?.parseObject.episode_number} ${media?.media?.title.userPreferred} ${media?.episode}`).join('') return files?.map(({ name, media, url }) => `\n${name} ${media?.parseObject?.anime_title} ${media?.parseObject?.episode_number} ${media?.media?.title?.userPreferred} ${media?.episode}`).join('')
} }
async function handleFiles (files) { async function handleFiles (files) {
debug(`Got ${files.length} files`, fileListToDebug(files)) debug(`Got ${files?.length} files`, fileListToDebug(files))
if (!files?.length) return processed.set(files) if (!files?.length) return processed.set(files)
let videoFiles = [] let videoFiles = []
const otherFiles = [] const otherFiles = []
@ -144,11 +145,11 @@
if (nowPlaying.episode) videoFiles[0].media.episode = nowPlaying.episode if (nowPlaying.episode) videoFiles[0].media.episode = nowPlaying.episode
} }
debug(`Resolved ${videoFiles.length} video files`, fileListToDebug(videoFiles)) debug(`Resolved ${videoFiles?.length} video files`, fileListToDebug(videoFiles))
if (!nowPlaying) { if (!nowPlaying) {
nowPlaying = findPreferredPlaybackMedia(videoFiles) nowPlaying = findPreferredPlaybackMedia(videoFiles)
debug(`Found preferred playback media: ${nowPlaying.media?.id}:${nowPlaying.media?.title.userPreferred} ${nowPlaying.episode}`) debug(`Found preferred playback media: ${nowPlaying?.media?.id}:${nowPlaying?.media?.title?.userPreferred} ${nowPlaying?.episode}`)
} }
const filtered = nowPlaying?.media && videoFiles.filter(file => file.media?.media?.id && file.media?.media?.id === nowPlaying.media.id) const filtered = nowPlaying?.media && videoFiles.filter(file => file.media?.media?.id && file.media?.media?.id === nowPlaying.media.id)
@ -160,7 +161,7 @@
result = filtered result = filtered
} else { } else {
const max = highestOccurence(videoFiles, file => file.media.parseObject.anime_title).media.parseObject.anime_title const max = highestOccurence(videoFiles, file => file.media.parseObject.anime_title).media.parseObject.anime_title
debug(`Highest occurence anime title: ${max}`) debug(`Highest occurrence anime title: ${max}`)
result = videoFiles.filter(file => file.media.parseObject.anime_title === max) result = videoFiles.filter(file => file.media.parseObject.anime_title === max)
} }
@ -263,6 +264,7 @@
export let miniplayer = false export let miniplayer = false
export let page = 'home' export let page = 'home'
export let overlay = 'none'
</script> </script>
<Player files={$processed} {miniplayer} media={$nowPlaying} bind:playFile bind:page on:current={handleCurrent} /> <Player files={$processed} {miniplayer} media={$nowPlaying} bind:playFile bind:page bind:overlay on:current={handleCurrent} />

View file

@ -4,7 +4,6 @@
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte' import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
import { client } from '@/modules/torrent.js' import { client } from '@/modules/torrent.js'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { anilistClient } from '@/modules/anilist.js'
import Subtitles from '@/modules/subtitles.js' import Subtitles from '@/modules/subtitles.js'
import { toTS, fastPrettyBytes, videoRx } from '@/modules/util.js' import { toTS, fastPrettyBytes, videoRx } from '@/modules/util.js'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
@ -12,16 +11,17 @@
import Seekbar from 'perfect-seekbar' import Seekbar from 'perfect-seekbar'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import VideoDeband from 'video-deband' import VideoDeband from 'video-deband'
import Helper from '@/modules/helper.js'
import { w2gEmitter, state } from '../WatchTogether/WatchTogether.svelte' import { w2gEmitter, state } from '../WatchTogether/WatchTogether.svelte'
import Keybinds, { loadWithDefaults, condition } from 'svelte-keybinds' import Keybinds, { loadWithDefaults, condition } from 'svelte-keybinds'
import { SUPPORTS } from '@/modules/support.js' import { SUPPORTS } from '@/modules/support.js'
import 'rvfc-polyfill' import 'rvfc-polyfill'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import { ArrowDown, ArrowUp, Captions, Cast, CircleHelp, Contrast, FastForward, Keyboard, List, ListMusic, ListVideo, Lock, Maximize, Minimize, Pause, PictureInPicture, PictureInPicture2, Play, Proportions, RefreshCcw, Rewind, RotateCcw, RotateCw, ScreenShare, SkipBack, SkipForward, Users, Volume1, Volume2, VolumeX } from 'lucide-svelte'
import { swipeControls } from '@/modules/swipecontrol.js'; import { swipeControls } from '@/modules/swipecontrol.js';
import { volumeScroll } from '@/modules/volumescroll.js'; import { volumeScroll } from '@/modules/volumescroll.js';
import GestureLock from '@/components/GestureLock.svelte'; import GestureLock from '@/components/GestureLock.svelte';
import { ArrowDown, ArrowUp, Captions, Cast, CircleHelp, Contrast, FastForward, RefreshCw, Keyboard, List, ListMusic, ListVideo, Maximize, Minimize, Pause, PictureInPicture, PictureInPicture2, Play, Proportions, RefreshCcw, Rewind, RotateCcw, RotateCw, ScreenShare, SkipBack, SkipForward, Users, Volume1, Volume2, VolumeX, Lock } from 'lucide-svelte'
const emit = createEventDispatcher() const emit = createEventDispatcher()
@ -49,6 +49,7 @@
export let miniplayer = false export let miniplayer = false
$condition = () => !miniplayer && SUPPORTS.keybinds && !document.querySelector('.modal.show') $condition = () => !miniplayer && SUPPORTS.keybinds && !document.querySelector('.modal.show')
export let page export let page
export let overlay
export let files = [] export let files = []
$: updateFiles(files) $: updateFiles(files)
let src = null let src = null
@ -109,15 +110,15 @@
// document.fullscreenElement isn't reactive // document.fullscreenElement isn't reactive
document.addEventListener('fullscreenchange', () => { document.addEventListener('fullscreenchange', () => {
isFullscreen = !!document.fullscreenElement isFullscreen = !!document.fullscreenElement
if (!SUPPORTS.isAndroid) return
if (document.fullscreenElement) { if (document.fullscreenElement) {
// window.Capacitor.Plugins.StatusBar.hide() // window.Capacitor.Plugins.StatusBar.hide()
window.AndroidFullScreen.immersiveMode() window.AndroidFullScreen?.immersiveMode()
screen.orientation.lock('landscape') screen.orientation.lock('landscape')
} else { } else {
// window.Capacitor.Plugins.StatusBar.show() // window.Capacitor.Plugins.StatusBar.show()
window.AndroidFullScreen.showSystemUI() window.AndroidFullScreen?.showSystemUI()
window.Capacitor.Plugins.StatusBar.setOverlaysWebView({ overlay: true }) window.Capacitor.Plugins.StatusBar.setOverlaysWebView({ overlay: true })
screen.orientation.unlock() screen.orientation.unlock()
} }
}) })
@ -984,7 +985,7 @@
if (media?.media?.episodes || media?.media?.nextAiringEpisode?.episode) { if (media?.media?.episodes || media?.media?.nextAiringEpisode?.episode) {
if (media.media.episodes || media.media.nextAiringEpisode?.episode > media.episode) { if (media.media.episodes || media.media.nextAiringEpisode?.episode > media.episode) {
completed = true completed = true
anilistClient.alEntry(media) Helper.updateEntry(media)
} }
} }
} }
@ -1166,10 +1167,10 @@
</div> </div>
<div class='middle d-flex align-items-center justify-content-center flex-grow-1 position-relative'> <div class='middle d-flex align-items-center justify-content-center flex-grow-1 position-relative'>
<!-- eslint-disable-next-line svelte/valid-compile --> <!-- eslint-disable-next-line svelte/valid-compile -->
<div class='w-full h-full position-absolute toggle-fullscreen' on:dblclick={!SUPPORTS.isAndroid ? toggleFullscreen : executeSeek} on:click|self={() => { if (page === 'player') playPause(); page = 'player' }} /> <div class='w-full h-full position-absolute toggle-fullscreen' on:dblclick={toggleFullscreen} on:click|self={() => { if (page === 'player' && ['none', 'player'].includes(overlay)) playPause(); page = 'player'; window.dispatchEvent(new Event('overlay-check')) }} />
<!-- eslint-disable-next-line svelte/valid-compile --> <!-- eslint-disable-next-line svelte/valid-compile -->
<div class='w-full h-full position-absolute toggle-immerse d-none' on:dblclick={!SUPPORTS.isAndroid ? toggleFullscreen : executeSeek} on:click|self={toggleImmerse} /> <div class='w-full h-full position-absolute toggle-immerse d-none' on:dblclick={toggleFullscreen} on:dblclick={!SUPPORTS.isAndroid ? toggleFullscreen : executeSeek} on:click|self={toggleImmerse} />
<div class='w-full h-full position-absolute mobile-focus-target d-none' use:click={() => { page = 'player'; toggleFullscreen() }} /> <div class='w-full h-full position-absolute mobile-focus-target d-none' use:click={() => { page = 'player'; window.dispatchEvent(new Event('overlay-check')) }} />
<!-- eslint-disable-next-line svelte/valid-compile --> <!-- eslint-disable-next-line svelte/valid-compile -->
<span class='icon ctrl h-full align-items-center justify-content-end w-150 mw-full mr-auto' on:click={rewind}> <span class='icon ctrl h-full align-items-center justify-content-end w-150 mw-full mr-auto' on:click={rewind}>
<Rewind size='3rem' /> <Rewind size='3rem' />
@ -1245,14 +1246,19 @@
{#if playbackRate !== 1} {#if playbackRate !== 1}
<div class='ts mr-auto'>x{playbackRate.toFixed(1)}</div> <div class='ts mr-auto'>x{playbackRate.toFixed(1)}</div>
{/if} {/if}
<span class='icon ctrl mr-5 d-flex align-items-center keybinds' title='Keybinds [`]' use:click={() => (showKeybinds = true)}> {#if video}
<Keyboard size='2.5rem' strokeWidth={2.5} /> <span class='icon ctrl mr-5 d-flex align-items-center reload-video' title='Reload Video' use:click={() => video.load()}>
<RefreshCw size='2.5rem' strokeWidth={2.5} />
</span> </span>
{/if}
{#if SUPPORTS.isAndroid} {#if SUPPORTS.isAndroid}
<span class='icon ctrl mr-5 d-flex align-items-center h-full' use:click={() => (isLocked = true)}> <span class='icon ctrl mr-5 d-flex align-items-center h-full' use:click={() => (isLocked = true)}>
<Lock size='2.5rem' strokeWidth={2.5} /> <Lock size='2.5rem' strokeWidth={2.5} />
</span> </span>
{/if} {/if}
<span class='icon ctrl mr-5 d-flex align-items-center keybinds' title='Keybinds [`]' use:click={() => (showKeybinds = true)}>
<Keyboard size='2.5rem' strokeWidth={2.5} />
</span>
{#if 'audioTracks' in HTMLVideoElement.prototype && video?.audioTracks?.length > 1} {#if 'audioTracks' in HTMLVideoElement.prototype && video?.audioTracks?.length > 1}
<div class='dropdown dropup with-arrow' use:click={toggleDropdown}> <div class='dropdown dropup with-arrow' use:click={toggleDropdown}>
<span class='icon ctrl mr-5 d-flex align-items-center h-full' title='Audio Tracks'> <span class='icon ctrl mr-5 d-flex align-items-center h-full' title='Audio Tracks'>
@ -1628,6 +1634,12 @@
.seekbar { .seekbar {
font-size: 2rem !important; font-size: 2rem !important;
} }
.miniplayer .mobile-focus-target {
display: block !important;
}
.miniplayer .mobile-focus-target:focus-visible {
background: hsla(209, 100%, 55%, 0.3);
}
@media (pointer: none), (pointer: coarse) { @media (pointer: none), (pointer: coarse) {

View file

@ -27,8 +27,10 @@
$items = [...$items, ...nextData] $items = [...$items, ...nextData]
return nextData[nextData.length - 1].data return nextData[nextData.length - 1].data
} }
const update = debounce(() => { const update = debounce((event) => {
if (event.target.id !== 'genre' && event.target.id !== 'tag') {
$key = {} $key = {}
}
}, 300) }, 300)
$: loadTillFull($key) $: loadTillFull($key)
@ -66,10 +68,10 @@
<div class='bg-dark h-full w-full overflow-y-scroll d-flex flex-wrap flex-row root overflow-x-hidden justify-content-center align-content-start' use:smoothScroll bind:this={container} on:scroll={infiniteScroll}> <div class='bg-dark h-full w-full overflow-y-scroll d-flex flex-wrap flex-row root overflow-x-hidden justify-content-center align-content-start' use:smoothScroll bind:this={container} on:scroll={infiniteScroll}>
<Search bind:search={$search} on:input={update} /> <Search bind:search={$search} on:input={update} />
<div class='w-full d-grid d-md-flex flex-wrap flex-row px-md-50 px-20 justify-content-center align-content-start'> <div class='w-full d-grid d-md-flex flex-wrap flex-row px-md-50 justify-content-center align-content-start'>
{#key $key} {#key $key}
{#each $items as card} {#each $items as card}
<Card {card} /> <Card {card} variables={{...$search}} />
{/each} {/each}
{#if $items?.length} {#if $items?.length}
<ErrorCard promise={$items[0].data} /> <ErrorCard promise={$items[0].data} />

View file

@ -4,7 +4,7 @@
import { resetSettings } from '@/modules/settings.js' import { resetSettings } from '@/modules/settings.js'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import { SUPPORTS } from '@/modules/support.js' import { SUPPORTS } from '@/modules/support.js'
import SettingCard from './SettingCard.svelte'; import SettingCard from './SettingCard.svelte'
async function importSettings () { async function importSettings () {
try { try {
@ -91,7 +91,7 @@
</script> </script>
<h4 class='mb-10 font-weight-bold'>Debug Settings</h4> <h4 class='mb-10 font-weight-bold'>Debug Settings</h4>
<SettingCard title='Logging Levels' description='Enable logging of specific parts of the app. These logs are saved to %appdata$/Miru/logs/main.log or ~/config/Miru/logs/main.log.'> <SettingCard title='Logging Levels' description='Enable logging of specific parts of the app. These logs are saved to %appdata$/Migu/logs/main.log or ~/config/Migu/logs/main.log.'>
<select class='form-control bg-dark w-300 mw-full' bind:value={$debug}> <select class='form-control bg-dark w-300 mw-full' bind:value={$debug}>
<option value='' selected>None</option> <option value='' selected>None</option>
<option value='*'>All</option> <option value='*'>All</option>
@ -140,10 +140,7 @@
Export Settings To Clipboard Export Settings To Clipboard
</button> </button>
{#if SUPPORTS.update} {#if SUPPORTS.update}
<button <button use:click={checkUpdate} class='btn btn-primary mt-10' type='button'>
use:click={checkUpdate}
class='btn btn-primary mt-10'
type='button'>
Check For Updates Check For Updates
</button> </button>
{/if} {/if}

View file

@ -6,6 +6,8 @@
import SettingCard from './SettingCard.svelte' import SettingCard from './SettingCard.svelte'
import { SUPPORTS } from '@/modules/support.js' import { SUPPORTS } from '@/modules/support.js'
import { Trash2 } from 'lucide-svelte' import { Trash2 } from 'lucide-svelte'
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
import Helper from "@/modules/helper.js"
function updateAngle () { function updateAngle () {
IPC.emit('angle', settings.value.angle) IPC.emit('angle', settings.value.angle)
} }
@ -20,6 +22,7 @@
<label for='rpc-enable'>{settings.enableRPC ? 'On' : 'Off'}</label> <label for='rpc-enable'>{settings.enableRPC ? 'On' : 'Off'}</label>
</div> </div>
</SettingCard> </SettingCard>
{#if settings.enableRPC}
<SettingCard title='Show Details in Discord Rich Presence' description='Shows currently played anime and episode in Discord rich presence.'> <SettingCard title='Show Details in Discord Rich Presence' description='Shows currently played anime and episode in Discord rich presence.'>
<div class='custom-switch'> <div class='custom-switch'>
<input type='checkbox' id='rpc-details' bind:checked={settings.showDetailsInRPC} /> <input type='checkbox' id='rpc-details' bind:checked={settings.showDetailsInRPC} />
@ -27,6 +30,7 @@
</div> </div>
</SettingCard> </SettingCard>
{/if} {/if}
{/if}
<h4 class='mb-10 font-weight-bold'>Interface Settings</h4> <h4 class='mb-10 font-weight-bold'>Interface Settings</h4>
<SettingCard title='Enable Smooth Scrolling' description='Enables smooth scrolling for vertical containers. Turning this off might remove jitter when scrolling on devices without a GPU.'> <SettingCard title='Enable Smooth Scrolling' description='Enables smooth scrolling for vertical containers. Turning this off might remove jitter when scrolling on devices without a GPU.'>
@ -50,12 +54,27 @@
<SettingCard title='CSS Variables' description='Used for custom themes. Can change colors, sizes, spacing and more. Supports only variables. Best way to discover variables is to use the built-in devtools via Ctrl+Shift+I or F12.'> <SettingCard title='CSS Variables' description='Used for custom themes. Can change colors, sizes, spacing and more. Supports only variables. Best way to discover variables is to use the built-in devtools via Ctrl+Shift+I or F12.'>
<textarea class='form-control w-500 mw-full bg-dark' placeholder='--accent-color: #20a2ff;' bind:value={$variables} /> <textarea class='form-control w-500 mw-full bg-dark' placeholder='--accent-color: #20a2ff;' bind:value={$variables} />
</SettingCard> </SettingCard>
{#if !Helper.isAniAuth()}
<SettingCard title='Preferred Title Language' description='What title language to automatically select when displaying the title of an anime.'>
<select class='form-control bg-dark w-300 mw-full' bind:value={settings.titleLang}>
<option value='romaji' selected>Japanese</option>
<option value='english'>English</option>
</select>
</SettingCard>
{/if}
<SettingCard title='Card Type' description='What type of cards to display in menus.'> <SettingCard title='Card Type' description='What type of cards to display in menus.'>
<select class='form-control bg-dark w-300 mw-full' bind:value={settings.cards}> <select class='form-control bg-dark w-300 mw-full' bind:value={settings.cards}>
<option value='small' selected>Small</option> <option value='small' selected>Small</option>
<option value='full'>Full</option> <option value='full'>Full</option>
</select> </select>
</SettingCard> </SettingCard>
<SettingCard title='Card Audio' description={'If the dub or sub icon should be shown on the cards in the menu.\nThis will show one of three simple icons which are previewed as follows:'}>
<AudioLabel example={true}/>
<div class='custom-switch'>
<input type='checkbox' id='card-audio' bind:checked={settings.cardAudio} />
<label for='card-audio'>{settings.cardAudio ? 'On' : 'Off'}</label>
</div>
</SettingCard>
{#if SUPPORTS.angle} {#if SUPPORTS.angle}
<h4 class='mb-10 font-weight-bold'>Rendering Settings</h4> <h4 class='mb-10 font-weight-bold'>Rendering Settings</h4>
<SettingCard title='ANGLE Backend' description="What ANGLE backend to use for rendering. DON'T CHANGE WITHOUT REASON! On some Windows machines D3D9 might help with flicker. Changing this setting to something your device doesn't support might prevent Migu from opening which will require a full reinstall. While Vulkan is an available option it might not be fully supported on Linux."> <SettingCard title='ANGLE Backend' description="What ANGLE backend to use for rendering. DON'T CHANGE WITHOUT REASON! On some Windows machines D3D9 might help with flicker. Changing this setting to something your device doesn't support might prevent Migu from opening which will require a full reinstall. While Vulkan is an available option it might not be fully supported on Linux.">
@ -74,6 +93,14 @@
{/if} {/if}
<h4 class='mb-10 font-weight-bold'>Home Screen Settings</h4> <h4 class='mb-10 font-weight-bold'>Home Screen Settings</h4>
{#if Helper.isAuthorized()}
<SettingCard title='Hide My Anime' description={'The anime on your Completed or Dropped list will automatically be hidden from the default sections, this excludes manually added RSS feeds and user specific feeds.'}>
<div class='custom-switch'>
<input type='checkbox' id='hide-my-anime' bind:checked={settings.hideMyAnime} />
<label for='hide-my-anime'>{settings.hideMyAnime ? 'On' : 'Off'}</label>
</div>
</SettingCard>
{/if}
<SettingCard title='RSS Feeds' description={'RSS feeds to display on the home screen. This needs to be a CORS enabled URL to a Nyaa or Tosho like RSS feed which cotains either an "infoHash" or "enclosure" tag.\nThis only shows the releases on the home screen, it doesn\'t automatically download the content.\nSince the feeds only provide the name of the file, Migu might not always detect the anime correctly!\nSome presets for popular groups are already provided as an example, custom feeds require the FULL URL.'}> <SettingCard title='RSS Feeds' description={'RSS feeds to display on the home screen. This needs to be a CORS enabled URL to a Nyaa or Tosho like RSS feed which cotains either an "infoHash" or "enclosure" tag.\nThis only shows the releases on the home screen, it doesn\'t automatically download the content.\nSince the feeds only provide the name of the file, Migu might not always detect the anime correctly!\nSome presets for popular groups are already provided as an example, custom feeds require the FULL URL.'}>
<div> <div>
{#each settings.rssFeedsNew as _, i} {#each settings.rssFeedsNew as _, i}

View file

@ -94,8 +94,10 @@
<option value='slo'>Slovak</option> <option value='slo'>Slovak</option>
<option value='swe'>Swedish</option> <option value='swe'>Swedish</option>
<option value='ara'>Arabic</option> <option value='ara'>Arabic</option>
<option value='idn'>Indonesian</option>
</select> </select>
</SettingCard> </SettingCard>
{#if 'audioTracks' in HTMLVideoElement.prototype}
<SettingCard title='Preferred Audio Language' description="What audio language to automatically select when a video is loaded if it exists. This won't find torrents with this language automatically. If not found defaults to Japanese."> <SettingCard title='Preferred Audio Language' description="What audio language to automatically select when a video is loaded if it exists. This won't find torrents with this language automatically. If not found defaults to Japanese.">
<select class='form-control bg-dark w-300 mw-full' bind:value={settings.audioLanguage}> <select class='form-control bg-dark w-300 mw-full' bind:value={settings.audioLanguage}>
<option value='eng'>English</option> <option value='eng'>English</option>
@ -121,8 +123,10 @@
<option value='slo'>Slovak</option> <option value='slo'>Slovak</option>
<option value='swe'>Swedish</option> <option value='swe'>Swedish</option>
<option value='ara'>Arabic</option> <option value='ara'>Arabic</option>
<option value='idn'>Indonesian</option>
</select> </select>
</SettingCard> </SettingCard>
{/if}
<h4 class='mb-10 font-weight-bold'>Playback Settings</h4> <h4 class='mb-10 font-weight-bold'>Playback Settings</h4>
<SettingCard title='Autoplay Next Episode' description='Automatically starts playing next episode when a video ends.'> <SettingCard title='Autoplay Next Episode' description='Automatically starts playing next episode when a video ends.'>
@ -137,7 +141,7 @@
<label for='player-pause'>{settings.playerPause ? 'On' : 'Off'}</label> <label for='player-pause'>{settings.playerPause ? 'On' : 'Off'}</label>
</div> </div>
</SettingCard> </SettingCard>
<SettingCard title='Auto-Complete Episodes' description='Automatically marks episodes as complete on AniList when you finish watching them. Requires AniList login.'> <SettingCard title='Auto-Complete Episodes' description='Automatically marks episodes as complete on AniList or MyAnimeList when you finish watching them. You must be logged in.'>
<div class='custom-switch'> <div class='custom-switch'>
<input type='checkbox' id='player-autocomplete' bind:checked={settings.playerAutocomplete} /> <input type='checkbox' id='player-autocomplete' bind:checked={settings.playerAutocomplete} />
<label for='player-autocomplete'>{settings.playerAutocomplete ? 'On' : 'Off'}</label> <label for='player-autocomplete'>{settings.playerAutocomplete ? 'On' : 'Off'}</label>

View file

@ -1,5 +1,4 @@
<script context='module'> <script context='module'>
import { toast } from 'svelte-sonner'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import { settings } from '@/modules/settings.js' import { settings } from '@/modules/settings.js'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
@ -36,9 +35,9 @@
import TorrentSettings from './TorrentSettings.svelte' import TorrentSettings from './TorrentSettings.svelte'
import InterfaceSettings from './InterfaceSettings.svelte' import InterfaceSettings from './InterfaceSettings.svelte'
import AppSettings from './AppSettings.svelte' import AppSettings from './AppSettings.svelte'
import { anilistClient } from '@/modules/anilist.js' import { profileView } from '@/components/Profiles.svelte'
import { logout } from '@/components/Logout.svelte'
import smoothScroll from '@/modules/scroll.js' import smoothScroll from '@/modules/scroll.js'
import Helper from '@/modules/helper.js'
import { AppWindow, Heart, LogIn, Logs, Play, Rss, Settings } from 'lucide-svelte' import { AppWindow, Heart, LogIn, Logs, Play, Rss, Settings } from 'lucide-svelte'
const groups = { const groups = {
@ -72,17 +71,7 @@
} }
function loginButton () { function loginButton () {
if (anilistClient.userID?.viewer?.data?.Viewer) { $profileView = true
$logout = true
} else {
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=20321&response_type=token') // Change redirect_url to migu://auth
if (platformMap[window.version.platform] === 'Linux') {
toast('Support Notification', {
description: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
duration: 300000
})
}
}
} }
onDestroy(() => { onDestroy(() => {
IPC.off('path', pathListener) IPC.off('path', pathListener)
@ -114,14 +103,14 @@
</div> --> </div> -->
<div class='pointer my-5 rounded' use:click={loginButton}> <div class='pointer my-5 rounded' use:click={loginButton}>
<div class='px-20 py-10 d-flex align-items-center'> <div class='px-20 py-10 d-flex align-items-center'>
{#if anilistClient.userID?.viewer?.data?.Viewer} {#if Helper.getUser()}
<span class='rounded mr-10'> <span class='rounded mr-10'>
<img src={anilistClient.userID.viewer.data.Viewer.avatar.medium} class='h-30 rounded' alt='logo' /> <img src={Helper.getUserAvatar()} class='h-30 rounded' alt='logo' />
</span> </span>
<div class='font-size-16 login-image-text'>Logout</div> <div class='font-size-16 login-image-text'>Profiles</div>
{:else} {:else}
<LogIn class='pr-10 d-inline-flex' size='3.1rem' /> <LogIn class='pr-10 d-inline-flex' size='3.1rem' />
<div class='font-size-16 line-height-normal'>Login With AniList</div> <div class='font-size-16 line-height-normal'>Login</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -141,9 +141,8 @@
{/if} {/if}
<h4 class='mb-10 font-weight-bold'>Client Settings</h4> <h4 class='mb-10 font-weight-bold'>Client Settings</h4>
<SettingCard title='Torrent Download Location' description='Path to the folder used to store torrents. By default this is the TMP folder, which might lose data when your OS tries to reclaim storage. {SUPPORTS.isAndroid ? 'RESTART IS REQUIRED. /sdcard/ is internal storage, not external SD Cards. /storage/AB12-34CD/ is external storage, not internal. Thank you Android!' : ''}'> <SettingCard title='Torrent Download Location' description='Path to the folder used to store torrents. By default this is the TMP folder, which might lose data when your OS tries to reclaim storage. {SUPPORTS.isAndroid ? "RESTART IS REQUIRED. /sdcard/ is internal storage, not external SD Cards. /storage/AB12-34CD/ is external storage, not internal. Thank you Android!" : ""}'>
<div <div class='input-group w-300 mw-full'>
class='input-group w-300 mw-full'>
<div class='input-group-prepend'> <div class='input-group-prepend'>
<button type='button' use:click={handleFolder} class='btn btn-primary input-group-append'>Select Folder</button> <button type='button' use:click={handleFolder} class='btn btn-primary input-group-append'>Select Folder</button>
</div> </div>

View file

@ -1,6 +1,7 @@
<script context='module'> <script context='module'>
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import { settings } from '@/modules/settings.js' import { settings } from '@/modules/settings.js'
import { anilistClient } from '@/modules/anilist.js'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import getResultsFromExtensions from '@/modules/extensions/index.js' import getResultsFromExtensions from '@/modules/extensions/index.js'
import Debug from 'debug' import Debug from 'debug'
@ -114,8 +115,8 @@ async function sortResults(results) {
$: autoPlay(best, $settings.rssAutoplay) $: autoPlay(best, $settings.rssAutoplay)
$: lookup.catch(err => { $: lookup.catch(err => {
debug(`Error fetching torrents for ${search.media.title.userPreferred} Episode ${search.episode}, ${err.stack}`) debug(`Error fetching torrents for ${search.media?.title?.userPreferred} Episode ${search.episode}, ${err.stack}`)
toast.error(`No torrent found for ${search.media.title.userPreferred} Episode ${search.episode}!`, { description: err.message }) toast.error(`No torrent found for ${anilistClient.title(search.media)} Episode ${search.episode}!`, { description: err.message })
}) })
$: firstLoad = !firstLoad && lookup.catch(close) $: firstLoad = !firstLoad && lookup.catch(close)

View file

@ -7,7 +7,10 @@
export function playAnime (media, episode = 1, force) { export function playAnime (media, episode = 1, force) {
episode = Number(episode) episode = Number(episode)
episode = isNaN(episode) ? 1 : episode episode = isNaN(episode) ? 1 : episode
if (!force && findInCurrent({ media, episode })) return if (!force && findInCurrent({ media, episode })) {
window.dispatchEvent(new Event('overlay-check'))
return
}
rss.set({ media, episode }) rss.set({ media, episode })
} }
</script> </script>
@ -15,7 +18,10 @@
<script> <script>
import TorrentMenu from './TorrentMenu.svelte' import TorrentMenu from './TorrentMenu.svelte'
export let overlay
function close () { function close () {
overlay = 'none'
$rss = null $rss = null
} }
function checkClose ({ keyCode }) { function checkClose ({ keyCode }) {
@ -26,10 +32,21 @@
$: search = $rss $: search = $rss
$: search && modal?.focus() $: {
if (search) {
overlay = 'torrent'
modal?.focus()
}
}
window.addEventListener('overlay-check', () => {
if (search) {
close()
}
})
</script> </script>
<div class='modal z-100' class:show={search} id='viewAnime'> <div class='modal z-50' class:show={search} id='viewAnime'>
{#if search} {#if search}
<div class='modal-dialog d-flex align-items-center px-md-15 pt-md-20' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}> <div class='modal-dialog d-flex align-items-center px-md-15 pt-md-20' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content m-0 mw-full h-full rounded overflow-hidden bg-very-dark d-flex flex-column overflow-y-scroll pt-0 px-0'> <div class='modal-content m-0 mw-full h-full rounded overflow-hidden bg-very-dark d-flex flex-column overflow-y-scroll pt-0 px-0'>

View file

@ -0,0 +1,119 @@
<script>
import { onMount } from 'svelte'
import { settings } from '@/modules/settings.js'
import { malDubs } from '@/modules/animedubs.js'
import { writable } from 'svelte/store'
import { matchPhrase } from "@/modules/util.js"
import { Mic, MicOff, Captions, TriangleAlert } from 'lucide-svelte'
/** @type {import('@/modules/al.d.ts').Media} */
export let media = null
export let data = null
export let banner = false
export let viewAnime = false
export let example = false
export let episode = false
let isDubbed = writable(false)
let isPartial = writable(false)
function setLabel() {
const dubLists = malDubs.dubLists.value
if (media?.idMal && dubLists?.dubbed) {
const episodeOrMedia = !episode || (matchPhrase(data.parseObject?.language, 'English', 3) || matchPhrase(data.parseObject?.file_name, ['Multi Audio', 'Dual Audio', 'English Audio'], 3))
isDubbed.set(episodeOrMedia && dubLists.dubbed.includes(media.idMal))
isPartial.set(episodeOrMedia && dubLists.incomplete.includes(media.idMal))
}
}
onMount(() => {
if (!example) {
setLabel()
}
})
</script>
{#if !banner && !viewAnime && !example}
{#if settings.value.cardAudio}
<div class='position-absolute top-0 left-0 ml-10 mt-10 d-flex align-items-center justify-content-center'>
<div class='w-auto h-auto z-10 text-white d-flex align-items-center justify-content-center {$isDubbed ? "dubbed" : $isPartial ? "incomplete" : "subbed"}'>
{#if $isDubbed}
<Mic size='2.5rem' />
{:else if $isPartial}
<MicOff size='2.5rem' />
{:else}
<Captions size='2.5rem' />
{/if}
</div>
{#if media.isAdult}
<div class='ml-5 w-auto h-auto z-10 text-white d-flex align-items-center justify-content-center adult'>
<TriangleAlert size='2.5rem' />
</div>
{/if}
</div>
{/if}
{:else if !viewAnime && !example}
{$isDubbed ? 'Dub' : $isPartial ? 'Partial Dub' : 'Sub'}
{:else if viewAnime}
{#if $isDubbed}
<Mic class='mx-10' size='2.2rem' />
{:else if $isPartial}
<MicOff class='mx-10' size='2.2rem' />
{:else}
<Captions class='mx-10' size='2.2rem' />
{/if}
<span class='mr-20'>
{$isDubbed ? 'Dub' : $isPartial ? 'Partial Dub' : 'Sub'}
</span>
{:else}
<div>
<div class='position-relative d-flex align-items-center justify-content-center mt-5'>
<div class='position-relative d-flex align-items-center justify-content-center'>
<div class='font-size-24 label ml-20 z-10 adult'>
<TriangleAlert size='2.5rem' />
</div>
<span class='ml-5 mb-5'>Rated 18+</span>
</div>
<div class='position-relative d-flex align-items-center justify-content-center'>
<div class='font-size-24 label ml-20 z-10 subbed'>
<Captions size='2.5rem' />
</div>
<span class='ml-5 mb-5'>Sub Only</span>
</div>
<div class='position-relative d-flex align-items-center justify-content-center'>
<div class='font-size-24 label ml-20 z-10 incomplete'>
<MicOff size='2.5rem' />
</div>
<span class='ml-5 mb-5'>Partial Dub</span>
</div>
<div class='position-relative d-flex align-items-center justify-content-center'>
<div class='font-size-24 label ml-20 z-10 dubbed'>
<Mic size='2.5rem' />
</div>
<span class='ml-5 mb-5 mr-10'>Dub</span>
</div>
</div>
</div>
{/if}
<style>
.label {
top: .625rem;
}
.adult {
color: rgb(215, 6, 10) !important;
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1)) drop-shadow(0 0 .4rem rgba(0, 0, 0, 1));
}
.dubbed {
color: rgb(255, 214, 0) !important;
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1)) drop-shadow(0 0 .4rem rgba(0, 0, 0, 1));
}
.subbed {
color: rgb(137, 39, 255) !important;
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1)) drop-shadow(0 0 .4rem rgba(0, 0, 0, 1));
}
.incomplete {
color: rgb(255, 94, 0) !important;
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1)) drop-shadow(0 0 .4rem rgba(0, 0, 0, 1));
}
</style>

View file

@ -1,22 +1,34 @@
<script> <script>
import { Building2, FolderKanban, Languages, Leaf, MonitorPlay, Type } from 'lucide-svelte' import { Building2, Earth, TriangleAlert, FolderKanban, Languages, CalendarRange, MonitorPlay, Type } from 'lucide-svelte'
export let media = null export let media = null
export let alt = null
const detailsMap = [ const detailsMap = [
{ property: 'season', label: 'Season', icon: Leaf, custom: 'property' }, { property: 'season', label: 'Season', icon: CalendarRange, custom: 'property' },
{ property: 'status', label: 'Status', icon: MonitorPlay }, { property: 'status', label: 'Status', icon: MonitorPlay },
{ property: 'nodes', label: 'Studio', icon: Building2 }, { property: 'studios', label: 'Studio', icon: Building2, custom: 'property' },
{ property: 'source', label: 'Source', icon: FolderKanban }, { property: 'source', label: 'Source', icon: FolderKanban },
{ property: 'countryOfOrigin', label: 'Country', icon: Earth, custom: 'property' },
{ property: 'isAdult', label: 'Adult', icon: TriangleAlert },
{ property: 'english', label: 'English', icon: Type }, { property: 'english', label: 'English', icon: Type },
{ property: 'romaji', label: 'Romaji', icon: Languages }, { property: 'romaji', label: 'Romaji', icon: Languages },
{ property: 'native', label: 'Native', icon: '語', custom: 'icon' } { property: 'native', label: 'Native', icon: '語', custom: 'icon' }
] ]
function getCustomProperty (detail, media) { async function getCustomProperty (detail, media) {
if (detail.property === 'averageScore') { if (detail.property === 'averageScore') {
return media.averageScore + '%' return media.averageScore + '%'
} else if (detail.property === 'season') { } else if (detail.property === 'season') {
return [media.season?.toLowerCase(), media.seasonYear].filter(f => f).join(' ') return [media.season?.toLowerCase(), media.seasonYear].filter(f => f).join(' ')
} else if (detail.property === 'countryOfOrigin') {
const countryMap = {
JP: 'Japan',
CN: 'China',
US: 'United States'
}
return countryMap[media.countryOfOrigin] || 'Unknown'
} else if (detail.property === 'studios') { // has to be manually fetched as studios returned by user lists are broken.
return ((await alt)?.data?.Media || media)?.studios?.nodes?.map(node => node.name).filter(name => name).join(', ') || 'Unknown'
} else { } else {
return media[detail.property] return media[detail.property]
} }
@ -26,6 +38,8 @@
return media.nextAiringEpisode?.episode return media.nextAiringEpisode?.episode
} else if (property === 'english' || property === 'romaji' || property === 'native') { } else if (property === 'english' || property === 'romaji' || property === 'native') {
return media.title[property] return media.title[property]
} else if (property === 'isAdult') {
return (media.isAdult === true ? 'Rated 18+' : false)
} }
return media[property] return media[property]
} }
@ -46,9 +60,11 @@
<div class='d-flex flex-column justify-content-center text-nowrap'> <div class='d-flex flex-column justify-content-center text-nowrap'>
<div class='font-weight-bold select-all line-height-normal'> <div class='font-weight-bold select-all line-height-normal'>
{#if detail.custom === 'property'} {#if detail.custom === 'property'}
{getCustomProperty(detail, media)} {#await getCustomProperty(detail, media)}
{:else if property.constructor === Array} Fetching...
{property === 'nodes' ? property[0] && property[0].name : property.join(', ').replace(/_/g, ' ').toLowerCase()} {:then res }
{res}
{/await}
{:else} {:else}
{property.toString().replace(/_/g, ' ').toLowerCase()} {property.toString().replace(/_/g, ' ').toLowerCase()}
{/if} {/if}

View file

@ -7,10 +7,10 @@
</script> </script>
<script> <script>
import { since } from '@/modules/util' import { since } from '@/modules/util.js'
import { click } from '@/modules/click.js' import { click } from '@/modules/click.js'
import { episodeByAirDate } from '@/modules/extensions/index.js' import { episodeByAirDate } from '@/modules/extensions/index.js'
import { anilistClient } from '@/modules/anilist' import { anilistClient } from '@/modules/anilist.js'
import { liveAnimeProgress } from '@/modules/animeprogress.js' import { liveAnimeProgress } from '@/modules/animeprogress.js'
export let media export let media
@ -19,27 +19,30 @@
export let watched = false export let watched = false
const id = media.id
const duration = media.duration
export let episodeCount export let episodeCount
export let userProgress = 0 export let userProgress = 0
export let play export let play
const episodeList = Array.from({ length: episodeCount }, (_, i) => ({ $: id = media.id
$: duration = media.duration
let episodeList = []
async function load () {
// updates episodeList when clicking through relations / recommendations
episodeList = Array.from({ length: episodeCount }, (_, i) => ({
episode: i + 1, image: null, summary: null, rating: null, title: null, length: null, airdate: null, airingAt: null, filler: fillerEpisodes[id]?.includes(i + 1) episode: i + 1, image: null, summary: null, rating: null, title: null, length: null, airdate: null, airingAt: null, filler: fillerEpisodes[id]?.includes(i + 1)
})) }))
async function load () {
const res = await fetch('https://api.ani.zip/mappings?anilist_id=' + id) const res = await fetch('https://api.ani.zip/mappings?anilist_id=' + id)
const { episodes, specialCount, episodeCount } = await res.json() const { episodes, specialCount, episodeCount: newEpisodeCount } = await res.json()
/** @type {{ airingAt: number; episode: number; filler?: boolean }[]} */ /** @type {{ airingAt: number; episode: number; filler?: boolean }[]} */
let alEpisodes = episodeList let alEpisodes = episodeList
// fallback: pull episodes from airing schedule if anime doesn't have expected episode count // fallback: pull episodes from airing schedule if anime doesn't have expected episode count
if (!(media.episodes && media.episodes === episodeCount && media.status === 'FINISHED')) { if (!(media.episodes && media.episodes === newEpisodeCount && media.status === 'FINISHED')) {
const settled = (await anilistClient.episodes({ id })).data.Page?.airingSchedules const settled = (await anilistClient.episodes({ id })).data.Page?.airingSchedules
if (settled?.length) alEpisodes = settled if (settled?.length) alEpisodes = settled
} }
@ -47,13 +50,13 @@
const alDate = new Date((airingAt || 0) * 1000) const alDate = new Date((airingAt || 0) * 1000)
// validate by air date if the anime has specials AND doesn't have matching episode count // validate by air date if the anime has specials AND doesn't have matching episode count
const needsValidation = !(!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) const needsValidation = !(!specialCount || (media.episodes && media.episodes === newEpisodeCount && episodes && episodes[Number(episode)]))
const { image, summary, rating, title, length, airdate } = needsValidation ? episodeByAirDate(alDate, episodes, episode) : (episodes[Number(episode)] || {}) const { image, summary, rating, title, length, airdate } = needsValidation ? episodeByAirDate(alDate, episodes, episode) : ((episodes && episodes[Number(episode)]) || {})
episodeList[episode - 1] = { episode, image, summary, rating, title, length: length || duration, airdate: +alDate || airdate, airingAt: +alDate || airdate, filler } episodeList[episode - 1] = { episode, image, summary, rating, title, length: length || duration, airdate: +alDate || airdate, airingAt: +alDate || airdate, filler }
} }
} }
load() $: if (media) load()
const animeProgress = liveAnimeProgress(id) const animeProgress = liveAnimeProgress(id)
</script> </script>

View file

@ -10,7 +10,7 @@
</script> </script>
{#await following then res} {#await following then res}
{@const following = res?.data?.Page?.mediaList} {@const following = [...new Map(res?.data?.Page?.mediaList.map(item => [item.user.name, item])).values()]}
{#if following?.length} {#if following?.length}
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'> <div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
<hr class='w-full' /> <hr class='w-full' />

View file

@ -0,0 +1,305 @@
<script>
import { anilistClient, codes } from '@/modules/anilist.js'
import { profiles } from '@/modules/settings.js'
import { click } from '@/modules/click.js'
import { get, writable } from 'svelte/store'
import { toast } from 'svelte-sonner'
import { Bookmark, PencilLine } from 'lucide-svelte'
import Helper from '@/modules/helper.js'
import Debug from 'debug'
const debug = Debug('ui:scoring')
/** @type {import('@/modules/al.d.ts').Media} */
export let media
export let viewAnime = false
export let previewAnime = false
const showModal = writable(false)
let modal
let score = 0
let status = 'NOT IN LIST'
let episode = 0
let totalEpisodes = '?'
const scoreName = {
10: '(Masterpiece)',
9: '(Great)',
8: '(Very Good)',
7: '(Good)',
6: '(Fine)',
5: '(Average)',
4: '(Bad)',
3: '(Very bad)',
2: '(Horrible)',
1: '(Appalling)',
0: 'Not Rated'
}
async function toggleModal (state) {
showModal.set(!$showModal)
if (state.save || state.delete) {
await new Promise(resolve => setTimeout(resolve, 300)) // allows time for animation to play
if (state.save) {
await saveEntry()
} else if (state.delete) {
await deleteEntry()
}
} else {
score = (media.mediaListEntry?.score ? media.mediaListEntry?.score : 0)
status = (media.mediaListEntry?.status ? media.mediaListEntry?.status : 'NOT IN LIST')
episode = (media.mediaListEntry?.progress ? media.mediaListEntry?.progress : 0)
totalEpisodes = (media.episodes ? `${media.episodes}` : '?')
}
}
async function deleteEntry() {
score = 0
episode = 0
status = 'NOT IN LIST'
if (media.mediaListEntry) {
const res = await Helper.delete(Helper.isAniAuth() ? {id: media.mediaListEntry.id} : {idMal: media.idMal})
const description = `${anilistClient.title(media)} has been deleted from your list`
printToast(res, description, false, false)
if (res) media.mediaListEntry = undefined
if (Helper.getUser().sync) { // handle profile syncing
const mediaId = media.id
for (const profile of get(profiles)) {
if (profile.viewer?.data?.Viewer.sync) {
const anilist = profile.viewer?.data?.Viewer?.avatar
const listId = (anilist ? {id: (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.id} : {idMal: media.idMal})
if (listId?.id || listId?.idMal) {
const res = await Helper.delete({...listId, token: profile.token, anilist})
printToast(res, description, false, profile)
}
}
}
}
}
}
async function saveEntry() {
if (!status.includes('NOT IN LIST')) {
const fuzzyDate = Helper.getFuzzyDate(media, status)
const lists = media.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || []
if (!lists.includes('Watched using Migu')) {
lists.push('Watched using Migu')
}
const variables = {
id: media.id,
idMal: media.idMal,
status,
episode,
score: Helper.isAniAuth() ? (score * 10) : score, // AniList score scale is out of 100, others use a scale of 10.
repeat: media.mediaListEntry?.repeat || 0,
lists,
...fuzzyDate
}
const res = await Helper.entry(media, variables)
if (res?.data?.SaveMediaListEntry) { media.mediaListEntry = res?.data?.SaveMediaListEntry }
const description = `Title: ${anilistClient.title(media)}\nStatus: ${Helper.statusName[status]}\nEpisode: ${episode} / ${totalEpisodes}${score !== 0 ? `\nYour Score: ${score}` : ''}`
printToast(res, description, true, false)
if (Helper.getUser().sync) { // handle profile syncing
const mediaId = media.id
for (const profile of get(profiles)) {
if (profile.viewer?.data?.Viewer.sync) {
const anilist = profile.viewer?.data?.Viewer?.avatar
const currentLists = (anilist ? (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || [] : lists)
if (!currentLists.includes('Watched using Migu')) {
currentLists.push('Watched using Migu')
}
const res = await Helper.entry(media, { ...variables, lists: currentLists, score: (anilist ? (score * 10) : score), token: profile.token, anilist })
printToast(res, description, true, profile)
}
}
}
} else {
await deleteEntry()
}
}
function printToast(res, description, save, profile) {
const who = (profile ? ' for ' + profile.viewer.data.Viewer.name + (profile.viewer?.data?.Viewer?.avatar ? ' (AniList)' : ' (MyAnimeList)') : '')
if ((save && res?.data?.SaveMediaListEntry) || (!save && res)) {
debug(`List Updated${who}: ${description.replace(/\n/g, ', ')}`)
if (!profile) {
if (save) {
toast.success('List Updated', {
description,
duration: 6000
})
} else {
toast.warning('List Updated', {
description,
duration: 9000
})
}
}
} else {
const error = `\n${429} - ${codes[429]}`
debug(`Error: Failed to ${(save ? 'update' : 'delete title from')} user list${who} with: ${description.replace(/\n/g, ', ')} ${error}`)
toast.error('Failed to ' + (save ? 'Update' : 'Delete') + ' List' + who, {
description: description + error,
duration: 9000
})
}
}
/**
* @param {Event & { currentTarget: HTMLInputElement }} event
*/
function handleEpisodes(event) {
const enteredValue = event.currentTarget.value
if (/^\d+$/.test(enteredValue)) {
const maxEpisodes = media.episodes || (media.nextAiringEpisode?.episode - 1)
if (parseInt(enteredValue) > maxEpisodes) {
episode = maxEpisodes
} else {
episode = parseInt(enteredValue)
}
} else {
episode = 0
}
}
function handleClick({target}) {
if (modal && !modal.contains(target) && target.id !== 'list-btn') {
showModal.set(false)
document.removeEventListener('click', handleClick, true)
}
}
$: {
if ($showModal) {
document.addEventListener('click', handleClick, true)
} else {
document.removeEventListener('click', handleClick, true)
}
}
</script>
<button type='button' id='list-btn' class='btn { viewAnime ? "bg-dark btn-lg font-size-20" : (previewAnime ? "btn-square" : "bg-dark-light") + " font-size-16" } btn-square ml-10 shadow-none border-0 d-flex align-items-center justify-content-center' use:click={toggleModal} disabled={!Helper.isAuthorized()}>
{#if media.mediaListEntry}
<PencilLine size='1.7rem' />
{:else}
<Bookmark size='1.7rem' />
{/if}
</button>
{#if Helper.isAuthorized()}
<div bind:this={modal} class='modal scoring position-absolute bg-dark shadow-lg rounded-3 p-20 z-30 {$showModal ? "visible" : "invisible"} {!previewAnime && !viewAnime ? "banner w-auto h-auto" : (!previewAnime ? "viewAnime w-auto h-auto" : "previewAnime")}' use:click={() => {}}>
<div class='d-flex justify-content-between align-items-center mb-2'>
<h5 class='font-weight-bold'>List Editor</h5>
<button type='button' class='btn btn-square mb-20 text-white font-size-24 font-weight-bold' use:click={toggleModal}>&times;</button>
</div>
<div class='modal-body'>
<div class='form-group mb-15'>
<label class='d-block mb-5' for='status'>Status</label>
<select class='form-control bg-dark-light' id='status' bind:value={status}>
<option value selected disabled hidden>Any</option>
<option value='CURRENT'>Watching</option>
<option value='PLANNING'>Planning</option>
<option value='COMPLETED'>Completed</option>
<option value='PAUSED'>Paused</option>
<option value='DROPPED'>Dropped</option>
<option value='REPEATING'>Rewatching</option>
</select>
</div>
<div class='form-group'>
<label class='d-block mb-5' for='episode'>Episode</label>
<div class='d-flex'>
<input class='form-control bg-dark-light w-full' type='number' id='episode' bind:value={episode} on:input={handleEpisodes} />
<div>
<span class='total-episodes position-absolute text-right pointer-events-none'>/ {totalEpisodes}</span>
</div>
</div>
</div>
<div class='form-group'>
<label class='d-block mb-5' for='score'>Your Score</label>
<input class='w-full p-2 bg-dark-light' type='range' id='score' min='0' max='10' bind:value={score} />
<div class='d-flex justify-content-center'>
{#if score !== 0}
<span class='text-center mt-2 text-decoration-underline font-weight-bold'>{score}</span>
<span class='ml-5'>/ 10</span>
{/if}
<span class='ml-5'>{scoreName[score]}</span>
</div>
</div>
</div>
<div class='d-flex justify-content-center'>
{#if !status.includes('NOT IN LIST') && media.mediaListEntry}
<button type='button' class='btn btn-delete btn-secondary text-dark mr-20 font-weight-bold shadow-none' use:click={() => toggleModal({ delete: true })}>Delete</button>
{/if}
<button type='button' class='btn btn-save btn-secondary text-dark font-weight-bold shadow-none' use:click={() => toggleModal({ save: true })}>Save</button>
</div>
</div>
{/if}
<style>
.modal:global(.absolute-container) {
left: -48% !important;
}
.btn-delete:hover {
color: white !important;
background: darkred !important;
}
.btn-save:hover {
color: white !important;
background: darkgreen !important;
}
.total-episodes {
margin-top: 0.65rem;
right: 4rem;
}
.previewAnime {
top: 65%;
margin-top: -26rem;
width: 70%;
left: 0.5rem;
cursor: auto;
}
.viewAnime {
top: auto;
left: auto;
margin-top: -1rem;
margin-left: 4rem;
}
.banner {
top: auto;
left: auto;
margin-top: 2rem;
margin-left: 14.5rem;
}
.visible {
animation: 0.3s ease 0s 1 load-in;
}
.invisible {
animation: load-out 0.3s ease-out forwards;
}
@keyframes load-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes load-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
</style>

View file

@ -6,26 +6,34 @@
showMore = !showMore showMore = !showMore
} }
export let title = 'Relations' export let title = 'Relations'
export let promise = null
</script> </script>
{#if list?.length} {#if list?.length}
<span class='d-flex align-items-end pointer' use:click={toggleList}> <span class='d-flex align-items-end pointer' use:click={toggleList}>
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'> <div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
<hr class='w-full' /> <hr class='w-full' />
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>{title}</div> <div class='title position-absolute font-size-18 font-weight-semi-bold px-20 text-white'>{title}</div>
<hr class='w-full' /> <hr class='w-full' />
{#if list.length > 4} {#if list.length > 4}
<div class='ml-auto pl-20 font-size-12 more text-muted text-nowrap'>{showMore ? 'Show Less' : 'Show More'}</div> <div class='ml-auto pl-20 font-size-12 more text-muted text-nowrap'>{showMore ? 'Show Less' : 'Show More'}</div>
{/if} {/if}
</div> </div>
</span> </span>
<div class='d-flex text-capitalize flex-wrap pt-10 justify-content-center'> <div class='d-flex text-capitalize flex-wrap pt-10 justify-content-center gallery'>
{#each list.slice(0, showMore ? 100 : 4) as item} {#each list.slice(0, showMore ? 100 : 4) as item}
<slot {item} /> <slot {item} {promise} />
{/each} {/each}
</div> </div>
{/if} {/if}
<style> <style>
.title {
left: 50%;
transform: translateX(-50%);
}
.gallery :global(.first-check:first-child) :global(.absolute-container) {
left: -48% !important;
}
.more:hover { .more:hover {
color: var(--dm-link-text-color-hover) !important; color: var(--dm-link-text-color-hover) !important;
} }

View file

@ -1,6 +1,6 @@
<script> <script>
import { getContext } from 'svelte' import { getContext } from 'svelte'
import { getMediaMaxEp, formatMap, playMedia, setStatus } from '@/modules/anime.js' import { getMediaMaxEp, formatMap, playMedia } from '@/modules/anime.js'
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte' import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import { anilistClient } from '@/modules/anilist.js' import { anilistClient } from '@/modules/anilist.js'
@ -8,24 +8,53 @@
import Details from './Details.svelte' import Details from './Details.svelte'
import EpisodeList from './EpisodeList.svelte' import EpisodeList from './EpisodeList.svelte'
import ToggleList from './ToggleList.svelte' import ToggleList from './ToggleList.svelte'
import Scoring from './Scoring.svelte'
import AudioLabel from './AudioLabel.svelte'
import Following from './Following.svelte' import Following from './Following.svelte'
import smoothScroll from '@/modules/scroll.js' import smoothScroll from '@/modules/scroll.js'
import IPC from '@/modules/ipc.js' import IPC from '@/modules/ipc.js'
import { alToken } from '@/modules/settings.js' import SmallCard from "@/components/cards/SmallCard.svelte"
import { Bookmark, Clapperboard, ExternalLink, Heart, LibraryBig, Play, Share2, Timer, TrendingUp, Tv } from 'lucide-svelte' import SkeletonCard from "@/components/cards/SkeletonCard.svelte"
import Helper from "@/modules/helper.js"
import { ArrowLeft, Clapperboard, ExternalLink, Users, Heart, Play, Share2, Timer, TrendingUp, Tv, LibraryBig } from 'lucide-svelte'
export let overlay
const view = getContext('view') const view = getContext('view')
function close () { function close (play) {
$view = null $view = null
mediaList = []
if (!play) {
overlay = 'none'
}
}
function back () {
if (mediaList.length > 1) {
const prevMedia = mediaList[mediaList.length - 2]
mediaList.splice(mediaList.length - 2, 2);
$view = prevMedia
}
}
function saveMedia () {
if (mediaList.length > 0) {
const lastMedia = mediaList[mediaList.length - 1]
if (media !== lastMedia) {
mediaList.push(media)
}
} else {
mediaList.push(media)
}
} }
$: media = $view
let modal let modal
$: media && modal?.focus() let container = null
let mediaList = []
$: media = anilistClient.mediaCache[$view?.id] || $view
$: mediaRecommendation = media && anilistClient.recommendations({ id: media.id })
$: media && (modal?.focus(), overlay = 'viewanime', saveMedia(), (container && container.dispatchEvent(new Event('scrolltop'))))
function checkClose ({ keyCode }) { function checkClose ({ keyCode }) {
if (keyCode === 27) close() if (keyCode === 27) close()
} }
function play (episode) { function play (episode) {
close() close(true)
if (episode) return playAnime(media, episode) if (episode) return playAnime(media, episode)
if (media.status === 'NOT_YET_RELEASED') return if (media.status === 'NOT_YET_RELEASED') return
playMedia(media) playMedia(media)
@ -44,16 +73,6 @@
return 'Watch Now' return 'Watch Now'
} }
$: playButtonText = getPlayButtonText(media) $: playButtonText = getPlayButtonText(media)
async function toggleStatus () {
if (!media.mediaListEntry) {
// add
const res = await setStatus('PLANNING', {}, media)
media.mediaListEntry = res.data.SaveMediaListEntry
} else {
anilistClient.delete({ id: media.mediaListEntry.id })
media.mediaListEntry = undefined
}
}
function toggleFavourite () { function toggleFavourite () {
anilistClient.favourite({ id: media.id }) anilistClient.favourite({ id: media.id })
media.isFavourite = !media.isFavourite media.isFavourite = !media.isFavourite
@ -69,6 +88,11 @@
IPC.emit('open', url) IPC.emit('open', url)
} }
let episodeOrder = true let episodeOrder = true
window.addEventListener('overlay-check', () => {
if (media) {
close()
}
})
// async function score (media, score) { // async function score (media, score) {
// const variables = { // const variables = {
@ -80,10 +104,15 @@
// } // }
</script> </script>
<div class='modal modal-full z-100' class:show={media} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}> <div class='modal modal-full z-50' class:show={media} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
{#if media} {#if media}
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto position-relative' use:smoothScroll> <div class='h-full modal-content bg-very-dark p-0 overflow-y-auto position-relative' bind:this={container} use:smoothScroll>
<button class='close pointer z-30 bg-dark top-20 right-0 position-fixed' type='button' use:click={close}> &times; </button> {#if mediaList.length > 1}
<button class='close back pointer z-30 bg-dark top-20 left-0 position-fixed' use:click={back}>
<ArrowLeft size='1.8rem' />
</button>
{/if}
<button class='close pointer z-30 bg-dark top-20 right-0 position-fixed' type='button' use:click={() => close()}> &times; </button>
<img class='w-full cover-img banner position-absolute' alt='banner' src={media.bannerImage || ' '} /> <img class='w-full cover-img banner position-absolute' alt='banner' src={media.bannerImage || ' '} />
<div class='row px-20'> <div class='row px-20'>
<div class='col-lg-7 col-12 pb-10'> <div class='col-lg-7 col-12 pb-10'>
@ -92,10 +121,10 @@
<img class='rounded cover-img overflow-hidden h-full' alt='cover-art' src={media.coverImage?.extraLarge || media.coverImage?.medium} /> <img class='rounded cover-img overflow-hidden h-full' alt='cover-art' src={media.coverImage?.extraLarge || media.coverImage?.medium} />
</div> </div>
<div class='pl-sm-20 ml-sm-20'> <div class='pl-sm-20 ml-sm-20'>
<h1 class='font-weight-very-bold text-white select-all mb-0'>{media.title.userPreferred}</h1> <h1 class='font-weight-very-bold text-white select-all mb-0'>{anilistClient.title(media)}</h1>
<div class='d-flex flex-row font-size-18 flex-wrap mt-5'> <div class='d-flex flex-row font-size-18 flex-wrap mt-5'>
{#if media.averageScore} {#if media.averageScore}
<div class='d-flex flex-row mt-10'> <div class='d-flex flex-row mt-10' title='{media.averageScore / 10} by {anilistClient.reviews(media)} reviews'>
<TrendingUp class='mx-10' size='2.2rem' /> <TrendingUp class='mx-10' size='2.2rem' />
<span class='mr-20'> <span class='mr-20'>
Rating: {media.averageScore + '%'} Rating: {media.averageScore + '%'}
@ -125,6 +154,17 @@
</span> </span>
</div> </div>
{/if} {/if}
{#if media.stats?.scoreDistribution}
<div class='d-flex flex-row mt-10'>
<Users class='mx-10' size='2.2rem' />
<span class='mr-20' title='{media.averageScore / 10} by {anilistClient.reviews(media)} reviews'>
Reviews: {anilistClient.reviews(media)}
</span>
</div>
{/if}
<div class='d-flex flex-row mt-10'>
<AudioLabel {media} viewAnime={true}/>
</div>
</div> </div>
<div class='d-flex flex-row flex-wrap'> <div class='d-flex flex-row flex-wrap'>
<button class='btn btn-lg btn-secondary w-250 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center mr-10 mt-20' <button class='btn btn-lg btn-secondary w-250 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center mr-10 mt-20'
@ -134,19 +174,17 @@
{playButtonText} {playButtonText}
</button> </button>
<div class='mt-20 d-flex'> <div class='mt-20 d-flex'>
<button title="Favourite" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!alToken}> <button title="Favourite" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0' use:click={toggleFavourite} disabled={!Helper.isAniAuth()}>
<Heart fill={media.isFavourite ? 'currentColor' : 'transparent'} size='1.7rem' /> <Heart fill={media.isFavourite ? 'currentColor' : 'transparent'} size='1.7rem' />
</button> </button>
<button title="Bookmark" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={toggleStatus} disabled={!alToken}> <Scoring {media} viewAnime={true} />
<Bookmark fill={media.mediaListEntry ? 'currentColor' : 'transparent'} size='1.7rem' />
</button>
<button title="Share" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => copyToClipboard(`https://miguapp.pages.dev/anime/${media.id}`)}> <button title="Share" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => copyToClipboard(`https://miguapp.pages.dev/anime/${media.id}`)}>
<Share2 size='1.7rem' /> <Share2 size='1.7rem' />
</button> </button>
<button title="Non-torrent alternatives" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => openInBrowser(`https://kuroiru.co/anime/${media.idMal}#tab=stream`)}> <button title="Non-torrent alternatives" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => openInBrowser(`https://kuroiru.co/anime/${media.idMal}/#tab=streams`)}>
<LibraryBig size='1.7rem' /> <LibraryBig size='1.7rem' />
</button> </button>
<button title="Open AniList" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => openInBrowser(`https://anilist.co/anime/${media.id}`)}> <button title="Open in Anilist" class='btn bg-dark btn-lg btn-square d-flex align-items-center justify-content-center shadow-none border-0 ml-10' use:click={() => openInBrowser(`https://anilist.co/anime/${media.id}`)}>
<ExternalLink size='1.7rem' /> <ExternalLink size='1.7rem' />
</button> </button>
<!-- <div class='input-group shadow-lg mb-5 font-size-16'> <!-- <div class='input-group shadow-lg mb-5 font-size-16'>
@ -171,7 +209,14 @@
</div> </div>
</div> </div>
</div> </div>
<Details {media} /> <Details {media} alt={mediaRecommendation} />
<div class='m-0 px-20 pb-0 pt-10 d-flex flex-row text-nowrap overflow-x-scroll text-capitalize align-items-start'>
{#each media.tags as tag}
<div class='bg-dark px-20 py-10 mr-10 rounded text-nowrap'>
<span class='font-weight-bolder'>{tag.name}</span><span class='font-weight-light'>: {tag.rank}%</span>
</div>
{/each}
</div>
<div class='d-flex flex-row mt-20 pt-10'> <div class='d-flex flex-row mt-20 pt-10'>
{#each media.genres as genre} {#each media.genres as genre}
<div class='bg-dark px-20 py-10 mr-10 rounded font-size-16'> <div class='bg-dark px-20 py-10 mr-10 rounded font-size-16'>
@ -179,6 +224,7 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if media.description}
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'> <div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
<hr class='w-full' /> <hr class='w-full' />
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Synopsis</div> <div class='font-size-18 font-weight-semi-bold px-20 text-white'>Synopsis</div>
@ -187,22 +233,40 @@
<div class='font-size-16 pre-wrap pt-20 select-all'> <div class='font-size-16 pre-wrap pt-20 select-all'>
{media.description?.replace(/<[^>]*>/g, '') || ''} {media.description?.replace(/<[^>]*>/g, '') || ''}
</div> </div>
<ToggleList list={media.relations?.edges?.filter(({ node }) => node.type === 'ANIME')} let:item title='Relations'> {/if}
<div class='w-150 mx-15 my-10 rel pointer' <ToggleList list={
use:click={async () => { $view = null; $view = (await anilistClient.searchIDSingle({ id: item.node.id })).data.Media }}> media.relations?.edges?.filter(({ node }) => node.type === 'ANIME').sort((a, b) => {
<img loading='lazy' src={item.node.coverImage.medium || ''} alt='cover' class='cover-img w-full h-200 rel-img rounded' /> const typeComparison = a.relationType.localeCompare(b.relationType)
<div class='pt-5'>{item.relationType.replace(/_/g, ' ').toLowerCase()}</div> if (typeComparison !== 0) {
<h5 class='font-weight-bold text-white mb-5'>{item.node.title.userPreferred}</h5> return typeComparison
}
return (a.node.seasonYear || 0) - (b.node.seasonYear || 0)
})} promise={ anilistClient.searchIDS({ page: 1, perPage: 50, id: media.relations?.edges?.filter(({ node }) => node.type === 'ANIME').map(({ node }) => node.id) }) } let:item let:promise title='Relations'>
<div class='small-card'>
{#await promise}
<SkeletonCard />
{:then res }
{#if res}
<SmallCard media={anilistClient.mediaCache[item.node.id]} type={item.relationType.replace(/_/g, ' ').toLowerCase()} />
{/if}
{/await}
</div> </div>
</ToggleList> </ToggleList>
<Following {media} /> {#await mediaRecommendation then res} <!-- reduces query complexity improving load times -->
<!-- <ToggleList list={media.recommendations.edges.filter(edge => edge.node.mediaRecommendation)} let:item title='Recommendations'> {@const mediaRecommendation = res?.data?.Media}
<div class='w-150 mx-15 my-10 rel pointer' <ToggleList list={ mediaRecommendation.recommendations?.edges?.filter(({ node }) => node.mediaRecommendation).sort((a, b) => b.node.rating - a.node.rating) } promise={ anilistClient.searchIDS({ page: 1, perPage: 50, id: mediaRecommendation.recommendations?.edges?.map(({ node }) => node.mediaRecommendation?.id) }) } let:item let:promise title='Recommendations'>
use:click={async () => { $view = null; $view = (await anilistClient.searchIDSingle({ id: item.node.mediaRecommendation.id })).data.Media }}> <div class='small-card'>
<img loading='lazy' src={item.node.mediaRecommendation.coverImage.medium || ''} alt='cover' class='cover-img w-full h-200 rel-img rounded' /> {#await promise}
<h5 class='font-weight-bold text-white mb-5'>{item.node.mediaRecommendation.title.userPreferred}</h5> <SkeletonCard />
{:then res }
{#if res}
<SmallCard media={anilistClient.mediaCache[item.node.mediaRecommendation.id]} type={item.node.rating} />
{/if}
{/await}
</div> </div>
</ToggleList> --> </ToggleList>
{/await}
<Following {media} />
<div class='w-full d-flex d-lg-none flex-row align-items-center pt-20 mt-10 pointer'> <div class='w-full d-flex d-lg-none flex-row align-items-center pt-20 mt-10 pointer'>
<hr class='w-full' /> <hr class='w-full' />
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Episodes</div> <div class='font-size-18 font-weight-semi-bold px-20 text-white'>Episodes</div>
@ -212,7 +276,7 @@
</div> </div>
</div> </div>
<div class='col-lg-5 col-12 d-flex flex-column pl-lg-20 overflow-x-hidden'> <div class='col-lg-5 col-12 d-flex flex-column pl-lg-20 overflow-x-hidden'>
<EpisodeList {media} {episodeOrder} userProgress={media.mediaListEntry?.status === 'CURRENT' && media.mediaListEntry.progress} watched={media.mediaListEntry?.status === 'COMPLETED'} episodeCount={getMediaMaxEp(media)} {play} /> <EpisodeList {media} {episodeOrder} userProgress={['CURRENT', 'PAUSED', 'DROPPED'].includes(media.mediaListEntry?.status) && media.mediaListEntry.progress} watched={media.mediaListEntry?.status === 'COMPLETED'} episodeCount={getMediaMaxEp(media)} {play} />
</div> </div>
</div> </div>
</div> </div>
@ -225,6 +289,11 @@
left: unset !important; left: unset !important;
right: 3rem !important; right: 3rem !important;
} }
.back {
top: 5rem !important;
left: 9rem !important;
right: unset !important;
}
.banner { .banner {
opacity: 0.5; opacity: 0.5;
z-index: 0; z-index: 0;
@ -247,6 +316,9 @@
.cover { .cover {
aspect-ratio: 7/10; aspect-ratio: 7/10;
} }
.small-card {
width: 23rem !important;
}
button.bg-dark:not([disabled]):hover { button.bg-dark:not([disabled]):hover {
background: #292d33 !important; background: #292d33 !important;

View file

@ -14,7 +14,7 @@
</script> </script>
<div class='message d-flex flex-row mt-15' class:flex-row={incoming} class:flex-row-reverse={!incoming}> <div class='message d-flex flex-row mt-15' class:flex-row={incoming} class:flex-row-reverse={!incoming}>
<img src={user?.avatar?.medium || 'https://s4.anilist.co/file/anilistcdn/user/avatar/large/default.png'} alt='ProfilePicture' class='w-50 h-50 rounded-circle p-5 mt-auto' /> <img src={user?.avatar?.medium || user?.picture || 'https://s4.anilist.co/file/anilistcdn/user/avatar/large/default.png'} alt='ProfilePicture' class='w-50 h-50 rounded-circle p-5 mt-auto' />
<div class='d-flex flex-column px-10 align-items-start flex-auto' class:align-items-start={incoming} class:align-items-end={!incoming}> <div class='d-flex flex-column px-10 align-items-start flex-auto' class:align-items-start={incoming} class:align-items-end={!incoming}>
<div class='pb-5 d-flex flex-row align-items-center px-5'> <div class='pb-5 d-flex flex-row align-items-center px-5'>
<div class='font-weight-bold font-size-18 line-height-normal'> <div class='font-weight-bold font-size-18 line-height-normal'>

View file

@ -8,12 +8,12 @@
</script> </script>
<div class='d-flex align-items-center pb-10'> <div class='d-flex align-items-center pb-10'>
<img src={user?.avatar?.medium || 'https://s4.anilist.co/file/anilistcdn/user/avatar/large/default.png'} alt='ProfilePicture' class='w-50 h-50 rounded-circle p-5 mt-auto' /> <img src={user?.avatar?.medium || user?.picture || 'https://s4.anilist.co/file/anilistcdn/user/avatar/large/default.png'} alt='ProfilePicture' class='w-50 h-50 rounded-circle p-5 mt-auto' />
<div class='font-size-18 line-height-normal pl-5'> <div class='font-size-18 line-height-normal pl-5'>
{user?.name || 'Anonymous'} {user?.name || 'Anonymous'}
</div> </div>
{#if user?.name} {#if user?.name}
<span class='pointer text-primary d-flex align-items-center ml-auto' use:click={() => IPC.emit('open', 'https://anilist.co/user/' + user.name)}> <span class='pointer text-primary d-flex align-items-center ml-auto' use:click={() => IPC.emit('open', (user?.avatar?.medium ? 'https://anilist.co/user/' : 'https://myanimelist.net/profile/') + user.name)}>
<ExternalLink size='2rem' /> <ExternalLink size='2rem' />
</span> </span>
{/if} {/if}

View file

@ -3,7 +3,7 @@ import { EventEmitter } from 'events'
import P2PT from 'p2pt' import P2PT from 'p2pt'
import Event, { EventTypes } from './events.js' import Event, { EventTypes } from './events.js'
import { anilistClient } from '@/modules/anilist.js' import Helper from '@/modules/helper.js'
import { add } from '@/modules/torrent.js' import { add } from '@/modules/torrent.js'
import { generateRandomHexCode } from '@/modules/util.js' import { generateRandomHexCode } from '@/modules/util.js'
import { writable } from 'simple-store-svelte' import { writable } from 'simple-store-svelte'
@ -37,7 +37,7 @@ export class W2GClient extends EventEmitter {
/** @type {import('simple-store-svelte').Writable<{message: string, user: import('@/modules/al.d.ts').Viewer | {id: string }, type: 'incoming' | 'outgoing', date: Date}[]>} */ /** @type {import('simple-store-svelte').Writable<{message: string, user: import('@/modules/al.d.ts').Viewer | {id: string }, type: 'incoming' | 'outgoing', date: Date}[]>} */
messages = writable([]) messages = writable([])
self = anilistClient.userID?.viewer.data.Viewer || { id: generateRandomHexCode(16) } self = Helper.getUser() || { id: generateRandomHexCode(16) }
/** @type {import('simple-store-svelte').Writable<PeerList>} */ /** @type {import('simple-store-svelte').Writable<PeerList>} */
peers = writable({ [this.self.id]: { user: this.self } }) peers = writable({ [this.self.id]: { user: this.self } })

View file

@ -133,9 +133,9 @@ export default class App {
ipcMain.on('portRequest', async ({ sender }) => { ipcMain.on('portRequest', async ({ sender }) => {
const { port1, port2 } = new MessageChannelMain() const { port1, port2 } = new MessageChannelMain()
await torrentLoad await torrentLoad
this.webtorrentWindow.webContents.postMessage('port', null, [port1])
this.webtorrentWindow.webContents.postMessage('player', store.get('player')) this.webtorrentWindow.webContents.postMessage('player', store.get('player'))
this.webtorrentWindow.webContents.postMessage('torrentPath', store.get('torrentPath')) this.webtorrentWindow.webContents.postMessage('torrentPath', store.get('torrentPath'))
this.webtorrentWindow.webContents.postMessage('port', null, [port1])
sender.postMessage('port', null, [port2]) sender.postMessage('port', null, [port2])
}) })

View file

@ -9,7 +9,7 @@ export default class Discord {
details: 'Stream anime torrents, real-time.', details: 'Stream anime torrents, real-time.',
state: 'Watching anime', state: 'Watching anime',
assets: { assets: {
small_image: 'https://raw.githubusercontent.com/NoCrypt/migu/main/common/public/logo_filled.png', small_image: 'logo',
small_text: 'https://github.com/NoCrypt/migu' small_text: 'https://github.com/NoCrypt/migu'
}, },
buttons: [ buttons: [

View file

@ -14,6 +14,7 @@ export default class Protocol {
// schema: migu://key/value // schema: migu://key/value
protocolMap = { protocolMap = {
auth: token => this.sendToken(token), auth: token => this.sendToken(token),
malauth: token => this.sendMalToken(token),
anime: id => this.window.webContents.send('open-anime', id), anime: id => this.window.webContents.send('open-anime', id),
w2g: link => this.window.webContents.send('w2glink', link), w2g: link => this.window.webContents.send('w2glink', link),
schedule: () => this.window.webContents.send('schedule'), schedule: () => this.window.webContents.send('schedule'),
@ -71,6 +72,20 @@ export default class Protocol {
} }
} }
/**
* @param {string} line
*/
sendMalToken (line) {
let code = line.split('code=')[1].split('&state')[0]
let state = line.split('&state=')[1]
if (code && state) {
if (code.endsWith('/')) code = code.slice(0, -1)
if (state.endsWith('/')) state = state.slice(0, -1)
if (state.includes('%')) state = decodeURIComponent(state)
this.window.webContents.send('maltoken', code, state)
}
}
/** /**
* @param {string} text * @param {string} text
*/ */

View file

@ -24,6 +24,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0", "eslint-config-standard": "^17.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"fuse.js": "^7.0.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"matroska-metadata": "^1.0.6", "matroska-metadata": "^1.0.6",
"mini-css-extract-plugin": "^2.8.1", "mini-css-extract-plugin": "^2.8.1",

View file

@ -32,6 +32,9 @@ importers:
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^2.35.1 specifier: ^2.35.1
version: 2.35.1(eslint@8.57.0)(svelte@4.2.12)(ts-node@10.9.2(typescript@5.4.5)) version: 2.35.1(eslint@8.57.0)(svelte@4.2.12)(ts-node@10.9.2(typescript@5.4.5))
fuse.js:
specifier: ^7.0.0
version: 7.0.0
html-webpack-plugin: html-webpack-plugin:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0(webpack@5.91.0(webpack-cli@5.1.4)) version: 5.6.0(webpack@5.91.0(webpack-cli@5.1.4))
@ -76,8 +79,8 @@ importers:
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(@capacitor/core@6.1.1) version: 6.0.2(@capacitor/core@6.1.1)
'@capacitor/browser': '@capacitor/browser':
specifier: ^6.0.1 specifier: ^6.0.2
version: 6.0.1(@capacitor/core@6.1.1) version: 6.0.2(@capacitor/core@6.1.1)
'@capacitor/cli': '@capacitor/cli':
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1 version: 6.1.1
@ -349,8 +352,8 @@ packages:
engines: {node: '>=10.3.0'} engines: {node: '>=10.3.0'}
hasBin: true hasBin: true
'@capacitor/browser@6.0.1': '@capacitor/browser@6.0.2':
resolution: {integrity: sha512-KBK0PKfmUj0if+gYWEh0+LG70l1gcLGbDCWJt2Ig3naXHGlrLoWBqVArCgbwBzwJZL+VlwW7iEhAzGOWpg2jhw==} resolution: {integrity: sha512-mJjdKbpdCAaaDVZD/vjzpJJxL1VvwsGTcEGn+4PpCQyPu3+yNQO7vgjwBV7ZYS6+mZIKeYn5swWq0BFuAcDqFg==}
peerDependencies: peerDependencies:
'@capacitor/core': ^6.0.0 '@capacitor/core': ^6.0.0
@ -2755,6 +2758,10 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuse.js@7.0.0:
resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==}
engines: {node: '>=10'}
get-browser-rtc@1.1.0: get-browser-rtc@1.1.0:
resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==} resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==}
@ -5687,7 +5694,7 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
'@capacitor/browser@6.0.1(@capacitor/core@6.1.1)': '@capacitor/browser@6.0.2(@capacitor/core@6.1.1)':
dependencies: dependencies:
'@capacitor/core': 6.1.1 '@capacitor/core': 6.1.1
@ -6586,7 +6593,7 @@ snapshots:
'@typescript-eslint/types': 7.1.1 '@typescript-eslint/types': 7.1.1
'@typescript-eslint/typescript-estree': 7.1.1(typescript@5.4.5) '@typescript-eslint/typescript-estree': 7.1.1(typescript@5.4.5)
'@typescript-eslint/visitor-keys': 7.1.1 '@typescript-eslint/visitor-keys': 7.1.1
debug: 4.3.4 debug: 4.3.5
eslint: 8.57.0 eslint: 8.57.0
optionalDependencies: optionalDependencies:
typescript: 5.4.5 typescript: 5.4.5
@ -7716,10 +7723,10 @@ snapshots:
dependencies: dependencies:
'@ionic/utils-array': 2.1.6 '@ionic/utils-array': 2.1.6
'@ionic/utils-fs': 3.1.7 '@ionic/utils-fs': 3.1.7
debug: 4.3.4 debug: 4.3.5
elementtree: 0.1.7 elementtree: 0.1.7
sharp: 0.29.3 sharp: 0.29.3
tslib: 2.6.2 tslib: 2.6.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -8742,6 +8749,8 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
fuse.js@7.0.0: {}
get-browser-rtc@1.1.0: {} get-browser-rtc@1.1.0: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}

View file

@ -21,9 +21,6 @@
} }
const playButtonText = getPlayButtonText(media) const playButtonText = getPlayButtonText(media)
function volume (video) {
video.volume = 0.1
}
let muted = true let muted = true
function toggleMute () { function toggleMute () {
muted = !muted muted = !muted
@ -32,19 +29,19 @@
function play () { function play () {
open('miru://anime/' + media.id) open('miru://anime/' + media.id)
} }
function lazyload (video) { function lazyload (iframe) {
if ('IntersectionObserver' in window) { if ('IntersectionObserver' in window) {
const lazyVideoObserver = new IntersectionObserver(entries => { const lazyVideoObserver = new IntersectionObserver(entries => {
for (const { target, isIntersecting } of entries) { for (const { target, isIntersecting } of entries) {
if (isIntersecting) { if (isIntersecting) {
video.src = video.dataset.src iframe.src = iframe.dataset.src
lazyVideoObserver.unobserve(target) lazyVideoObserver.unobserve(target)
} }
} }
}) })
lazyVideoObserver.observe(video.parentNode) lazyVideoObserver.observe(iframe.parentNode)
} else { } else {
video.src = video.dataset.src iframe.src = iframe.dataset.src
} }
} }
</script> </script>
@ -56,18 +53,15 @@
<div class='material-symbols-outlined filled position-absolute z-10 top-0 right-0 p-15 font-size-22' class:d-none={hide} use:click={toggleMute}>{muted ? 'volume_off' : 'volume_up'}</div> <div class='material-symbols-outlined filled position-absolute z-10 top-0 right-0 p-15 font-size-22' class:d-none={hide} use:click={toggleMute}>{muted ? 'volume_off' : 'volume_up'}</div>
<!-- for now we use some invidious instance, would be nice to somehow get these links outselves, this redirects straight to some google endpoint --> <!-- for now we use some invidious instance, would be nice to somehow get these links outselves, this redirects straight to some google endpoint -->
<!-- eslint-disable-next-line svelte/valid-compile --> <!-- eslint-disable-next-line svelte/valid-compile -->
<video data-src={`https://inv.tux.pizza/latest_version?id=${media.trailer.id}&itag=18`} <iframe
class='w-full h-full position-absolute left-0' class='w-full border-0 position-absolute left-0'
class:d-none={hide} class:d-none={hide}
playsinline title={media.title.userPreferred}
preload='none' allow='autoplay'
loading='lazy'
use:lazyload use:lazyload
loop on:load={() => { hide = false }}
use:volume data-src={`https://www.youtube-nocookie.com/embed/${media.trailer?.id}?autoplay=1&controls=0&mute=${muted ? 1 : 0}&disablekb=1&loop=1&vq=medium&playlist=${media.trailer?.id}&cc_lang_pref=ja`}
bind:muted />
on:loadeddata={() => { hide = false }}
autoplay />
{/if} {/if}
</div> </div>
<div class='w-full px-20'> <div class='w-full px-20'>

View file

@ -1,5 +1,28 @@
@import '@fontsource-variable/material-symbols-outlined/full.css'; @import '@fontsource-variable/material-symbols-outlined/full.css';
.material-symbols-outlined {
font-family: "Material Symbols Outlined Variable";
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "liga";
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 64;
}
.filled {
font-variation-settings: 'FILL' 1;
}
.dark-mode { .dark-mode {
background-color: #101113 !important; background-color: #101113 !important;
} }