mirror of
https://github.com/NoCrypt/migu.git
synced 2026-01-11 20:10:22 +00:00
Merge branch 'master' of https://github.com/RockinChaos/miru into pr-test
This commit is contained in:
commit
50f8c2c190
72 changed files with 3150 additions and 798 deletions
|
|
@ -19,6 +19,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "github:thaunknown/capacitor-assets",
|
||||
"@capacitor/cli": "^6.1.2",
|
||||
"cordova-res": "^0.15.4",
|
||||
"nodejs-mobile-gyp": "^0.4.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
"@capacitor/android": "^6.1.1",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/app-launcher": "^6.0.2",
|
||||
"@capacitor/browser": "^6.0.1",
|
||||
"@capacitor/browser": "^6.0.2",
|
||||
"@capacitor/cli": "^6.1.1",
|
||||
"@capacitor/core": "^6.1.1",
|
||||
"@capacitor/device": "^6.0.1",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ IPC.on('dialog', async () => {
|
|||
// schema: migu://key/value
|
||||
const protocolMap = {
|
||||
auth: token => sendToken(token),
|
||||
malauth: token => sendMalToken(token),
|
||||
anime: id => IPC.emit('open-anime', id),
|
||||
w2g: link => IPC.emit('w2glink', link),
|
||||
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 => {
|
||||
if (location.hash !== '#skipAlLogin') {
|
||||
location.hash = '#skipAlLogin'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// import { rss } from './views/TorrentSearch/TorrentModal.svelte'
|
||||
|
||||
export const page = writable('home')
|
||||
export const overlay = writable('none')
|
||||
export const view = writable(null)
|
||||
export async function handleAnime (anime) {
|
||||
view.set(null)
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
import TorrentModal from './views/TorrentSearch/TorrentModal.svelte'
|
||||
import Menubar from './components/Menubar.svelte'
|
||||
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 { SUPPORTS } from '@/modules/support.js';
|
||||
import UpdateModal, { changeLog, updateModal } from './components/UpdateModal.svelte';
|
||||
|
|
@ -108,20 +109,20 @@
|
|||
|
||||
</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} />
|
||||
<ViewAnime />
|
||||
<UpdateModal />
|
||||
<Logout />
|
||||
<Profiles />
|
||||
<Sidebar bind:page={$page} />
|
||||
<Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton toastOptions={{
|
||||
classes: {
|
||||
closeButton: SUPPORTS.isAndroid ? "toast-close-button" : ""
|
||||
}
|
||||
}} style="margin-top: var(--safe-area-top)"/>
|
||||
<div class='overflow-hidden content-wrapper h-full z-10'>
|
||||
<TorrentModal />
|
||||
<Router bind:page={$page} />
|
||||
<div class='overflow-hidden content-wrapper h-full'>
|
||||
<Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton toastOptions={{
|
||||
classes: {
|
||||
closeButton: SUPPORTS.isAndroid ? "toast-close-button" : ""
|
||||
}
|
||||
}} />
|
||||
<ViewAnime bind:overlay={$overlay} />
|
||||
<UpdateModal/>
|
||||
<TorrentModal bind:overlay={$overlay} />
|
||||
<Router bind:page={$page} bind:overlay={$overlay} />
|
||||
</div>
|
||||
<Navbar bind:page={$page} />
|
||||
</div>
|
||||
|
|
@ -145,6 +146,7 @@
|
|||
|
||||
.content-wrapper {
|
||||
will-change: width;
|
||||
white-space: pre-line;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script context='module'>
|
||||
import { readable } from 'simple-store-svelte'
|
||||
|
||||
const mql = matchMedia('(min-width: 769px)')
|
||||
const isMobile = readable(!mql.matches, set => {
|
||||
const check = ({ matches }) => set(!matches)
|
||||
|
|
@ -15,7 +17,6 @@
|
|||
import Miniplayer from 'svelte-miniplayer'
|
||||
import Search from './views/Search.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 { onMount } from 'svelte';
|
||||
import { SUPPORTS } from '@/modules/support.js';
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
import { rss } from './views/TorrentSearch/TorrentModal.svelte';
|
||||
|
||||
export let page = 'home'
|
||||
export let overlay = 'none'
|
||||
|
||||
$: minwidth = $isMobile ? '200px' : '35rem'
|
||||
$: maxwidth = $isMobile ? '200px' : '60rem'
|
||||
|
|
@ -50,13 +52,13 @@
|
|||
</script>
|
||||
|
||||
<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}>
|
||||
<MediaHandler miniplayer={page !== 'player'} bind:page />
|
||||
<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' || overlay === 'viewanime'} bind:page bind:overlay />
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
{#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;">×</div>
|
||||
{/if}
|
||||
</Miniplayer>
|
||||
</Miniplayer>
|
||||
</div>
|
||||
{#if page === 'settings'}
|
||||
<Settings />
|
||||
|
|
|
|||
|
|
@ -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}> × </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>
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import { media } from '../views/Player/MediaHandler.svelte'
|
||||
import { rss } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import NavbarLink from './NavbarLink.svelte'
|
||||
// import { click } from '@/modules/click.js'
|
||||
// import IPC from '@/modules/ipc.js'
|
||||
import { MagnifyingGlass } from 'svelte-radix'
|
||||
import { Users, Clock, Settings, Heart, ListVideo, House } from 'lucide-svelte'
|
||||
const view = getContext('view')
|
||||
|
|
@ -15,11 +17,15 @@
|
|||
$rss = null
|
||||
}
|
||||
|
||||
// function close () {
|
||||
// $view = null
|
||||
// page = 'home'
|
||||
// }
|
||||
</script>
|
||||
|
||||
<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'}>
|
||||
<!-- <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>
|
||||
<House size='2.2rem' class='flex-shrink-0 p-5 w-30 h-30 m-5 rounded' strokeWidth={active ? '3.5' : '2'} />
|
||||
</NavbarLink>
|
||||
|
|
@ -67,4 +73,4 @@
|
|||
filter: drop-shadow(0 0 0.5rem #fa68b6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
@ -11,8 +11,7 @@
|
|||
|
||||
<div
|
||||
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'>
|
||||
<slot active={page === _page}>{icon}</slot>
|
||||
</span>
|
||||
|
|
|
|||
230
common/components/Profiles.svelte
Normal file
230
common/components/Profiles.svelte
Normal 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}> × </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>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<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 badgeKeys.includes(entry[0]) && entry
|
||||
return (!badge || badgeKeys.includes(entry[0])) && entry
|
||||
}).filter(a => a?.[1]))
|
||||
}
|
||||
</script>
|
||||
|
|
@ -14,103 +16,413 @@
|
|||
import { click } from '@/modules/click.js'
|
||||
import { page } from '@/App.svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import Helper from '@/modules/helper.js'
|
||||
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
|
||||
let searchTextInput
|
||||
let searchTextInput = {
|
||||
title: null,
|
||||
genre: null,
|
||||
tag: null
|
||||
}
|
||||
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'
|
||||
]
|
||||
|
||||
function searchClear () {
|
||||
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() {
|
||||
search = {
|
||||
title: '',
|
||||
search: '',
|
||||
genre: '',
|
||||
tag: '',
|
||||
season: '',
|
||||
year: null,
|
||||
format: '',
|
||||
status: '',
|
||||
sort: ''
|
||||
sort: '',
|
||||
hideSubs: false,
|
||||
hideMyAnime: false,
|
||||
hideStatus: ''
|
||||
}
|
||||
searchTextInput.focus()
|
||||
searchTextInput.title.focus()
|
||||
form.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
$page = 'search'
|
||||
}
|
||||
|
||||
function handleFile ({ target }) {
|
||||
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 }) {
|
||||
const { files } = target
|
||||
if (files?.[0]) {
|
||||
toast.promise(traceAnime(files[0]), {
|
||||
description: 'You can also paste an URL to an image.',
|
||||
loading: 'Looking up 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
|
||||
}
|
||||
}
|
||||
function changeCardMode (type) {
|
||||
|
||||
function changeCardMode(type) {
|
||||
$settings.cards = type
|
||||
form.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
</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} >
|
||||
<div class='row' style="padding-top: var(--safe-area-top)">
|
||||
<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'>
|
||||
<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'>
|
||||
<Type class='mr-10' size='3rem' />
|
||||
Title
|
||||
<div>Title</div>
|
||||
</div>
|
||||
<div class='input-group'>
|
||||
<div class='input-group-prepend'>
|
||||
<MagnifyingGlass size='2.75rem' class='input-group-text bg-dark-light pr-0' />
|
||||
</div>
|
||||
<input
|
||||
bind:this={searchTextInput}
|
||||
bind:this={searchTextInput.title}
|
||||
type='search'
|
||||
class='form-control bg-dark-light border-left-0 text-capitalize'
|
||||
autocomplete='off'
|
||||
bind:value={search.search}
|
||||
data-option='search'
|
||||
disabled={search.disableSearch}
|
||||
placeholder='Any' />
|
||||
placeholder='Any'/>
|
||||
</div>
|
||||
</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'>
|
||||
<Drama class='mr-10' size='3rem' />
|
||||
Genre
|
||||
<div>Genres</div>
|
||||
</div>
|
||||
<div class='input-group'>
|
||||
<select class='form-control bg-dark-light' required bind:value={search.genre} disabled={search.disableSearch}>
|
||||
<option value selected>Any</option>
|
||||
<option value='Action'>Action</option>
|
||||
<option value='Adventure'>Adventure</option>
|
||||
<option value='Comedy'>Comedy</option>
|
||||
<option value='Drama'>Drama</option>
|
||||
<option value='Ecchi'>Ecchi</option>
|
||||
<option value='Fantasy'>Fantasy</option>
|
||||
<option value='Horror'>Horror</option>
|
||||
<option value='Mahou Shoujo'>Mahou Shoujo</option>
|
||||
<option value='Mecha'>Mecha</option>
|
||||
<option value='Music'>Music</option>
|
||||
<option value='Mystery'>Mystery</option>
|
||||
<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>
|
||||
<input
|
||||
id='genre'
|
||||
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.genre}
|
||||
on:keydown={(event) => filterTags(event, 'genre', 'keydown')}
|
||||
on:input={(event) => filterTags(event, 'genre', 'input')}
|
||||
data-option='search'
|
||||
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
|
||||
placeholder='Any'
|
||||
list='search-genre'/>
|
||||
</div>
|
||||
<datalist id='search-genre'>
|
||||
{#each genreList as genre}
|
||||
{#if !search.genre || !search.genre.includes(genre) }
|
||||
<option>{genre}</option>
|
||||
{/if}
|
||||
{/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'>
|
||||
<Leaf class='mr-10' size='3rem' />
|
||||
Season
|
||||
<Hash class='mr-10' size='3rem' />
|
||||
<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 class='input-group'>
|
||||
<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='pb-10 font-size-24 font-weight-semi-bold d-flex'>
|
||||
<Tv class='mr-10' size='3rem' />
|
||||
Format
|
||||
<div>Format</div>
|
||||
</div>
|
||||
<div class='input-group'>
|
||||
<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='pb-10 font-size-24 font-weight-semi-bold d-flex'>
|
||||
<MonitorPlay class='mr-10' size='3rem' />
|
||||
Status
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div class='input-group'>
|
||||
<select class='form-control bg-dark-light' required bind:value={search.status} disabled={search.disableSearch}>
|
||||
<option value selected>Any</option>
|
||||
<option value='RELEASING'>Airing</option>
|
||||
<option value='RELEASING'>Releasing</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>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -163,23 +475,63 @@
|
|||
<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'>
|
||||
<ArrowDownWideNarrow class='mr-10' size='3rem' />
|
||||
Sort
|
||||
<div>Sort</div>
|
||||
</div>
|
||||
<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='START_DATE_DESC'>Release Date</option>
|
||||
<option value='SCORE_DESC'>Score</option>
|
||||
<option value='POPULARITY_DESC'>Popularity</option>
|
||||
<option value='TRENDING_DESC'>Trending</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>
|
||||
</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} />
|
||||
<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'>
|
||||
<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'>
|
||||
<Image size='1.625rem' />
|
||||
</label>
|
||||
|
|
@ -188,19 +540,41 @@
|
|||
</div>
|
||||
<div class='col-auto p-10 d-flex'>
|
||||
<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}>
|
||||
<Trash2 size='1.625rem' />
|
||||
<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}>
|
||||
{#if !!sanitisedSearch?.length || search.disableSearch || search.clearNext}
|
||||
<FilterX size='1.625rem' />
|
||||
{:else}
|
||||
<Filter size='1.625rem' />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='w-full px-10 pt-10 h-50 d-flex flex-colum align-items-center'>
|
||||
{#if sanitisedSearch?.length}
|
||||
<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}
|
||||
<form>
|
||||
<div role="button" tabindex="0">
|
||||
{#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' />
|
||||
{/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='text-dark-light pointer' class:text-muted={$settings.cards === 'full'} use:click={() => changeCardMode('full')}><Grid2X2 size='2.25rem' /></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,54 +1,39 @@
|
|||
<script>
|
||||
import { getContext } from 'svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { media } from '../views/Player/MediaHandler.svelte'
|
||||
import { platformMap } from '@/views/Settings/Settings.svelte'
|
||||
import { media } from '@/views/Player/MediaHandler.svelte'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { logout } from './Logout.svelte'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
// import { toast } from 'svelte-sonner'
|
||||
import { profileView } from './Profiles.svelte'
|
||||
import Helper from '@/modules/helper.js'
|
||||
// import IPC from '@/modules/ipc.js'
|
||||
import SidebarLink from './SidebarLink.svelte'
|
||||
import { Clock, Download, Heart, Home, ListVideo, LogIn, Settings, Users } from 'lucide-svelte'
|
||||
import { MagnifyingGlass } from 'svelte-radix'
|
||||
|
||||
let updateState = ''
|
||||
// let updateState = ''
|
||||
|
||||
IPC.on('update-available', () => {
|
||||
updateState = 'downloading'
|
||||
})
|
||||
IPC.on('update-downloaded', () => {
|
||||
updateState = 'ready'
|
||||
})
|
||||
// IPC.on('update-available', () => {
|
||||
// updateState = 'downloading'
|
||||
// })
|
||||
// IPC.on('update-downloaded', () => {
|
||||
// updateState = 'ready'
|
||||
// })
|
||||
|
||||
const view = getContext('view')
|
||||
|
||||
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>
|
||||
|
||||
<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-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' />
|
||||
</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'} />
|
||||
</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' />
|
||||
</SidebarLink>
|
||||
<SidebarLink click={() => { page = 'schedule' }} _page='schedule' icon='schedule' text='Schedule' {page} let:active>
|
||||
|
|
@ -118,6 +103,9 @@
|
|||
.sidebar.animated:hover {
|
||||
width: 22rem
|
||||
}
|
||||
.sidebar.animated {
|
||||
z-index: 60 !important;
|
||||
}
|
||||
.sidebar-overlay {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
</script>
|
||||
|
||||
<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'>
|
||||
{#if image}
|
||||
<span class='rounded d-flex'>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
<script>
|
||||
import { formatMap, setStatus, playMedia } from '@/modules/anime.js'
|
||||
import { formatMap, playMedia } from '@/modules/anime.js'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
import { alToken } from '@/modules/settings.js'
|
||||
import { Bookmark, Heart } from 'lucide-svelte'
|
||||
import Scoring from '@/views/ViewAnime/Scoring.svelte'
|
||||
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
|
||||
import Helper from "@/modules/helper.js"
|
||||
import { Heart } from 'lucide-svelte'
|
||||
|
||||
export let mediaList
|
||||
|
||||
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 () {
|
||||
anilistClient.favourite({ id: current.id })
|
||||
current.isFavourite = !current.isFavourite
|
||||
|
|
@ -46,13 +38,13 @@
|
|||
</script>
|
||||
|
||||
{#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}
|
||||
<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='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'>
|
||||
{current.title.userPreferred}
|
||||
{anilistClient.title(current)}
|
||||
</div>
|
||||
<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'>
|
||||
|
|
@ -73,6 +65,14 @@
|
|||
{current.duration + ' Minutes'}
|
||||
</span>
|
||||
{/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}
|
||||
<span class='text-nowrap d-flex align-items-center'>
|
||||
{[current.season?.toLowerCase(), current.seasonYear].filter(s => s).join(' ')}
|
||||
|
|
@ -94,12 +94,10 @@
|
|||
use:click={() => playMedia(current)}>
|
||||
Watch Now
|
||||
</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' />
|
||||
</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}>
|
||||
<Bookmark fill={current.mediaListEntry ? 'currentColor' : 'transparent'} size='1.5rem' />
|
||||
</button>
|
||||
<Scoring media={current} />
|
||||
</div>
|
||||
<div class='d-flex'>
|
||||
{#each mediaList as media}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
export let card
|
||||
|
||||
export let variables = null
|
||||
const type = card.type || $settings.cards
|
||||
</script>
|
||||
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
<FullSkeletonCard />
|
||||
{:then media}
|
||||
{#if media}
|
||||
<FullCard media={anilistClient.mediaCache[media.id]} />
|
||||
<FullCard media={anilistClient.mediaCache[media.id]} {variables} />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
<SkeletonCard />
|
||||
{:then media}
|
||||
{#if media}
|
||||
<SmallCard media={anilistClient.mediaCache[media.id]} />
|
||||
<SmallCard media={anilistClient.mediaCache[media.id]} {variables} />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,41 @@
|
|||
<script>
|
||||
import { statusColorMap } from '@/modules/anime.js'
|
||||
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 { getContext, onMount } from 'svelte'
|
||||
import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { SUPPORTS } from '@/modules/support.js';
|
||||
import { Play } from 'lucide-svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
|
||||
export let data
|
||||
|
||||
let preview = false
|
||||
let prompt = writable(false)
|
||||
/** @type {import('@/modules/al.d.ts').Media | null} */
|
||||
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')
|
||||
function viewMedia () {
|
||||
if (data.onclick) {
|
||||
if (SUPPORTS.isAndroid) document.querySelector('.content-wrapper').requestFullscreen()
|
||||
data.onclick()
|
||||
return
|
||||
}
|
||||
|
||||
$view = media
|
||||
}
|
||||
function setClickState() {
|
||||
if ($prompt === false && media?.mediaListEntry?.progress < (data.episode - 1)) {
|
||||
prompt.set(true)
|
||||
} else {
|
||||
if (data.onclick) {
|
||||
if (SUPPORTS.isAndroid) document.querySelector('.content-wrapper').requestFullscreen()
|
||||
data.onclick()
|
||||
} else {
|
||||
viewMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
function setHoverState (state) {
|
||||
preview = state
|
||||
}
|
||||
|
|
@ -39,22 +49,24 @@
|
|||
onMount(() => {
|
||||
|
||||
if (SUPPORTS.isAndroid){
|
||||
click(thisElement, viewMedia)
|
||||
click(thisElement, setClickState)
|
||||
} else {
|
||||
hoverClick(thisElement, [viewMedia, setHoverState])
|
||||
hoverClick(thisElement, [setClickState, setHoverState])
|
||||
}
|
||||
|
||||
})
|
||||
const progress = liveAnimeEpisodeProgress(media?.id, data?.episode)
|
||||
const watched = media?.mediaListEntry?.status === 'COMPLETED'
|
||||
const completed = !watched && media?.mediaListEntry?.progress >= data?.episode
|
||||
</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 !SUPPORTS.isAndroid}
|
||||
<EpisodePreviewCard {data} />
|
||||
<EpisodePreviewCard {data} bind:prompt={$prompt} />
|
||||
{/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 === ' '}>
|
||||
<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' />
|
||||
|
|
@ -63,9 +75,13 @@
|
|||
{media.duration}m
|
||||
{/if}
|
||||
</div>
|
||||
{#if $progress > 0}
|
||||
<div class='progress container-fluid position-absolute' style='height: 2px; min-height: 2px;'>
|
||||
<div class='progress-bar' style='width: {$progress}%' />
|
||||
{#if completed}
|
||||
<div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -75,28 +91,45 @@
|
|||
{#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} />
|
||||
{/if}
|
||||
{media?.title.userPreferred || data.parseObject.anime_title}
|
||||
{anilistClient.title(media) || data.parseObject.anime_title}
|
||||
</div>
|
||||
<div class='text-muted font-size-12 title overflow-hidden'>
|
||||
{data.episodeData?.title?.en || ''}
|
||||
</div>
|
||||
</div>
|
||||
{#if data.episode}
|
||||
<div class='col-auto d-flex flex-column align-items-end text-right'>
|
||||
<div class='text-white font-weight-bold'>
|
||||
<div class='col-auto d-flex flex-column align-items-end text-right'>
|
||||
<div class='text-white font-weight-bold'>
|
||||
{#if 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>
|
||||
{#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'>
|
||||
{since(data.date)}
|
||||
</div>
|
||||
{: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'>
|
||||
{Math.round(data.similarity * 100)}%
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,6 +139,9 @@
|
|||
z-index: 30;
|
||||
/* fixes transform scaling on click causing z-index issues */
|
||||
}
|
||||
.opacity-half {
|
||||
opacity: 30%;
|
||||
}
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
<script>
|
||||
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 { anilistClient } from "@/modules/anilist"
|
||||
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
|
||||
import { getContext } from '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
|
||||
|
||||
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>
|
||||
|
||||
<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
|
||||
{/if}
|
||||
</div>
|
||||
{#if $progress > 0}
|
||||
<div class='progress container-fluid position-absolute mb-5'>
|
||||
<div class='progress-bar' style='width: {$progress}%' />
|
||||
{#if completed}
|
||||
<div class='progress container-fluid position-absolute z-10' style='height: 2px; min-height: 2px;'>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
<div class='w-full d-flex flex-column flex-grow-1 px-20 pb-15'>
|
||||
<div class='row pt-15'>
|
||||
<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}
|
||||
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />
|
||||
{/if}
|
||||
{data.media?.title.userPreferred || data.parseObject.anime_title}
|
||||
{anilistClient.title(media) || data.parseObject.anime_title}
|
||||
</div>
|
||||
<div class='text-muted font-size-12 title overflow-hidden' title={data.episodeData?.title?.en}>
|
||||
{data.episodeData?.title?.en || ''}
|
||||
</div>
|
||||
</div>
|
||||
{#if data.episode}
|
||||
<div class='col-auto d-flex flex-column align-items-end text-right'>
|
||||
<div class='text-white font-weight-bold'>
|
||||
<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}
|
||||
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>
|
||||
{#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'>
|
||||
{since(data.date)}
|
||||
</div>
|
||||
{: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'>
|
||||
{Math.round(data.similarity * 100)}%
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class='w-full text-muted description overflow-hidden pt-15'>
|
||||
{data.episodeData?.description || media?.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
|
|
@ -85,9 +119,22 @@
|
|||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
background-color: rgba(28, 28, 28, 0.9);
|
||||
}
|
||||
.description {
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 3;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@
|
|||
import { countdown } from '@/modules/util.js'
|
||||
import { SUPPORTS } from '@/modules/support.js';
|
||||
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} */
|
||||
export let media
|
||||
export let variables = null
|
||||
|
||||
const view = getContext('view')
|
||||
function viewMedia () {
|
||||
|
|
@ -15,11 +19,12 @@
|
|||
</script>
|
||||
|
||||
<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'}>
|
||||
<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' />
|
||||
<AudioLabel {media} />
|
||||
</div>
|
||||
<div class='col h-full card-grid'>
|
||||
<div class='px-15 py-10 bg-very-dark'>
|
||||
|
|
@ -27,7 +32,7 @@
|
|||
{#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} />
|
||||
{/if}
|
||||
{media.title.userPreferred}
|
||||
{anilistClient.title(media)}
|
||||
</h5>
|
||||
{#if $page === 'schedule'}
|
||||
<div class='py-5'>
|
||||
|
|
@ -41,8 +46,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<p class='text-muted m-0 text-capitalize details'>
|
||||
<span class='text-nowrap'>
|
||||
<p class='details text-muted m-0 text-capitalize d-flex flex-wrap'>
|
||||
<span class='text-nowrap d-flex align-items-center'>
|
||||
{#if media.format === 'TV'}
|
||||
TV Show
|
||||
{:else if media.format}
|
||||
|
|
@ -50,7 +55,7 @@
|
|||
{/if}
|
||||
</span>
|
||||
{#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 }
|
||||
{media.mediaListEntry.progress} / {media.episodes} Episodes
|
||||
{:else}
|
||||
|
|
@ -58,21 +63,39 @@
|
|||
{/if}
|
||||
</span>
|
||||
{: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 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}
|
||||
</p>
|
||||
<p class='details text-muted m-0 text-capitalize d-flex flex-wrap'>
|
||||
{#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(' ')}
|
||||
</span>
|
||||
{/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>
|
||||
</div>
|
||||
<div class='overflow-y-auto px-15 pb-5 bg-very-dark card-desc pre-wrap'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
{#if media.description}
|
||||
<div class='overflow-y-auto px-15 pb-5 bg-very-dark card-desc pre-wrap'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.genres.length}
|
||||
<div class='px-15 pb-10 pt-5 genres'>
|
||||
{#each media.genres.slice(0, 3) as genre}
|
||||
|
|
@ -89,8 +112,17 @@
|
|||
.pre-wrap {
|
||||
white-space: pre-wrap
|
||||
}
|
||||
.details span + span::before {
|
||||
content: ' • ';
|
||||
.opacity-half {
|
||||
opacity: 30%;
|
||||
}
|
||||
.details {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.details > span:not(:last-child)::after {
|
||||
content: '•';
|
||||
padding: .5rem;
|
||||
font-size: .6rem;
|
||||
align-self: center;
|
||||
white-space: normal;
|
||||
}
|
||||
.card {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<script>
|
||||
import { formatMap, setStatus, playMedia } from '@/modules/anime.js'
|
||||
import { formatMap, playMedia } from '@/modules/anime.js'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
import { alToken } from '@/modules/settings.js'
|
||||
import { Bookmark, Heart, Play, VolumeX, Volume2 } from 'lucide-svelte'
|
||||
import Scoring from '@/views/ViewAnime/Scoring.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} */
|
||||
export let media
|
||||
export let type = null
|
||||
|
||||
let hide = true
|
||||
|
||||
|
|
@ -26,17 +30,6 @@
|
|||
return 'Watch Now'
|
||||
}
|
||||
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 () {
|
||||
anilistClient.favourite({ id: media.id })
|
||||
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='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}
|
||||
<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}
|
||||
<VolumeX size='2.2rem' fill='currentColor' />
|
||||
{:else}
|
||||
|
|
@ -86,8 +79,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
<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}>
|
||||
{media.title.userPreferred}
|
||||
<div class='font-size-24 font-weight-bold text-truncate d-inline-block w-full text-white' title={anilistClient.title(media)}>
|
||||
{anilistClient.title(media)}
|
||||
</div>
|
||||
<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'
|
||||
|
|
@ -96,14 +89,22 @@
|
|||
<Play class='pr-10 z-10' fill='currentColor' size='2.2rem' />
|
||||
{playButtonText}
|
||||
</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' />
|
||||
</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}>
|
||||
<Bookmark fill={media.mediaListEntry ? 'currentColor' : 'transparent'} size='1.5rem' />
|
||||
</button>
|
||||
<Scoring {media} previewAnime={true}/>
|
||||
</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'>
|
||||
{#if media.format}
|
||||
{formatMap[media.format]}
|
||||
|
|
@ -122,15 +123,33 @@
|
|||
{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}
|
||||
</div>
|
||||
<div class='details text-white text-capitalize pb-10 d-flex'>
|
||||
{#if media.season || media.seasonYear}
|
||||
<span class='text-nowrap d-flex align-items-center'>
|
||||
{[media.season?.toLowerCase(), media.seasonYear].filter(s => s).join(' ')}
|
||||
</span>
|
||||
{/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 class='w-full h-full text-muted description overflow-hidden'>
|
||||
{media.description?.replace(/<[^>]*>/g, '')}
|
||||
</div>
|
||||
{#if media.description}
|
||||
<div class='w-full h-full text-muted description overflow-hidden'>
|
||||
{media.description?.replace(/<[^>]*>/g, '')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -140,9 +159,9 @@
|
|||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.details span + span::before {
|
||||
.details > span:not(:last-child)::after {
|
||||
content: '•';
|
||||
padding: 0 .5rem;
|
||||
padding: .5rem;
|
||||
font-size: .6rem;
|
||||
align-self: center;
|
||||
white-space: normal;
|
||||
|
|
@ -151,6 +170,9 @@
|
|||
.banner {
|
||||
height: 45%
|
||||
}
|
||||
.sound {
|
||||
filter: drop-shadow(0 0 .4rem rgba(0, 0, 0, 1))
|
||||
}
|
||||
/* video {
|
||||
object-fit: cover;
|
||||
} */
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@
|
|||
import { formatMap, statusColorMap } from '@/modules/anime.js'
|
||||
import { hoverClick } from '@/modules/click.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 { 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} */
|
||||
export let media
|
||||
export let type = null
|
||||
export let variables = null
|
||||
let preview = false
|
||||
|
||||
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]}>
|
||||
{#if preview}
|
||||
<PreviewCard {media} />
|
||||
<PreviewCard {media} {type} />
|
||||
{/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'}
|
||||
<div class='w-full text-center pb-10'>
|
||||
{#if media.airingSchedule?.nodes?.[0]?.airingAt}
|
||||
|
|
@ -38,13 +43,25 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full rounded' style:--color={media.coverImage.color || '#1890ff'} />
|
||||
|
||||
<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'} />
|
||||
<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'>
|
||||
{#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} />
|
||||
{/if}
|
||||
{media.title.userPreferred}
|
||||
{anilistClient.title(media)}
|
||||
</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 align-items-center pr-5' style='margin-left: -1px'>
|
||||
|
|
@ -64,7 +81,9 @@
|
|||
z-index: 30;
|
||||
/* fixes transform scaling on click causing z-index issues */
|
||||
}
|
||||
|
||||
.opacity-half {
|
||||
opacity: 30%;
|
||||
}
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
--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-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%);
|
||||
--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%;
|
||||
|
|
@ -118,6 +118,10 @@ a[href]:active, button:not([disabled]):active, fieldset:not([disabled]):active,
|
|||
z-index: 100;
|
||||
}
|
||||
|
||||
.z-101 {
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.text-dark-light {
|
||||
color: var(--gray-color-light);
|
||||
}
|
||||
|
|
|
|||
78
common/modules/al.d.ts
vendored
78
common/modules/al.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
export type Media = {
|
||||
id: number
|
||||
idMal: number
|
||||
title: {
|
||||
romaji?: string
|
||||
english?: string
|
||||
|
|
@ -15,6 +16,10 @@ export type Media = {
|
|||
duration?: number
|
||||
averageScore?: number
|
||||
genres?: string[]
|
||||
tags?: {
|
||||
name: string
|
||||
rank: integer
|
||||
}[]
|
||||
isFavourite: boolean
|
||||
coverImage?: {
|
||||
extraLarge: string
|
||||
|
|
@ -26,15 +31,16 @@ export type Media = {
|
|||
isAdult?: boolean
|
||||
bannerImage?: string
|
||||
synonyms?: string[]
|
||||
stats: {
|
||||
scoreDistribution: {
|
||||
score: number
|
||||
amount: number
|
||||
}[]
|
||||
}
|
||||
nextAiringEpisode?: {
|
||||
episode: number
|
||||
airingAt: number
|
||||
}
|
||||
startDate?: {
|
||||
year: number
|
||||
month?: number
|
||||
day?: number
|
||||
}
|
||||
trailer?: {
|
||||
id: string
|
||||
site: string
|
||||
|
|
@ -50,12 +56,20 @@ export type Media = {
|
|||
status?: string
|
||||
customLists?: string[]
|
||||
score?: number
|
||||
startedAt?: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
completedAt?: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
}
|
||||
studios?: {
|
||||
edges: {
|
||||
node: {
|
||||
name: string
|
||||
}
|
||||
nodes: {
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
airingSchedule?: {
|
||||
|
|
@ -69,50 +83,27 @@ export type Media = {
|
|||
relationType: string
|
||||
node: {
|
||||
id: number
|
||||
title: {
|
||||
userPreferred: string
|
||||
}
|
||||
type: string
|
||||
status: string
|
||||
format?: string
|
||||
episodes?: number
|
||||
synonyms?: string[]
|
||||
season?: string
|
||||
seasonYear?: number
|
||||
startDate?: {
|
||||
year: number
|
||||
month?: number
|
||||
day?: number
|
||||
}
|
||||
endDate?: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
}[]
|
||||
}
|
||||
recommendations?: {
|
||||
edges?: {
|
||||
node: {
|
||||
rating: number
|
||||
mediaRecommendation: {
|
||||
id: number
|
||||
}
|
||||
}
|
||||
}[]
|
||||
}
|
||||
// recommendations?: {
|
||||
// edges?: {
|
||||
// node: {
|
||||
// media: {
|
||||
// id: number
|
||||
// title: {
|
||||
// userPreferred: string
|
||||
// }
|
||||
// coverImage?: {
|
||||
// medium: string
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }[]
|
||||
// }
|
||||
}
|
||||
|
||||
export type Following = {
|
||||
status: string
|
||||
score: number
|
||||
progress: number
|
||||
user: {
|
||||
name: string
|
||||
avatar: {
|
||||
|
|
@ -135,6 +126,8 @@ export type MediaListMedia = {
|
|||
relationType: string
|
||||
node: {
|
||||
id: number
|
||||
type: string
|
||||
format?: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
|
@ -144,7 +137,7 @@ export type MediaListCollection = {
|
|||
lists: {
|
||||
status: string
|
||||
entries: {
|
||||
media: MediaListMedia
|
||||
media: Media
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
|
@ -152,6 +145,7 @@ export type MediaListCollection = {
|
|||
export type Viewer = {
|
||||
avatar: {
|
||||
medium: string
|
||||
large: string
|
||||
}
|
||||
name: string
|
||||
id: number
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@ import lavenshtein from 'js-levenshtein'
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import Bottleneck from 'bottleneck'
|
||||
|
||||
import { alToken } from '@/modules/settings.js'
|
||||
import { alToken, settings } from '@/modules/settings.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { sleep } from './util.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import Debug from 'debug'
|
||||
|
||||
const debug = Debug('ui:anilist')
|
||||
|
||||
const codes = {
|
||||
export const codes = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
402: 'Payment Required',
|
||||
|
|
@ -22,6 +23,7 @@ const codes = {
|
|||
410: 'Gone',
|
||||
412: 'Precondition Failed',
|
||||
413: 'Request Entity Too Large',
|
||||
422: 'Unprocessable Entity',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
501: 'Not Implemented',
|
||||
|
|
@ -81,6 +83,10 @@ episodes,
|
|||
duration,
|
||||
averageScore,
|
||||
genres,
|
||||
tags {
|
||||
name,
|
||||
rank
|
||||
},
|
||||
isFavourite,
|
||||
coverImage {
|
||||
extraLarge,
|
||||
|
|
@ -92,15 +98,21 @@ countryOfOrigin,
|
|||
isAdult,
|
||||
bannerImage,
|
||||
synonyms,
|
||||
studios(sort: NAME, isMain: true) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
},
|
||||
stats {
|
||||
scoreDistribution {
|
||||
score,
|
||||
amount
|
||||
}
|
||||
},
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring,
|
||||
episode
|
||||
},
|
||||
startDate {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
},
|
||||
trailer {
|
||||
id,
|
||||
site
|
||||
|
|
@ -115,11 +127,16 @@ mediaListEntry {
|
|||
repeat,
|
||||
status,
|
||||
customLists(asArray: true),
|
||||
score(format: POINT_10)
|
||||
},
|
||||
studios(isMain: true) {
|
||||
nodes {
|
||||
name
|
||||
score(format: POINT_10),
|
||||
startedAt {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
},
|
||||
completedAt {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
}
|
||||
},
|
||||
airingSchedule(page: 1, perPage: 1, notYetAired: true) {
|
||||
|
|
@ -133,45 +150,13 @@ relations {
|
|||
relationType(version:2),
|
||||
node {
|
||||
id,
|
||||
title {userPreferred},
|
||||
coverImage {medium},
|
||||
type,
|
||||
status,
|
||||
format,
|
||||
episodes,
|
||||
synonyms,
|
||||
season,
|
||||
seasonYear,
|
||||
startDate {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
},
|
||||
endDate {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
}
|
||||
seasonYear
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// recommendations {
|
||||
// edges {
|
||||
// node {
|
||||
// mediaRecommendation {
|
||||
// id,
|
||||
// title {
|
||||
// userPreferred
|
||||
// },
|
||||
// coverImage {
|
||||
// medium
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
class AnilistClient {
|
||||
limiter = new Bottleneck({
|
||||
reservoir: 90,
|
||||
|
|
@ -194,7 +179,7 @@ class AnilistClient {
|
|||
lastNotificationDate = Date.now() / 1000
|
||||
|
||||
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) => {
|
||||
printError(error)
|
||||
|
||||
|
|
@ -210,9 +195,9 @@ class AnilistClient {
|
|||
})
|
||||
|
||||
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
|
||||
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
|
||||
setInterval(() => { this.findNewNotifications() }, 1000 * 60 * 5)
|
||||
}
|
||||
|
|
@ -265,14 +250,14 @@ class AnilistClient {
|
|||
query: query.replace(/\s/g, '').replaceAll(' ', ' '),
|
||||
variables: {
|
||||
page: 1,
|
||||
perPage: 30,
|
||||
status_in: '[CURRENT,PLANNING]',
|
||||
perPage: 50,
|
||||
status_in: '[CURRENT,PLANNING,COMPLETED,DROPPED,PAUSED,REPEATING]',
|
||||
...variables
|
||||
}
|
||||
})
|
||||
}
|
||||
// @ts-ignore
|
||||
if (alToken?.token) options.headers.Authorization = alToken.token
|
||||
if (variables?.token) options.headers.Authorization = variables.token
|
||||
else if (alToken?.token) options.headers.Authorization = alToken.token
|
||||
|
||||
return this.handleRequest(options)
|
||||
}
|
||||
|
|
@ -290,7 +275,7 @@ class AnilistClient {
|
|||
const notifications = res.data.Page.notifications
|
||||
const newNotifications = notifications.filter(({ createdAt }) => createdAt > this.lastNotificationDate)
|
||||
this.lastNotificationDate = Date.now() / 1000
|
||||
debug(`Found ${newNotifications.length} new notifications`)
|
||||
debug(`Found ${newNotifications?.length} new notifications`)
|
||||
for (const { media, episode, type } of newNotifications) {
|
||||
const options = {
|
||||
title: media.title.userPreferred,
|
||||
|
|
@ -306,7 +291,7 @@ class AnilistClient {
|
|||
* @param {{key: string, title: string, year?: string, isAdult: boolean}[]} 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 []
|
||||
// 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>} */
|
||||
|
|
@ -363,59 +348,15 @@ class AnilistClient {
|
|||
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data.Page.media.find(media => media.id === id)])
|
||||
}
|
||||
|
||||
async alEntry (filemedia) {
|
||||
// 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')) {
|
||||
variables.lists.push('Watched using Migu')
|
||||
}
|
||||
await this.entry(variables)
|
||||
this.userLists.value = this.getUserLists()
|
||||
async alEntry (lists, variables) {
|
||||
if (!lists.includes('Watched using Migu')) {
|
||||
variables.lists.push('Watched using Migu')
|
||||
}
|
||||
return await this.entry(variables)
|
||||
}
|
||||
|
||||
async searchName (variables = {}) {
|
||||
debug(`Searching name for ${variables.name}`)
|
||||
debug(`Searching name for ${variables?.name}`)
|
||||
const query = /* js */`
|
||||
query($page: Int, $perPage: Int, $sort: [MediaSort], $name: String, $status: [MediaStatus], $year: Int, $isAdult: Boolean) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
|
|
@ -432,14 +373,13 @@ class AnilistClient {
|
|||
|
||||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async searchIDSingle (variables) {
|
||||
debug(`Searching for ID: ${variables.id}`)
|
||||
debug(`Searching for ID: ${variables?.id}`)
|
||||
const query = /* js */`
|
||||
query($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
|
|
@ -450,20 +390,20 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache([res.data.Media])
|
||||
await this.updateCache([res.data.Media])
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async searchIDS (variables) {
|
||||
debug(`Searching for IDs: ${variables.id.length}`)
|
||||
debug(`Searching for IDs: ${variables?.id?.length || variables?.idMal?.length}`)
|
||||
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) {
|
||||
pageInfo {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
|
@ -472,7 +412,7 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -527,7 +467,8 @@ class AnilistClient {
|
|||
query {
|
||||
Viewer {
|
||||
avatar {
|
||||
medium
|
||||
medium,
|
||||
large
|
||||
},
|
||||
name,
|
||||
id,
|
||||
|
|
@ -545,46 +486,31 @@ class AnilistClient {
|
|||
/** @returns {Promise<import('./al.d.ts').Query<{ MediaListCollection: import('./al.d.ts').MediaListCollection }>>} */
|
||||
async getUserLists (variables = {}) {
|
||||
debug('Getting user lists')
|
||||
const userId = this.userID?.viewer?.data?.Viewer.id
|
||||
variables.id = userId
|
||||
variables.id = !variables.userID ? this.userID?.viewer?.data?.Viewer.id : variables.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 */`
|
||||
query($id: Int) {
|
||||
MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) {
|
||||
query($id: Int, $sort: [MediaListSort]) {
|
||||
MediaListCollection(userId: $id, type: ANIME, sort: $sort, forceSingleCompletedList: true) {
|
||||
lists {
|
||||
status,
|
||||
entries {
|
||||
media {
|
||||
id,
|
||||
status,
|
||||
mediaListEntry {
|
||||
progress
|
||||
},
|
||||
nextAiringEpisode {
|
||||
episode
|
||||
},
|
||||
relations {
|
||||
edges {
|
||||
relationType(version:2)
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
${queryObjects}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// this doesn't need to be cached, as SearchIDStatus is already cached, which is the only thing that uses this
|
||||
return await this.alRequest(query, variables)
|
||||
const res = 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 }}>>} */
|
||||
async searchIDStatus (variables = {}) {
|
||||
variables.id = this.userID?.viewer?.data?.Viewer.id
|
||||
debug(`Searching for ID status: ${variables.id}`)
|
||||
const userId = this.userID?.viewer?.data?.Viewer.id
|
||||
variables.id = userId
|
||||
const query = /* js */`
|
||||
query($id: Int, $mediaId: Int) {
|
||||
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}[]}>} */
|
||||
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
|
||||
}
|
||||
|
|
@ -645,12 +571,12 @@ class AnilistClient {
|
|||
debug(`Searching ${JSON.stringify(variables)}`)
|
||||
variables.sort ||= 'SEARCH_MATCH'
|
||||
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) {
|
||||
pageInfo {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
|
@ -659,7 +585,7 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -686,7 +612,6 @@ class AnilistClient {
|
|||
mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {
|
||||
status,
|
||||
score,
|
||||
progress,
|
||||
user {
|
||||
name,
|
||||
avatar {
|
||||
|
|
@ -700,22 +625,64 @@ class AnilistClient {
|
|||
return this.alRequest(query, variables)
|
||||
}
|
||||
|
||||
entry (variables) {
|
||||
debug(`Updating entry for ${variables.id}`)
|
||||
const query = /* js */`
|
||||
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int) {
|
||||
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists) {
|
||||
/** @returns {Promise<import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>>} */
|
||||
recommendations (variables) {
|
||||
debug(`Getting recommendations for ${variables.id}`)
|
||||
const query = /* js */`
|
||||
query($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id,
|
||||
status,
|
||||
progress,
|
||||
repeat
|
||||
idMal,
|
||||
studios(sort: NAME, isMain: true) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
},
|
||||
recommendations {
|
||||
edges {
|
||||
node {
|
||||
rating,
|
||||
mediaRecommendation {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
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}`)
|
||||
const query = /* js */`
|
||||
mutation($id: Int) {
|
||||
|
|
@ -723,8 +690,9 @@ class AnilistClient {
|
|||
deleted
|
||||
}
|
||||
}`
|
||||
|
||||
return this.alRequest(query, variables)
|
||||
const res = await this.alRequest(query, variables)
|
||||
if (!variables.token) this.userLists.value = this.getUserLists({sort: 'UPDATED_TIME_DESC'})
|
||||
return res
|
||||
}
|
||||
|
||||
favourite (variables) {
|
||||
|
|
@ -750,9 +718,36 @@ class AnilistClient {
|
|||
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 */
|
||||
updateCache (medias) {
|
||||
this.mediaCache = { ...this.mediaCache, ...Object.fromEntries(medias.map(media => [media.id, media])) }
|
||||
async updateCache (medias) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import clipboard from './clipboard.js'
|
|||
import { search, key } from '@/views/Search.svelte'
|
||||
|
||||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import Helper from "@/modules/helper.js"
|
||||
|
||||
const imageRx = /\.(jpeg|jpg|gif|png|webp)/i
|
||||
|
||||
|
|
@ -175,6 +176,8 @@ export async function anitomyscript (...args) {
|
|||
obj.anime_season = seasonMatch[1]
|
||||
obj.episode_number = seasonMatch[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})/)
|
||||
if (yearMatch && Number(yearMatch[1]) <= (new Date().getUTCFullYear() + 1)) {
|
||||
|
|
@ -225,12 +228,17 @@ export async function playMedia (media) {
|
|||
}
|
||||
|
||||
export function setStatus (status, other = {}, media) {
|
||||
const fuzzyDate = Helper.getFuzzyDate(media, status)
|
||||
const variables = {
|
||||
id: media.id,
|
||||
idMal: media.idMal,
|
||||
status,
|
||||
score: media.mediaListEntry?.score || 0,
|
||||
repeat: media.mediaListEntry?.repeat || 0,
|
||||
...fuzzyDate,
|
||||
...other
|
||||
}
|
||||
return anilistClient.entry(variables)
|
||||
return Helper.entry(media, variables)
|
||||
}
|
||||
|
||||
const episodeMetadataMap = {}
|
||||
|
|
|
|||
63
common/modules/animedubs.js
Normal file
63
common/modules/animedubs.js
Normal 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()
|
||||
|
|
@ -76,12 +76,12 @@ export default new class AnimeResolver {
|
|||
return titleObjects
|
||||
}).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)) {
|
||||
// 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)) {
|
||||
debug(`Found ${key} as ${media.id}: ${media.title.userPreferred}`)
|
||||
debug(`Found ${key} as ${media?.id}: ${media?.title?.userPreferred}`)
|
||||
this.animeNameCache[key] = media
|
||||
}
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ export default new class AnimeResolver {
|
|||
let media = this.animeNameCache[this.getCacheKeyForTitle(parseObj)]
|
||||
// resolve episode, if movie, dont.
|
||||
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 (Array.isArray(parseObj.episode_number)) {
|
||||
// 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
|
||||
// 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))
|
||||
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
|
||||
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
|
||||
const 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}`)
|
||||
let result = await this.resolveSeason({ media: root || media, episode: parseObj.episode_number[1], increment: !parseObj.anime_season ? null : true })
|
||||
|
||||
// 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
|
||||
const diff = parseObj.episode_number[1] - result.episode
|
||||
episode = `${parseObj.episode_number[0] - diff} ~ ${result.episode}`
|
||||
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 {
|
||||
// cant find ep count or range seems fine
|
||||
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) {
|
||||
// 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))
|
||||
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
|
||||
debug(`Root ${root && root.id}:${root && root.title.userPreferred}`)
|
||||
debug(`Root ${root?.id}:${root?.title?.userPreferred}`)
|
||||
|
||||
// value bigger than episode count
|
||||
const 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}`)
|
||||
let result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number), increment: !parseObj.anime_season ? null : true })
|
||||
|
||||
// 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
|
||||
episode = result.episode
|
||||
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 {
|
||||
// cant find ep count or episode seems fine
|
||||
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({
|
||||
episode: episode || parseObj.episode_number,
|
||||
parseObject: parseObj,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/**
|
||||
* Adds hover and click event listeners to the specified node.
|
||||
|
|
@ -250,4 +263,4 @@ queueMicrotask(() => {
|
|||
navigateDPad(DirectionKeyMap[e.key])
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -39,7 +39,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
|
|||
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 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)
|
||||
|
||||
debug(`Found ${results.length} results`)
|
||||
debug(`Found ${results?.length} results`)
|
||||
|
||||
for (const error of errors) {
|
||||
debug(`Source Fetch Failed: ${error}`)
|
||||
|
|
@ -82,7 +82,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
|
|||
|
||||
async function updatePeerCounts (entries) {
|
||||
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([
|
||||
new Promise(resolve => {
|
||||
|
|
@ -141,14 +141,14 @@ function getRelation (list, type) {
|
|||
* @param {{episodes: any, episodeCount: number, specialCount: number}} param1
|
||||
* */
|
||||
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 media has no specials or their episode counts don't match
|
||||
if (!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) {
|
||||
debug('No specials found, or episode count matches between AL and AniDB')
|
||||
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 })
|
||||
// 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)
|
||||
|
|
|
|||
363
common/modules/helper.js
Normal file
363
common/modules/helper.js
Normal 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
51
common/modules/mal.d.ts
vendored
Normal 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
|
||||
}
|
||||
298
common/modules/myanimelist.js
Normal file
298
common/modules/myanimelist.js
Normal 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()
|
||||
|
|
@ -15,7 +15,7 @@ export default class Parser {
|
|||
destroyed = false
|
||||
|
||||
constructor (client, file) {
|
||||
debug('Initializing parser for file: ' + file.name)
|
||||
debug('Initializing parser for file: ' + file?.name)
|
||||
this.client = client
|
||||
this.file = file
|
||||
this.metadata = new Metadata(file)
|
||||
|
|
@ -33,18 +33,18 @@ export default class Parser {
|
|||
|
||||
this.metadata.getChapters().then(chapters => {
|
||||
if (this.destroyed) return
|
||||
debug(`Found ${chapters.length} chapters`)
|
||||
debug(`Found ${chapters?.length} chapters`)
|
||||
this.client.dispatch('chapters', chapters)
|
||||
})
|
||||
|
||||
this.metadata.getAttachments().then(files => {
|
||||
if (this.destroyed) return
|
||||
debug(`Found ${files.length} attachments`)
|
||||
debug(`Found ${files?.length} attachments`)
|
||||
for (const file of files) {
|
||||
if (fontRx.test(file.filename) || file.mimetype?.toLowerCase().includes('font')) {
|
||||
const data = hex2bin(arr2hex(file.data))
|
||||
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
|
||||
}
|
||||
this.client.dispatch('file', data)
|
||||
|
|
@ -64,7 +64,7 @@ export default class Parser {
|
|||
cb(this.metadata.parseStream(iterator))
|
||||
})
|
||||
} else {
|
||||
debug('Unsupported file format: ' + this.file.name)
|
||||
debug('Unsupported file format: ' + this.file?.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class RSSMediaManager {
|
|||
|
||||
const res = await Promise.all(await results)
|
||||
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) {
|
||||
const options = {
|
||||
|
|
@ -133,7 +133,7 @@ class RSSMediaManager {
|
|||
try {
|
||||
res.episodeData = (await getEpisodeMetadataForMedia(res.media))?.[res.episode]
|
||||
} 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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ export default function (t, { speed = 120, smooth = 10 } = {}) {
|
|||
return deltaTime / 14
|
||||
}
|
||||
|
||||
t.addEventListener('scrolltop', () => {
|
||||
pos = 0
|
||||
t.scrollTop = scrollTop
|
||||
if (!moving) {
|
||||
lastTime = null
|
||||
update()
|
||||
}
|
||||
})
|
||||
|
||||
t.addEventListener('pointerup', () => { pos = scrollTop = t.scrollTop })
|
||||
|
||||
function update () {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
|
||||
import { malDubs } from "@/modules/animedubs.js"
|
||||
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 Helper from '@/modules/helper.js'
|
||||
|
||||
export const hasNextPage = writable(true)
|
||||
|
||||
|
|
@ -24,8 +26,13 @@ export default class SectionsManager {
|
|||
|
||||
static createFallbackLoad (variables, type) {
|
||||
return (page = 1, perPage = 50, search = variables) => {
|
||||
const options = { page, perPage, ...SectionsManager.sanitiseObject(search) }
|
||||
const res = anilistClient.search(options)
|
||||
const hideSubs = search.hideSubs ? { idMal: malDubs.dubLists.value.dubbed } : {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,18 +44,12 @@ export default class SectionsManager {
|
|||
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) {
|
||||
const { data } = await arr
|
||||
return data?.Page.media[i]
|
||||
}
|
||||
|
||||
static sanitiseObject = Helper.sanitiseObject
|
||||
}
|
||||
|
||||
// list of all possible home screen sections
|
||||
|
|
@ -82,114 +83,114 @@ function createSections () {
|
|||
}),
|
||||
// user specific sections
|
||||
{
|
||||
title: 'Continue Watching',
|
||||
title: 'Sequels You Missed', variables : { sort: 'POPULARITY_DESC', userList: true, disableHide: true },
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = anilistClient.userLists.value.then(async res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => {
|
||||
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 => {
|
||||
if (Helper.isMalAuth()) return {} // not going to bother handling this, see below.
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
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 {}
|
||||
const ids = mediaList.flatMap(({ media }) => {
|
||||
return media.relations.edges.filter(edge => edge.relationType === 'SEQUEL')
|
||||
}).map(({ node }) => node.id)
|
||||
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)
|
||||
},
|
||||
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 = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries.map(({ media }) => media.id)
|
||||
if (!ids) return {}
|
||||
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth() ? res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => {
|
||||
return (status === 'CURRENT' || status === 'REPEATING') ? filtered.concat(entries) : filtered
|
||||
}, []) : 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)
|
||||
},
|
||||
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 = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries.map(({ media }) => media.id)
|
||||
if (!ids) return {}
|
||||
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries
|
||||
: 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)
|
||||
},
|
||||
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 = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries.map(({ media }) => media.id)
|
||||
if (!ids) return {}
|
||||
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
|
||||
: 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)
|
||||
},
|
||||
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 = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'DROPPED')?.entries.map(({ media }) => media.id)
|
||||
if (!ids) return {}
|
||||
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries
|
||||
: 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)
|
||||
},
|
||||
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 = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries.map(({ media }) => media.id)
|
||||
if (!ids) return {}
|
||||
return anilistClient.searchIDS({ page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries
|
||||
: 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)
|
||||
},
|
||||
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
|
||||
{ title: 'Popular This Season', variables: { sort: 'POPULARITY_DESC', season: currentSeason, year: currentYear } },
|
||||
{ title: 'Trending Now', variables: { sort: 'TRENDING_DESC' } },
|
||||
{ title: 'All Time Popular', variables: { sort: 'POPULARITY_DESC' } },
|
||||
{ title: 'Romance', variables: { sort: 'TRENDING_DESC', genre: 'Romance' } },
|
||||
{ title: 'Action', variables: { sort: 'TRENDING_DESC', genre: 'Action' } },
|
||||
{ title: 'Adventure', variables: { sort: 'TRENDING_DESC', genre: 'Adventure' } },
|
||||
{ title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: 'Fantasy' } }
|
||||
{ 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', hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
{ title: 'All Time Popular', variables: { sort: 'POPULARITY_DESC', hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
{ title: 'Romance', variables: { sort: 'TRENDING_DESC', genre: ['Romance'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
{ title: 'Action', variables: { sort: 'TRENDING_DESC', genre: ['Action'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
{ title: 'Adventure', variables: { sort: 'TRENDING_DESC', genre: ['Adventure'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
{ title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: ['Fantasy'], hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import { get, writable } from 'simple-store-svelte'
|
||||
import { defaults } from './util.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import { anilistClient } from './anilist.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import Debug from 'debug'
|
||||
|
||||
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} */
|
||||
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 }
|
||||
|
||||
|
|
@ -19,7 +21,7 @@ try {
|
|||
} catch (e) {}
|
||||
try {
|
||||
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) {
|
||||
resetSettings()
|
||||
|
|
@ -35,18 +37,37 @@ settings.subscribe(value => {
|
|||
localStorage.setItem('settings', JSON.stringify(value))
|
||||
})
|
||||
|
||||
profiles.subscribe(value => {
|
||||
localStorage.setItem('profiles', JSON.stringify(value))
|
||||
})
|
||||
|
||||
export function resetSettings () {
|
||||
settings.value = { ...defaults, ...scopedDefaults }
|
||||
}
|
||||
|
||||
export function isAuthorized() {
|
||||
return alToken || malToken
|
||||
}
|
||||
|
||||
window.addEventListener('paste', ({ clipboardData }) => {
|
||||
if (clipboardData.items?.[0]) {
|
||||
if (clipboardData.items[0].type === 'text/plain' && clipboardData.items[0].kind === 'string') {
|
||||
clipboardData.items[0].getAsString(text => {
|
||||
let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0]
|
||||
if (token) {
|
||||
if (token.endsWith('/')) token = token.slice(0, -1)
|
||||
handleToken(token)
|
||||
if (text.includes("access_token=")) { // is an AniList token
|
||||
let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0]
|
||||
if (token) {
|
||||
if (token.endsWith('/')) token = token.slice(0, -1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -54,17 +75,163 @@ window.addEventListener('paste', ({ clipboardData }) => {
|
|||
})
|
||||
IPC.on('altoken', handleToken)
|
||||
async function handleToken (token) {
|
||||
alToken = { token, viewer: null }
|
||||
const viewer = await anilistClient.viewer({ token })
|
||||
const { anilistClient} = await import('./anilist.js')
|
||||
const viewer = await anilistClient.viewer({token})
|
||||
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)})
|
||||
debug(`Failed to sign in with AniList: ${JSON.stringify(viewer)}`)
|
||||
return
|
||||
}
|
||||
const lists = viewer?.data?.Viewer?.mediaListOptions?.animeList?.customLists || []
|
||||
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()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { SUPPORTS } from '@/modules/support.js'
|
||||
import levenshtein from 'js-levenshtein'
|
||||
|
||||
export function countdown (s) {
|
||||
const d = Math.floor(s / (3600 * 24))
|
||||
|
|
@ -93,6 +94,32 @@ export function generateRandomHexCode (len) {
|
|||
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) {
|
||||
let wait = false
|
||||
return (...args) => {
|
||||
|
|
@ -127,7 +154,6 @@ export const defaults = {
|
|||
playerAutocomplete: true,
|
||||
playerAutoSkip: false,
|
||||
playerDeband: false,
|
||||
playerSeek: 5,
|
||||
rssQuality: '1080',
|
||||
rssFeedsNew: SUPPORTS.extensions ? [['New Releases', 'ASW [Small Size]']] : [],
|
||||
rssAutoplay: false,
|
||||
|
|
@ -157,6 +183,9 @@ export const defaults = {
|
|||
showDetailsInRPC: true,
|
||||
smoothScroll: false,
|
||||
cards: 'small',
|
||||
cardAudio: false,
|
||||
titleLang: 'english',
|
||||
hideMyAnime: false,
|
||||
expandingSidebar: !SUPPORTS.isAndroid,
|
||||
torrentPathNew: undefined,
|
||||
font: undefined,
|
||||
|
|
@ -165,7 +194,8 @@ export const defaults = {
|
|||
extensions: SUPPORTS.extensions ? ['anisearch'] : [],
|
||||
sources: {},
|
||||
enableExternal: false,
|
||||
playerPath: ''
|
||||
playerPath: '',
|
||||
playerSeek: 5
|
||||
}
|
||||
|
||||
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export default class TorrentClient extends WebTorrent {
|
|||
}
|
||||
|
||||
async torrentReady (torrent) {
|
||||
debug('Got torrent metadata: ' + torrent.name)
|
||||
debug('Got torrent metadata: ' + torrent?.name)
|
||||
const files = torrent.files.map(file => {
|
||||
return {
|
||||
infoHash: torrent.infoHash,
|
||||
|
|
@ -196,7 +196,7 @@ export default class TorrentClient extends WebTorrent {
|
|||
const subfiles = files.filter(file => {
|
||||
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) {
|
||||
const data = await file.arrayBuffer()
|
||||
if (targetFile !== this.current) return
|
||||
|
|
|
|||
BIN
common/public/anilist_icon.png
Normal file
BIN
common/public/anilist_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
common/public/anilist_logo.png
Normal file
BIN
common/public/anilist_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
common/public/myanimelist_icon.png
Normal file
BIN
common/public/myanimelist_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
common/public/myanimelist_logo.png
Normal file
BIN
common/public/myanimelist_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
|
|
@ -2,6 +2,8 @@
|
|||
import SectionsManager from '@/modules/sections.js'
|
||||
import Search, { search } from './Search.svelte'
|
||||
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 }
|
||||
|
||||
|
|
@ -9,20 +11,26 @@
|
|||
const variables = { ..._variables }
|
||||
const results = { data: { Page: { media: [], pageInfo: { hasNextPage: false } } } }
|
||||
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) {
|
||||
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
|
||||
results.data.Page.media = results.data.Page.media.concat(res.data.Page.media)
|
||||
}
|
||||
|
||||
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
|
||||
const season = seasons.at(seasons.indexOf(vars.season) - 1)
|
||||
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)
|
||||
|
||||
// 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.sort((a, b) => a.airingSchedule?.nodes?.[0]?.airingAt - b.airingSchedule?.nodes?.[0]?.airingAt)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import SectionsManager, { sections } from '@/modules/sections.js'
|
||||
import { settings } from '@/modules/settings.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' })
|
||||
|
||||
|
|
@ -15,14 +16,13 @@
|
|||
|
||||
for (const sectionTitle of settings.value.homeSections) manager.add(mappedSections[sectionTitle])
|
||||
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
const userSections = ['Continue Watching', 'Sequels You Missed', 'Your List', 'Completed List', 'Paused List', 'Dropped List', 'Currently Watching List']
|
||||
|
||||
anilistClient.userLists.subscribe(value => {
|
||||
if (Helper.getUser()) {
|
||||
const userSections = ['Continue Watching', 'Sequels You Missed', 'Planning List', 'Completed List', 'Paused List', 'Dropped List', 'Watching List']
|
||||
Helper.getClient().userLists.subscribe(value => {
|
||||
if (!value) return
|
||||
for (const section of manager.sections) {
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
function deferredLoad (element) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
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)
|
||||
}
|
||||
}, { threshold: 0 })
|
||||
|
|
@ -27,7 +27,8 @@
|
|||
function _click () {
|
||||
$search = {
|
||||
...opts.variables,
|
||||
load: opts.load
|
||||
load: opts.load,
|
||||
title: opts.title,
|
||||
}
|
||||
$page = 'search'
|
||||
}
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
class:fader={!SUPPORTS.isAndroid}
|
||||
>
|
||||
{#each $preview || fakecards as card}
|
||||
<Card {card} />
|
||||
<Card {card} variables={{...opts.variables}} />
|
||||
{/each}
|
||||
{#if $preview?.length}
|
||||
<ErrorCard promise={$preview[0].data} />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { tick } from 'svelte'
|
||||
import { state } from '../WatchTogether/WatchTogether.svelte'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import { anilistClient } from "@/modules/anilist.js"
|
||||
import Debug from 'debug'
|
||||
|
||||
const debug = Debug('ui:mediahandler')
|
||||
|
|
@ -63,7 +64,7 @@
|
|||
|
||||
const np = {
|
||||
media,
|
||||
title: media?.title.userPreferred || parseObject.anime_title,
|
||||
title: anilistClient.title(media) || parseObject.anime_title,
|
||||
episode: ep,
|
||||
episodeTitle: streamingEpisode && episodeRx.exec(streamingEpisode.title)[2],
|
||||
thumbnail: streamingEpisode?.thumbnail || media?.coverImage.extraLarge
|
||||
|
|
@ -112,11 +113,11 @@
|
|||
}
|
||||
|
||||
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) {
|
||||
debug(`Got ${files.length} files`, fileListToDebug(files))
|
||||
debug(`Got ${files?.length} files`, fileListToDebug(files))
|
||||
if (!files?.length) return processed.set(files)
|
||||
let videoFiles = []
|
||||
const otherFiles = []
|
||||
|
|
@ -144,11 +145,11 @@
|
|||
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) {
|
||||
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)
|
||||
|
|
@ -160,7 +161,7 @@
|
|||
result = filtered
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +264,7 @@
|
|||
|
||||
export let miniplayer = false
|
||||
export let page = 'home'
|
||||
export let overlay = 'none'
|
||||
</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} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import Subtitles from '@/modules/subtitles.js'
|
||||
import { toTS, fastPrettyBytes, videoRx } from '@/modules/util.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
|
@ -12,16 +11,17 @@
|
|||
import Seekbar from 'perfect-seekbar'
|
||||
import { click } from '@/modules/click.js'
|
||||
import VideoDeband from 'video-deband'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
import { w2gEmitter, state } from '../WatchTogether/WatchTogether.svelte'
|
||||
import Keybinds, { loadWithDefaults, condition } from 'svelte-keybinds'
|
||||
import { SUPPORTS } from '@/modules/support.js'
|
||||
import 'rvfc-polyfill'
|
||||
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 { volumeScroll } from '@/modules/volumescroll.js';
|
||||
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()
|
||||
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
export let miniplayer = false
|
||||
$condition = () => !miniplayer && SUPPORTS.keybinds && !document.querySelector('.modal.show')
|
||||
export let page
|
||||
export let overlay
|
||||
export let files = []
|
||||
$: updateFiles(files)
|
||||
let src = null
|
||||
|
|
@ -109,15 +110,15 @@
|
|||
// document.fullscreenElement isn't reactive
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
isFullscreen = !!document.fullscreenElement
|
||||
if (!SUPPORTS.isAndroid) return
|
||||
if (document.fullscreenElement) {
|
||||
// window.Capacitor.Plugins.StatusBar.hide()
|
||||
window.AndroidFullScreen.immersiveMode()
|
||||
window.AndroidFullScreen?.immersiveMode()
|
||||
screen.orientation.lock('landscape')
|
||||
} else {
|
||||
// window.Capacitor.Plugins.StatusBar.show()
|
||||
window.AndroidFullScreen.showSystemUI()
|
||||
window.AndroidFullScreen?.showSystemUI()
|
||||
window.Capacitor.Plugins.StatusBar.setOverlaysWebView({ overlay: true })
|
||||
|
||||
screen.orientation.unlock()
|
||||
}
|
||||
})
|
||||
|
|
@ -984,7 +985,7 @@
|
|||
if (media?.media?.episodes || media?.media?.nextAiringEpisode?.episode) {
|
||||
if (media.media.episodes || media.media.nextAiringEpisode?.episode > media.episode) {
|
||||
completed = true
|
||||
anilistClient.alEntry(media)
|
||||
Helper.updateEntry(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1166,10 +1167,10 @@
|
|||
</div>
|
||||
<div class='middle d-flex align-items-center justify-content-center flex-grow-1 position-relative'>
|
||||
<!-- 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 -->
|
||||
<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 mobile-focus-target d-none' use:click={() => { page = 'player'; toggleFullscreen() }} />
|
||||
<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'; window.dispatchEvent(new Event('overlay-check')) }} />
|
||||
<!-- 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}>
|
||||
<Rewind size='3rem' />
|
||||
|
|
@ -1245,14 +1246,19 @@
|
|||
{#if playbackRate !== 1}
|
||||
<div class='ts mr-auto'>x{playbackRate.toFixed(1)}</div>
|
||||
{/if}
|
||||
{#if video}
|
||||
<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>
|
||||
{/if}
|
||||
{#if SUPPORTS.isAndroid}
|
||||
<span class='icon ctrl mr-5 d-flex align-items-center h-full' use:click={() => (isLocked = true)}>
|
||||
<Lock size='2.5rem' strokeWidth={2.5} />
|
||||
</span>
|
||||
{/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 SUPPORTS.isAndroid}
|
||||
<span class='icon ctrl mr-5 d-flex align-items-center h-full' use:click={() => (isLocked = true)}>
|
||||
<Lock size='2.5rem' strokeWidth={2.5} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if 'audioTracks' in HTMLVideoElement.prototype && video?.audioTracks?.length > 1}
|
||||
<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'>
|
||||
|
|
@ -1628,6 +1634,12 @@
|
|||
.seekbar {
|
||||
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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@
|
|||
$items = [...$items, ...nextData]
|
||||
return nextData[nextData.length - 1].data
|
||||
}
|
||||
const update = debounce(() => {
|
||||
$key = {}
|
||||
const update = debounce((event) => {
|
||||
if (event.target.id !== 'genre' && event.target.id !== 'tag') {
|
||||
$key = {}
|
||||
}
|
||||
}, 300)
|
||||
|
||||
$: 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}>
|
||||
<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}
|
||||
{#each $items as card}
|
||||
<Card {card} />
|
||||
<Card {card} variables={{...$search}} />
|
||||
{/each}
|
||||
{#if $items?.length}
|
||||
<ErrorCard promise={$items[0].data} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { resetSettings } from '@/modules/settings.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import { SUPPORTS } from '@/modules/support.js'
|
||||
import SettingCard from './SettingCard.svelte';
|
||||
import SettingCard from './SettingCard.svelte'
|
||||
|
||||
async function importSettings () {
|
||||
try {
|
||||
|
|
@ -91,7 +91,7 @@
|
|||
</script>
|
||||
|
||||
<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}>
|
||||
<option value='' selected>None</option>
|
||||
<option value='*'>All</option>
|
||||
|
|
@ -140,10 +140,7 @@
|
|||
Export Settings To Clipboard
|
||||
</button>
|
||||
{#if SUPPORTS.update}
|
||||
<button
|
||||
use:click={checkUpdate}
|
||||
class='btn btn-primary mt-10'
|
||||
type='button'>
|
||||
<button use:click={checkUpdate} class='btn btn-primary mt-10' type='button'>
|
||||
Check For Updates
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -117,4 +117,4 @@
|
|||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
import SettingCard from './SettingCard.svelte'
|
||||
import { SUPPORTS } from '@/modules/support.js'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
|
||||
import Helper from "@/modules/helper.js"
|
||||
function updateAngle () {
|
||||
IPC.emit('angle', settings.value.angle)
|
||||
}
|
||||
|
|
@ -20,12 +22,14 @@
|
|||
<label for='rpc-enable'>{settings.enableRPC ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard title='Show Details in Discord Rich Presence' description='Shows currently played anime and episode in Discord rich presence.'>
|
||||
<div class='custom-switch'>
|
||||
<input type='checkbox' id='rpc-details' bind:checked={settings.showDetailsInRPC} />
|
||||
<label for='rpc-details'>{settings.showDetailsInRPC ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
{#if settings.enableRPC}
|
||||
<SettingCard title='Show Details in Discord Rich Presence' description='Shows currently played anime and episode in Discord rich presence.'>
|
||||
<div class='custom-switch'>
|
||||
<input type='checkbox' id='rpc-details' bind:checked={settings.showDetailsInRPC} />
|
||||
<label for='rpc-details'>{settings.showDetailsInRPC ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h4 class='mb-10 font-weight-bold'>Interface Settings</h4>
|
||||
|
|
@ -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.'>
|
||||
<textarea class='form-control w-500 mw-full bg-dark' placeholder='--accent-color: #20a2ff;' bind:value={$variables} />
|
||||
</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.'>
|
||||
<select class='form-control bg-dark w-300 mw-full' bind:value={settings.cards}>
|
||||
<option value='small' selected>Small</option>
|
||||
<option value='full'>Full</option>
|
||||
</select>
|
||||
</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}
|
||||
<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.">
|
||||
|
|
@ -74,6 +93,14 @@
|
|||
{/if}
|
||||
|
||||
<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.'}>
|
||||
<div>
|
||||
{#each settings.rssFeedsNew as _, i}
|
||||
|
|
|
|||
|
|
@ -94,35 +94,39 @@
|
|||
<option value='slo'>Slovak</option>
|
||||
<option value='swe'>Swedish</option>
|
||||
<option value='ara'>Arabic</option>
|
||||
</select>
|
||||
</SettingCard>
|
||||
<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}>
|
||||
<option value='eng'>English</option>
|
||||
<option value='jpn' selected>Japanese</option>
|
||||
<option value='chi'>Chinese</option>
|
||||
<option value='idn'>Indonesian</option>
|
||||
<option value='por'>Portuguese</option>
|
||||
<option value='spa'>Spanish</option>
|
||||
<option value='ger'>German</option>
|
||||
<option value='pol'>Polish</option>
|
||||
<option value='cze'>Czech</option>
|
||||
<option value='dan'>Danish</option>
|
||||
<option value='gre'>Greek</option>
|
||||
<option value='fin'>Finnish</option>
|
||||
<option value='fre'>French</option>
|
||||
<option value='hun'>Hungarian</option>
|
||||
<option value='ita'>Italian</option>
|
||||
<option value='kor'>Korean</option>
|
||||
<option value='dut'>Dutch</option>
|
||||
<option value='nor'>Norwegian</option>
|
||||
<option value='rum'>Romanian</option>
|
||||
<option value='rus'>Russian</option>
|
||||
<option value='slo'>Slovak</option>
|
||||
<option value='swe'>Swedish</option>
|
||||
<option value='ara'>Arabic</option>
|
||||
</select>
|
||||
</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.">
|
||||
<select class='form-control bg-dark w-300 mw-full' bind:value={settings.audioLanguage}>
|
||||
<option value='eng'>English</option>
|
||||
<option value='jpn' selected>Japanese</option>
|
||||
<option value='chi'>Chinese</option>
|
||||
<option value='idn'>Indonesian</option>
|
||||
<option value='por'>Portuguese</option>
|
||||
<option value='spa'>Spanish</option>
|
||||
<option value='ger'>German</option>
|
||||
<option value='pol'>Polish</option>
|
||||
<option value='cze'>Czech</option>
|
||||
<option value='dan'>Danish</option>
|
||||
<option value='gre'>Greek</option>
|
||||
<option value='fin'>Finnish</option>
|
||||
<option value='fre'>French</option>
|
||||
<option value='hun'>Hungarian</option>
|
||||
<option value='ita'>Italian</option>
|
||||
<option value='kor'>Korean</option>
|
||||
<option value='dut'>Dutch</option>
|
||||
<option value='nor'>Norwegian</option>
|
||||
<option value='rum'>Romanian</option>
|
||||
<option value='rus'>Russian</option>
|
||||
<option value='slo'>Slovak</option>
|
||||
<option value='swe'>Swedish</option>
|
||||
<option value='ara'>Arabic</option>
|
||||
<option value='idn'>Indonesian</option>
|
||||
</select>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
|
||||
<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.'>
|
||||
|
|
@ -137,7 +141,7 @@
|
|||
<label for='player-pause'>{settings.playerPause ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</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'>
|
||||
<input type='checkbox' id='player-autocomplete' bind:checked={settings.playerAutocomplete} />
|
||||
<label for='player-autocomplete'>{settings.playerAutocomplete ? 'On' : 'Off'}</label>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script context='module'>
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { click } from '@/modules/click.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
|
|
@ -36,9 +35,9 @@
|
|||
import TorrentSettings from './TorrentSettings.svelte'
|
||||
import InterfaceSettings from './InterfaceSettings.svelte'
|
||||
import AppSettings from './AppSettings.svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { logout } from '@/components/Logout.svelte'
|
||||
import { profileView } from '@/components/Profiles.svelte'
|
||||
import smoothScroll from '@/modules/scroll.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
import { AppWindow, Heart, LogIn, Logs, Play, Rss, Settings } from 'lucide-svelte'
|
||||
|
||||
const groups = {
|
||||
|
|
@ -72,17 +71,7 @@
|
|||
}
|
||||
|
||||
function loginButton () {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
$profileView = true
|
||||
}
|
||||
onDestroy(() => {
|
||||
IPC.off('path', pathListener)
|
||||
|
|
@ -114,14 +103,14 @@
|
|||
</div> -->
|
||||
<div class='pointer my-5 rounded' use:click={loginButton}>
|
||||
<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'>
|
||||
<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>
|
||||
<div class='font-size-16 login-image-text'>Logout</div>
|
||||
<div class='font-size-16 login-image-text'>Profiles</div>
|
||||
{:else}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -141,9 +141,8 @@
|
|||
{/if}
|
||||
|
||||
<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!' : ''}'>
|
||||
<div
|
||||
class='input-group w-300 mw-full'>
|
||||
<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 class='input-group w-300 mw-full'>
|
||||
<div class='input-group-prepend'>
|
||||
<button type='button' use:click={handleFolder} class='btn btn-primary input-group-append'>Select Folder</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script context='module'>
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
import getResultsFromExtensions from '@/modules/extensions/index.js'
|
||||
import Debug from 'debug'
|
||||
|
|
@ -114,8 +115,8 @@ async function sortResults(results) {
|
|||
$: autoPlay(best, $settings.rssAutoplay)
|
||||
|
||||
$: lookup.catch(err => {
|
||||
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 })
|
||||
debug(`Error fetching torrents for ${search.media?.title?.userPreferred} Episode ${search.episode}, ${err.stack}`)
|
||||
toast.error(`No torrent found for ${anilistClient.title(search.media)} Episode ${search.episode}!`, { description: err.message })
|
||||
})
|
||||
|
||||
$: firstLoad = !firstLoad && lookup.catch(close)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
export function playAnime (media, episode = 1, force) {
|
||||
episode = Number(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 })
|
||||
}
|
||||
</script>
|
||||
|
|
@ -15,7 +18,10 @@
|
|||
<script>
|
||||
import TorrentMenu from './TorrentMenu.svelte'
|
||||
|
||||
export let overlay
|
||||
|
||||
function close () {
|
||||
overlay = 'none'
|
||||
$rss = null
|
||||
}
|
||||
function checkClose ({ keyCode }) {
|
||||
|
|
@ -26,10 +32,21 @@
|
|||
|
||||
$: search = $rss
|
||||
|
||||
$: search && modal?.focus()
|
||||
$: {
|
||||
if (search) {
|
||||
overlay = 'torrent'
|
||||
modal?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('overlay-check', () => {
|
||||
if (search) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class='modal z-100' class:show={search} id='viewAnime'>
|
||||
<div class='modal z-50' class:show={search} id='viewAnime'>
|
||||
{#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-content m-0 mw-full h-full rounded overflow-hidden bg-very-dark d-flex flex-column overflow-y-scroll pt-0 px-0'>
|
||||
|
|
|
|||
119
common/views/ViewAnime/AudioLabel.svelte
Normal file
119
common/views/ViewAnime/AudioLabel.svelte
Normal 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>
|
||||
|
|
@ -1,22 +1,34 @@
|
|||
<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 alt = null
|
||||
|
||||
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: 'nodes', label: 'Studio', icon: Building2 },
|
||||
{ property: 'studios', label: 'Studio', icon: Building2, custom: 'property' },
|
||||
{ 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: 'romaji', label: 'Romaji', icon: Languages },
|
||||
{ property: 'native', label: 'Native', icon: '語', custom: 'icon' }
|
||||
]
|
||||
function getCustomProperty (detail, media) {
|
||||
async function getCustomProperty (detail, media) {
|
||||
if (detail.property === 'averageScore') {
|
||||
return media.averageScore + '%'
|
||||
} else if (detail.property === 'season') {
|
||||
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 {
|
||||
return media[detail.property]
|
||||
}
|
||||
|
|
@ -26,6 +38,8 @@
|
|||
return media.nextAiringEpisode?.episode
|
||||
} else if (property === 'english' || property === 'romaji' || property === 'native') {
|
||||
return media.title[property]
|
||||
} else if (property === 'isAdult') {
|
||||
return (media.isAdult === true ? 'Rated 18+' : false)
|
||||
}
|
||||
return media[property]
|
||||
}
|
||||
|
|
@ -46,9 +60,11 @@
|
|||
<div class='d-flex flex-column justify-content-center text-nowrap'>
|
||||
<div class='font-weight-bold select-all line-height-normal'>
|
||||
{#if detail.custom === 'property'}
|
||||
{getCustomProperty(detail, media)}
|
||||
{:else if property.constructor === Array}
|
||||
{property === 'nodes' ? property[0] && property[0].name : property.join(', ').replace(/_/g, ' ').toLowerCase()}
|
||||
{#await getCustomProperty(detail, media)}
|
||||
Fetching...
|
||||
{:then res }
|
||||
{res}
|
||||
{/await}
|
||||
{:else}
|
||||
{property.toString().replace(/_/g, ' ').toLowerCase()}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@
|
|||
</script>
|
||||
|
||||
<script>
|
||||
import { since } from '@/modules/util'
|
||||
import { since } from '@/modules/util.js'
|
||||
import { click } from '@/modules/click.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'
|
||||
|
||||
export let media
|
||||
|
|
@ -19,27 +19,30 @@
|
|||
|
||||
export let watched = false
|
||||
|
||||
const id = media.id
|
||||
|
||||
const duration = media.duration
|
||||
|
||||
export let episodeCount
|
||||
|
||||
export let userProgress = 0
|
||||
|
||||
export let play
|
||||
|
||||
const 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)
|
||||
}))
|
||||
$: 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)
|
||||
}))
|
||||
|
||||
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 }[]} */
|
||||
let alEpisodes = episodeList
|
||||
|
||||
// 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
|
||||
if (settled?.length) alEpisodes = settled
|
||||
}
|
||||
|
|
@ -47,13 +50,13 @@
|
|||
const alDate = new Date((airingAt || 0) * 1000)
|
||||
|
||||
// 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 { image, summary, rating, title, length, airdate } = needsValidation ? episodeByAirDate(alDate, episodes, episode) : (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 && episodes[Number(episode)]) || {})
|
||||
|
||||
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)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</script>
|
||||
|
||||
{#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}
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
|
|
|
|||
305
common/views/ViewAnime/Scoring.svelte
Normal file
305
common/views/ViewAnime/Scoring.svelte
Normal 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}>×</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>
|
||||
|
|
@ -6,26 +6,34 @@
|
|||
showMore = !showMore
|
||||
}
|
||||
export let title = 'Relations'
|
||||
export let promise = null
|
||||
</script>
|
||||
{#if list?.length}
|
||||
<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'>
|
||||
<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' />
|
||||
{#if list.length > 4}
|
||||
<div class='ml-auto pl-20 font-size-12 more text-muted text-nowrap'>{showMore ? 'Show Less' : 'Show More'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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}
|
||||
<slot {item} />
|
||||
<slot {item} {promise} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.title {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.gallery :global(.first-check:first-child) :global(.absolute-container) {
|
||||
left: -48% !important;
|
||||
}
|
||||
.more:hover {
|
||||
color: var(--dm-link-text-color-hover) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
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 { toast } from 'svelte-sonner'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
|
|
@ -8,24 +8,53 @@
|
|||
import Details from './Details.svelte'
|
||||
import EpisodeList from './EpisodeList.svelte'
|
||||
import ToggleList from './ToggleList.svelte'
|
||||
import Scoring from './Scoring.svelte'
|
||||
import AudioLabel from './AudioLabel.svelte'
|
||||
import Following from './Following.svelte'
|
||||
import smoothScroll from '@/modules/scroll.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import { alToken } from '@/modules/settings.js'
|
||||
import { Bookmark, Clapperboard, ExternalLink, Heart, LibraryBig, Play, Share2, Timer, TrendingUp, Tv } from 'lucide-svelte'
|
||||
import SmallCard from "@/components/cards/SmallCard.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')
|
||||
function close () {
|
||||
function close (play) {
|
||||
$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
|
||||
$: 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 }) {
|
||||
if (keyCode === 27) close()
|
||||
}
|
||||
function play (episode) {
|
||||
close()
|
||||
close(true)
|
||||
if (episode) return playAnime(media, episode)
|
||||
if (media.status === 'NOT_YET_RELEASED') return
|
||||
playMedia(media)
|
||||
|
|
@ -44,16 +73,6 @@
|
|||
return 'Watch Now'
|
||||
}
|
||||
$: 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 () {
|
||||
anilistClient.favourite({ id: media.id })
|
||||
media.isFavourite = !media.isFavourite
|
||||
|
|
@ -69,6 +88,11 @@
|
|||
IPC.emit('open', url)
|
||||
}
|
||||
let episodeOrder = true
|
||||
window.addEventListener('overlay-check', () => {
|
||||
if (media) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
|
||||
// async function score (media, score) {
|
||||
// const variables = {
|
||||
|
|
@ -80,10 +104,15 @@
|
|||
// }
|
||||
</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}
|
||||
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto position-relative' use:smoothScroll>
|
||||
<button class='close pointer z-30 bg-dark top-20 right-0 position-fixed' type='button' use:click={close}> × </button>
|
||||
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto position-relative' bind:this={container} use:smoothScroll>
|
||||
{#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()}> × </button>
|
||||
<img class='w-full cover-img banner position-absolute' alt='banner' src={media.bannerImage || ' '} />
|
||||
<div class='row px-20'>
|
||||
<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} />
|
||||
</div>
|
||||
<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'>
|
||||
{#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' />
|
||||
<span class='mr-20'>
|
||||
Rating: {media.averageScore + '%'}
|
||||
|
|
@ -125,6 +154,17 @@
|
|||
</span>
|
||||
</div>
|
||||
{/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 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'
|
||||
|
|
@ -134,19 +174,17 @@
|
|||
{playButtonText}
|
||||
</button>
|
||||
<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' />
|
||||
</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}>
|
||||
<Bookmark fill={media.mediaListEntry ? 'currentColor' : 'transparent'} size='1.7rem' />
|
||||
</button>
|
||||
<Scoring {media} viewAnime={true} />
|
||||
<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' />
|
||||
</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' />
|
||||
</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' />
|
||||
</button>
|
||||
<!-- <div class='input-group shadow-lg mb-5 font-size-16'>
|
||||
|
|
@ -171,7 +209,14 @@
|
|||
</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'>
|
||||
{#each media.genres as genre}
|
||||
<div class='bg-dark px-20 py-10 mr-10 rounded font-size-16'>
|
||||
|
|
@ -179,30 +224,49 @@
|
|||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Synopsis</div>
|
||||
<hr class='w-full' />
|
||||
</div>
|
||||
<div class='font-size-16 pre-wrap pt-20 select-all'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
<ToggleList list={media.relations?.edges?.filter(({ node }) => node.type === 'ANIME')} let:item title='Relations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await anilistClient.searchIDSingle({ id: item.node.id })).data.Media }}>
|
||||
<img loading='lazy' src={item.node.coverImage.medium || ''} alt='cover' class='cover-img w-full h-200 rel-img rounded' />
|
||||
<div class='pt-5'>{item.relationType.replace(/_/g, ' ').toLowerCase()}</div>
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.title.userPreferred}</h5>
|
||||
{#if media.description}
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Synopsis</div>
|
||||
<hr class='w-full' />
|
||||
</div>
|
||||
<div class='font-size-16 pre-wrap pt-20 select-all'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
{/if}
|
||||
<ToggleList list={
|
||||
media.relations?.edges?.filter(({ node }) => node.type === 'ANIME').sort((a, b) => {
|
||||
const typeComparison = a.relationType.localeCompare(b.relationType)
|
||||
if (typeComparison !== 0) {
|
||||
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>
|
||||
</ToggleList>
|
||||
{#await mediaRecommendation then res} <!-- reduces query complexity improving load times -->
|
||||
{@const mediaRecommendation = res?.data?.Media}
|
||||
<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'>
|
||||
<div class='small-card'>
|
||||
{#await promise}
|
||||
<SkeletonCard />
|
||||
{:then res }
|
||||
{#if res}
|
||||
<SmallCard media={anilistClient.mediaCache[item.node.mediaRecommendation.id]} type={item.node.rating} />
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</ToggleList>
|
||||
{/await}
|
||||
<Following {media} />
|
||||
<!-- <ToggleList list={media.recommendations.edges.filter(edge => edge.node.mediaRecommendation)} let:item title='Recommendations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await anilistClient.searchIDSingle({ id: item.node.mediaRecommendation.id })).data.Media }}>
|
||||
<img loading='lazy' src={item.node.mediaRecommendation.coverImage.medium || ''} alt='cover' class='cover-img w-full h-200 rel-img rounded' />
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.mediaRecommendation.title.userPreferred}</h5>
|
||||
</div>
|
||||
</ToggleList> -->
|
||||
<div class='w-full d-flex d-lg-none flex-row align-items-center pt-20 mt-10 pointer'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Episodes</div>
|
||||
|
|
@ -212,7 +276,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -225,6 +289,11 @@
|
|||
left: unset !important;
|
||||
right: 3rem !important;
|
||||
}
|
||||
.back {
|
||||
top: 5rem !important;
|
||||
left: 9rem !important;
|
||||
right: unset !important;
|
||||
}
|
||||
.banner {
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
|
|
@ -247,6 +316,9 @@
|
|||
.cover {
|
||||
aspect-ratio: 7/10;
|
||||
}
|
||||
.small-card {
|
||||
width: 23rem !important;
|
||||
}
|
||||
|
||||
button.bg-dark:not([disabled]):hover {
|
||||
background: #292d33 !important;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
</script>
|
||||
|
||||
<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='pb-5 d-flex flex-row align-items-center px-5'>
|
||||
<div class='font-weight-bold font-size-18 line-height-normal'>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
</script>
|
||||
|
||||
<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'>
|
||||
{user?.name || 'Anonymous'}
|
||||
</div>
|
||||
{#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' />
|
||||
</span>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { EventEmitter } from 'events'
|
|||
import P2PT from 'p2pt'
|
||||
|
||||
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 { generateRandomHexCode } from '@/modules/util.js'
|
||||
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}[]>} */
|
||||
messages = writable([])
|
||||
|
||||
self = anilistClient.userID?.viewer.data.Viewer || { id: generateRandomHexCode(16) }
|
||||
self = Helper.getUser() || { id: generateRandomHexCode(16) }
|
||||
/** @type {import('simple-store-svelte').Writable<PeerList>} */
|
||||
peers = writable({ [this.self.id]: { user: this.self } })
|
||||
|
||||
|
|
|
|||
|
|
@ -133,9 +133,9 @@ export default class App {
|
|||
ipcMain.on('portRequest', async ({ sender }) => {
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
await torrentLoad
|
||||
this.webtorrentWindow.webContents.postMessage('port', null, [port1])
|
||||
this.webtorrentWindow.webContents.postMessage('player', store.get('player'))
|
||||
this.webtorrentWindow.webContents.postMessage('torrentPath', store.get('torrentPath'))
|
||||
this.webtorrentWindow.webContents.postMessage('port', null, [port1])
|
||||
sender.postMessage('port', null, [port2])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default class Discord {
|
|||
details: 'Stream anime torrents, real-time.',
|
||||
state: 'Watching anime',
|
||||
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'
|
||||
},
|
||||
buttons: [
|
||||
|
|
@ -96,4 +96,4 @@ export default class Discord {
|
|||
this.clearDiscordRPC()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export default class Protocol {
|
|||
// schema: migu://key/value
|
||||
protocolMap = {
|
||||
auth: token => this.sendToken(token),
|
||||
malauth: token => this.sendMalToken(token),
|
||||
anime: id => this.window.webContents.send('open-anime', id),
|
||||
w2g: link => this.window.webContents.send('w2glink', link),
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -46,4 +46,4 @@ export default class Updater {
|
|||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"eslint": "^8.57.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"matroska-metadata": "^1.0.6",
|
||||
"mini-css-extract-plugin": "^2.8.1",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ importers:
|
|||
eslint-plugin-svelte:
|
||||
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))
|
||||
fuse.js:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
html-webpack-plugin:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(webpack@5.91.0(webpack-cli@5.1.4))
|
||||
|
|
@ -76,8 +79,8 @@ importers:
|
|||
specifier: ^6.0.2
|
||||
version: 6.0.2(@capacitor/core@6.1.1)
|
||||
'@capacitor/browser':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(@capacitor/core@6.1.1)
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(@capacitor/core@6.1.1)
|
||||
'@capacitor/cli':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1
|
||||
|
|
@ -349,8 +352,8 @@ packages:
|
|||
engines: {node: '>=10.3.0'}
|
||||
hasBin: true
|
||||
|
||||
'@capacitor/browser@6.0.1':
|
||||
resolution: {integrity: sha512-KBK0PKfmUj0if+gYWEh0+LG70l1gcLGbDCWJt2Ig3naXHGlrLoWBqVArCgbwBzwJZL+VlwW7iEhAzGOWpg2jhw==}
|
||||
'@capacitor/browser@6.0.2':
|
||||
resolution: {integrity: sha512-mJjdKbpdCAaaDVZD/vjzpJJxL1VvwsGTcEGn+4PpCQyPu3+yNQO7vgjwBV7ZYS6+mZIKeYn5swWq0BFuAcDqFg==}
|
||||
peerDependencies:
|
||||
'@capacitor/core': ^6.0.0
|
||||
|
||||
|
|
@ -2755,6 +2758,10 @@ packages:
|
|||
functions-have-names@1.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==}
|
||||
|
||||
|
|
@ -5687,7 +5694,7 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@capacitor/browser@6.0.1(@capacitor/core@6.1.1)':
|
||||
'@capacitor/browser@6.0.2(@capacitor/core@6.1.1)':
|
||||
dependencies:
|
||||
'@capacitor/core': 6.1.1
|
||||
|
||||
|
|
@ -6586,7 +6593,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 7.1.1
|
||||
'@typescript-eslint/typescript-estree': 7.1.1(typescript@5.4.5)
|
||||
'@typescript-eslint/visitor-keys': 7.1.1
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
eslint: 8.57.0
|
||||
optionalDependencies:
|
||||
typescript: 5.4.5
|
||||
|
|
@ -7716,10 +7723,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@ionic/utils-array': 2.1.6
|
||||
'@ionic/utils-fs': 3.1.7
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
elementtree: 0.1.7
|
||||
sharp: 0.29.3
|
||||
tslib: 2.6.2
|
||||
tslib: 2.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -8742,6 +8749,8 @@ snapshots:
|
|||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuse.js@7.0.0: {}
|
||||
|
||||
get-browser-rtc@1.1.0: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@
|
|||
}
|
||||
const playButtonText = getPlayButtonText(media)
|
||||
|
||||
function volume (video) {
|
||||
video.volume = 0.1
|
||||
}
|
||||
let muted = true
|
||||
function toggleMute () {
|
||||
muted = !muted
|
||||
|
|
@ -32,19 +29,19 @@
|
|||
function play () {
|
||||
open('miru://anime/' + media.id)
|
||||
}
|
||||
function lazyload (video) {
|
||||
function lazyload (iframe) {
|
||||
if ('IntersectionObserver' in window) {
|
||||
const lazyVideoObserver = new IntersectionObserver(entries => {
|
||||
for (const { target, isIntersecting } of entries) {
|
||||
if (isIntersecting) {
|
||||
video.src = video.dataset.src
|
||||
iframe.src = iframe.dataset.src
|
||||
lazyVideoObserver.unobserve(target)
|
||||
}
|
||||
}
|
||||
})
|
||||
lazyVideoObserver.observe(video.parentNode)
|
||||
lazyVideoObserver.observe(iframe.parentNode)
|
||||
} else {
|
||||
video.src = video.dataset.src
|
||||
iframe.src = iframe.dataset.src
|
||||
}
|
||||
}
|
||||
</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>
|
||||
<!-- 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 -->
|
||||
<video data-src={`https://inv.tux.pizza/latest_version?id=${media.trailer.id}&itag=18`}
|
||||
class='w-full h-full position-absolute left-0'
|
||||
<iframe
|
||||
class='w-full border-0 position-absolute left-0'
|
||||
class:d-none={hide}
|
||||
playsinline
|
||||
preload='none'
|
||||
loading='lazy'
|
||||
title={media.title.userPreferred}
|
||||
allow='autoplay'
|
||||
use:lazyload
|
||||
loop
|
||||
use:volume
|
||||
bind:muted
|
||||
on:loadeddata={() => { hide = false }}
|
||||
autoplay />
|
||||
on:load={() => { hide = false }}
|
||||
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`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class='w-full px-20'>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,28 @@
|
|||
@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 {
|
||||
background-color: #101113 !important;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue