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

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

View file

@ -19,6 +19,7 @@
},
"devDependencies": {
"@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",

View file

@ -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'

View file

@ -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;
}

View file

@ -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;">&times;</div>
{/if}
</Miniplayer>
</Miniplayer>
</div>
{#if page === 'settings'}
<Settings />

View file

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

View file

@ -3,6 +3,8 @@
import { media } from '../views/Player/MediaHandler.svelte'
import { 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>

View file

@ -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>

View file

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

View file

@ -1,9 +1,11 @@
<script context='module'>
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>

View file

@ -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;

View file

@ -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'>

View file

@ -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}

View file

@ -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}

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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;
} */

View file

@ -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;

View file

@ -28,7 +28,7 @@
--banner-gradient-left: linear-gradient(90deg, #17191D 0%, rgba(23, 25, 29, 0.5) 75%, rgba(25, 28, 32, 0) 100%);
--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);
}

View file

@ -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

View file

@ -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('&nbsp;', ' '),
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
}
}
}

View file

@ -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 = {}

View file

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

View file

@ -76,12 +76,12 @@ export default new class AnimeResolver {
return titleObjects
}).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,

View file

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

View file

@ -39,7 +39,7 @@ export default async function getResultsFromExtensions ({ media, episode, batch,
throw new Error('No torrent sources configured. Add extensions in settings.')
}
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
View file

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

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

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

View file

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

View file

@ -15,7 +15,7 @@ export default class Parser {
destroyed = false
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)
}
}

View file

@ -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

View file

@ -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 () {

View file

@ -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'] } }
]
}
}

View file

@ -1,14 +1,16 @@
import { writable } from 'simple-store-svelte'
import { get, writable } from 'simple-store-svelte'
import { defaults } from './util.js'
import 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))
}

View file

@ -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']

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View file

@ -2,6 +2,8 @@
import SectionsManager from '@/modules/sections.js'
import 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)

View file

@ -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)
}
})
}

View file

@ -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} />

View file

@ -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} />

View file

@ -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) {

View file

@ -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} />

View file

@ -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}

View file

@ -117,4 +117,4 @@
.pointer {
cursor: pointer;
}
</style>
</style>

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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)

View file

@ -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'>

View file

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

View file

@ -1,22 +1,34 @@
<script>
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}

View file

@ -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>

View file

@ -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' />

View file

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

View file

@ -6,26 +6,34 @@
showMore = !showMore
}
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;
}

View file

@ -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}> &times; </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()}> &times; </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;

View file

@ -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'>

View file

@ -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}

View file

@ -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 } })

View file

@ -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])
})

View file

@ -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()
}
}
}
}

View file

@ -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
*/

View file

@ -46,4 +46,4 @@ export default class Updater {
return true
}
}
}
}

View file

@ -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",

View file

@ -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: {}

View file

@ -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'>

View file

@ -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;
}