feat: donation status, RPC buttons

fix: linting, subtitle track errors
This commit is contained in:
ThaUnknown 2022-07-26 21:35:47 +02:00
parent c0254d749b
commit 7267316895
27 changed files with 2506 additions and 2486 deletions

View file

@ -1,10 +0,0 @@
{
"useTabs": false,
"jsxSingleQuote": true,
"printWidth": 180,
"semi": false,
"singleQuote": true,
"svelteBracketNewLine": false,
"arrowParens": "avoid",
"trailingComma": "none"
}

View file

@ -1,6 +1,6 @@
{
"name": "Miru",
"version": "2.10.0",
"version": "2.10.1",
"author": "ThaUnknown_ <ThaUnknown@users.noreply.github.com>",
"main": "src/index.js",
"homepage": "https://github.com/ThaUnknown/miru#readme",

View file

@ -1,29 +1,29 @@
<script context="module">
import { writable } from 'svelte/store'
export const title = writable('Miru')
<script context='module'>
import { writable } from 'svelte/store'
export const title = writable('Miru')
</script>
<div class="w-full navbar border-0 bg-dark position-relative p-0">
<div class="menu-shadow shadow-lg position-absolute w-full h-full z-0" />
<div class="w-full h-full bg-dark z-10 d-flex">
<div class="d-flex w-full draggable h-full align-items-center">
<img src="./logo.ico" alt="ico" />
<div class='w-full navbar border-0 bg-dark position-relative p-0'>
<div class='menu-shadow shadow-lg position-absolute w-full h-full z-0' />
<div class='w-full h-full bg-dark z-10 d-flex'>
<div class='d-flex w-full draggable h-full align-items-center'>
<img src='./logo.ico' alt='ico' />
{$title}
</div>
<div class="controls d-flex h-full pointer">
<div class="d-flex align-items-center" on:click={() => window.IPC.emit('minimize')}>
<svg viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z" />
<div class='controls d-flex h-full pointer'>
<div class='d-flex align-items-center' on:click={() => window.IPC.emit('minimize')}>
<svg viewBox='0 0 24 24'>
<path d='M19 13H5v-2h14v2z' />
</svg>
</div>
<div class="d-flex align-items-center" on:click={() => window.IPC.emit('maximize')}>
<svg viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
<div class='d-flex align-items-center' on:click={() => window.IPC.emit('maximize')}>
<svg viewBox='0 0 24 24'>
<path d='M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z' />
</svg>
</div>
<div class="d-flex align-items-center close" on:click={window.close}>
<svg viewBox="0 0 24 24">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
<div class='d-flex align-items-center close' on:click={window.close}>
<svg viewBox='0 0 24 24'>
<path d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z' />
</svg>
</div>
</div>

View file

@ -1,295 +1,294 @@
<script context="module">
import { DOMPARSER } from '@/modules/util.js'
import { updateMedia } from './pages/Player.svelte'
import { set } from './pages/Settings.svelte'
import { addToast } from '@/lib/Toasts.svelte'
import { alRequest } from '@/modules/anilist.js'
import { episodeRx, findEdge, resolveSeason, getMediaMaxEp } from '@/modules/anime.js'
<script context='module'>
import { DOMPARSER } from '@/modules/util.js'
import { updateMedia } from './pages/Player.svelte'
import { set } from './pages/Settings.svelte'
import { addToast } from '@/lib/Toasts.svelte'
import { alRequest } from '@/modules/anilist.js'
import { episodeRx, findEdge, resolveSeason, getMediaMaxEp } from '@/modules/anime.js'
import { writable } from 'svelte/store'
import { writable } from 'svelte/store'
const rss = writable({})
const rss = writable({})
const settings = set
const settings = set
const exclusions = ['DTS', '[ASW]']
const exclusions = ['DTS', '[ASW]']
const video = document.createElement('video')
const video = document.createElement('video')
if (!video.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')) {
exclusions.push('HEVC', 'x265', 'H.265')
}
if (!video.canPlayType('audio/mp4; codecs="ac-3"')) {
exclusions.push('AC3', 'AC-3')
}
video.remove()
if (!video.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')) {
exclusions.push('HEVC', 'x265', 'H.265')
}
if (!video.canPlayType('audio/mp4; codecs="ac-3"')) {
exclusions.push('AC3', 'AC-3')
}
video.remove()
export function playAnime (media, episode = 1) {
episode = isNaN(episode) ? 1 : episode
rss.set({ media, episode })
}
export function playAnime (media, episode = 1) {
episode = isNaN(episode) ? 1 : episode
rss.set({ media, episode })
}
// padleft a variable with 0 ex: 1 => '01'
function pl (v = 1) {
return (typeof v === 'string' ? v : v.toString()).padStart(2, '0')
}
// padleft a variable with 0 ex: 1 => '01'
function pl (v = 1) {
return (typeof v === 'string' ? v : v.toString()).padStart(2, '0')
}
export function getRSSContent (url) {
return url && fetch(url)
.then(res => {
if (res.ok) {
return res.text().then(xmlTxt => {
return DOMPARSER(xmlTxt, 'text/xml')
})
}
throw Error(res.statusText)
})
.catch(error => {
addToast({
text: 'Failed fetching RSS!<br>' + error,
title: 'Search Failed',
type: 'danger'
export function getRSSContent (url) {
return url && fetch(url)
.then(res => {
if (res.ok) {
return res.text().then(xmlTxt => {
return DOMPARSER(xmlTxt, 'text/xml')
})
console.error(error)
})
}
const rssmap = {
SubsPlease: 'https://nyaa.si/?page=rss&c=0_0&f=0&u=subsplease&q=',
'Erai-raws [Multi-Sub]': 'https://nyaa.si/?page=rss&c=0_0&f=0&u=Erai-raws&q=',
NanDesuKa: 'https://nyaa.si/?page=rss&c=0_0&f=0&u=NanDesuKa&q='
}
const epstring = ep => `"E${pl(ep)}+"|"E${pl(ep)}v"|"+${pl(ep)}+"|"+${pl(ep)}v"`
export function getReleasesRSSurl (val) {
const rss = rssmap[val] || val
return rss && new URL(rssmap[val] ? `${rss}${settings.rssQuality ? `"${settings.rssQuality}"` : ''}` : rss)
}
// matches: OP01 ED01 EP01 E01 01v -01- _01_ with spaces and stuff
const epNumRx = /[EO]?[EPD _-]\d{2}[v _-]|\d{2}[-~]\d{2}/i
async function getRSSEntries ({ media, episode, mode, ignoreQuality }) {
// mode cuts down on the amt of queries made 'check' || 'batch'
const titles = createTitle(media).join(')|(')
const prequel = findEdge(media, 'PREQUEL')?.node
const sequel = findEdge(media, 'SEQUEL')?.node
const isBatch = media.status === 'FINISHED' && settings.rssBatch && media.episodes !== 1
// if media has multiple seasons, and this S is > 1, then get the absolute episode number of the episode
const absolute = prequel && !mode && (await resolveSeason({ media, episode, force: true }))
const absoluteep = absolute?.offset + episode
const episodes = [episode]
// only use absolute episode number if its smaller than max episodes this series has, ex:
// looking for E1 of S2, S1 has 12 ep and S2 has 13, absolute will be 13
// so this would find the 13th ep of the 2nd season too if this check wasnt here
if (absolute && absoluteep < (getMediaMaxEp(media) || episode)) {
episodes.push(absoluteep)
}
let ep = ''
if (media.episodes !== 1 && mode !== 'batch') {
if (isBatch) {
ep = `"01-${pl(media.episodes)}"|"01~${pl(media.episodes)}"|"Batch"|"Complete"|"${pl(episode)}+"|"${pl(episode)}v"|"S01"`
} else {
ep = `(${episodes.map(epstring).join('|')})`
}
}
const excl = exclusions.join('|')
const quality = (!ignoreQuality && (`"${settings.rssQuality}"` || '"1080"')) || ''
const trusted = settings.rssTrusted === true ? 2 : 0
const url = new URL(
`https://nyaa.si/?page=rss&c=1_2&f=${trusted}&s=seeders&o=desc&q=(${titles})${ep}${quality}-(${excl})`
)
let nodes = [...(await getRSSContent(url)).querySelectorAll('item')]
if (absolute) {
// if this is S > 1 aka absolute ep number exists get entries for S1title + absoluteEP
// the reason this isnt done with recursion like sequelEntries is because that would include the S1 media dates
// we want the dates of the target media as the S1 title might be used for SX releases
const titles = createTitle(absolute.media).join(')|(')
const url = new URL(
`https://nyaa.si/?page=rss&c=1_2&f=${trusted}&s=seeders&o=desc&q=(${titles})${epstring(absoluteep)}${quality}-(${excl})`
)
nodes = [...nodes, ...(await getRSSContent(url)).querySelectorAll('item')]
}
let entries = parseRSSNodes(nodes)
const checkSequelDate = media.status === 'FINISHED' && (sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') && sequel.startDate
const sequelStartDate = checkSequelDate && new Date(Object.values(checkSequelDate).join(' '))
// recursive, get all entries for media sequel, and its sequel, and its sequel
const sequelEntries =
(sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') &&
(await getRSSEntries({ media: (await alRequest({ method: 'SearchIDSingle', id: sequel.id })).data.Media, episode, mode: mode || 'check' }))
const checkPrequelDate = (media.status === 'FINISHED' || media.status === 'RELEASING') && prequel?.status === 'FINISHED' && prequel?.endDate
const prequelEndDate = checkPrequelDate && new Date(Object.values(checkPrequelDate).join(' '))
// 1 month in MS, a bit of jitter for pre-releases and releasers being late as fuck, lets hope it doesnt cause issues
const month = 2674848460
if (prequelEndDate) {
entries = entries.filter(entry => entry.date > new Date(+prequelEndDate + month))
}
if (sequelStartDate && media.format === 'TV') {
entries = entries.filter(entry => entry.date < new Date(+sequelStartDate - month))
}
if (sequelEntries?.length) {
if (mode === 'check') {
entries = [...entries, ...sequelEntries]
} else {
entries = entries.filter(entry => !sequelEntries.find(sequel => sequel.link === entry.link))
}
}
// this gets entries without any episode limiting, and for batches
const batchEntries = !mode && isBatch && (await getRSSEntries({ media, episode, ignoreQuality, mode: 'batch' })).filter(entry => {
return !epNumRx.test(entry.title)
throw Error(res.statusText)
})
if (batchEntries?.length) {
entries = [...entries, ...batchEntries]
}
// some archaic shows only have shit DVD's in weird qualities, so try to look up without any quality restrictions when there are no results
if (!entries.length && !ignoreQuality && !mode) {
entries = await getRSSEntries({ media, episode, ignoreQuality: true })
}
// dedupe
const ids = entries.map(e => e.link)
return entries.filter(({ link }, index) => !ids.includes(link, index + 1))
}
function parseRSSNodes (nodes) {
return nodes.map(item => {
const pubDate = item.querySelector('pubDate')?.textContent
return {
title: item.querySelector('title')?.textContent || '?',
link: item.querySelector('link')?.textContent || '?',
seeders: item.querySelector('seeders')?.textContent ?? '?',
leechers: item.querySelector('leechers')?.textContent ?? '?',
downloads: item.querySelector('downloads')?.textContent ?? '?',
size: item.querySelector('size')?.textContent ?? '?',
date: pubDate && new Date(pubDate)
}
})
}
// create an array of potentially valid titles from a given media
function createTitle (media) {
// group and de-duplicate
const grouped = [
...new Set(
Object.values(media.title)
.concat(media.synonyms)
.filter(name => name != null && name.length > 3)
)
]
const titles = []
for (const t of grouped) {
// replace & with encoded
const title = t.replace(/&/g, '%26').replace(/\?/g, '%3F').replace(/#/g, '%23')
titles.push(title)
// replace Season 2 with S2, else replace 2nd Season with S2, but keep the original title
const match1 = title.match(/(\d)(?:nd|rd|th) Season/i)
const match2 = title.match(/Season (\d)/i)
if (match2) {
titles.push(title.replace(/Season \d/i, `S${match2[1]}`))
} else if (match1) {
titles.push(title.replace(/(\d)(?:nd|rd|th) Season/i, `S${match1[1]}`))
}
}
return titles
}
</script>
<script>
import { add } from '@/modules/torrent.js'
$: parseRss($rss)
let table = null
let fileMedia = null
export async function parseRss ({ media, episode }) {
if (!media) return
const entries = await getRSSEntries({ media, episode })
if (!entries?.length) {
.catch(error => {
addToast({
text: /* html */`Couldn't find torrent for ${media.title.userPreferred} Episode ${parseInt(episode)}! Try specifying a torrent manually.`,
text: 'Failed fetching RSS!<br>' + error,
title: 'Search Failed',
type: 'danger'
})
return
}
entries.sort((a, b) => b.seeders - a.seeders)
const streamingEpisode = media?.streamingEpisodes.filter(episode => episodeRx.exec(episode.title) && Number(episodeRx.exec(episode.title)[1]) === Number(episode))[0]
fileMedia = {
mediaTitle: media?.title.userPreferred,
episodeNumber: Number(episode),
episodeTitle: streamingEpisode ? episodeRx.exec(streamingEpisode.title)[2] : undefined,
episodeThumbnail: streamingEpisode?.thumbnail,
mediaCover: media?.coverImage.medium,
name: 'Miru',
media
}
if (settings.rssAutoplay) {
play(entries[0])
console.error(error)
})
}
const rssmap = {
SubsPlease: 'https://nyaa.si/?page=rss&c=0_0&f=0&u=subsplease&q=',
'Erai-raws [Multi-Sub]': 'https://nyaa.si/?page=rss&c=0_0&f=0&u=Erai-raws&q=',
NanDesuKa: 'https://nyaa.si/?page=rss&c=0_0&f=0&u=NanDesuKa&q='
}
const epstring = ep => `"E${pl(ep)}+"|"E${pl(ep)}v"|"+${pl(ep)}+"|"+${pl(ep)}v"`
export function getReleasesRSSurl (val) {
const rss = rssmap[val] || val
return rss && new URL(rssmap[val] ? `${rss}${settings.rssQuality ? `"${settings.rssQuality}"` : ''}` : rss)
}
// matches: OP01 ED01 EP01 E01 01v -01- _01_ with spaces and stuff
const epNumRx = /[EO]?[EPD _-]\d{2}[v _-]|\d{2}[-~]\d{2}/i
async function getRSSEntries ({ media, episode, mode, ignoreQuality }) {
// mode cuts down on the amt of queries made 'check' || 'batch'
const titles = createTitle(media).join(')|(')
const prequel = findEdge(media, 'PREQUEL')?.node
const sequel = findEdge(media, 'SEQUEL')?.node
const isBatch = media.status === 'FINISHED' && settings.rssBatch && media.episodes !== 1
// if media has multiple seasons, and this S is > 1, then get the absolute episode number of the episode
const absolute = prequel && !mode && (await resolveSeason({ media, episode, force: true }))
const absoluteep = absolute?.offset + episode
const episodes = [episode]
// only use absolute episode number if its smaller than max episodes this series has, ex:
// looking for E1 of S2, S1 has 12 ep and S2 has 13, absolute will be 13
// so this would find the 13th ep of the 2nd season too if this check wasnt here
if (absolute && absoluteep < (getMediaMaxEp(media) || episode)) {
episodes.push(absoluteep)
}
let ep = ''
if (media.episodes !== 1 && mode !== 'batch') {
if (isBatch) {
ep = `"01-${pl(media.episodes)}"|"01~${pl(media.episodes)}"|"Batch"|"Complete"|"${pl(episode)}+"|"${pl(episode)}v"|"S01"`
} else {
table = entries
ep = `(${episodes.map(epstring).join('|')})`
}
}
function close () {
table = null
const excl = exclusions.join('|')
const quality = (!ignoreQuality && (`"${settings.rssQuality}"` || '"1080"')) || ''
const trusted = settings.rssTrusted === true ? 2 : 0
const url = new URL(
`https://nyaa.si/?page=rss&c=1_2&f=${trusted}&s=seeders&o=desc&q=(${titles})${ep}${quality}-(${excl})`
)
let nodes = [...(await getRSSContent(url)).querySelectorAll('item')]
if (absolute) {
// if this is S > 1 aka absolute ep number exists get entries for S1title + absoluteEP
// the reason this isnt done with recursion like sequelEntries is because that would include the S1 media dates
// we want the dates of the target media as the S1 title might be used for SX releases
const titles = createTitle(absolute.media).join(')|(')
const url = new URL(
`https://nyaa.si/?page=rss&c=1_2&f=${trusted}&s=seeders&o=desc&q=(${titles})${epstring(absoluteep)}${quality}-(${excl})`
)
nodes = [...nodes, ...(await getRSSContent(url)).querySelectorAll('item')]
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
let entries = parseRSSNodes(nodes)
const checkSequelDate = media.status === 'FINISHED' && (sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') && sequel.startDate
const sequelStartDate = checkSequelDate && new Date(Object.values(checkSequelDate).join(' '))
// recursive, get all entries for media sequel, and its sequel, and its sequel
const sequelEntries =
(sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') &&
(await getRSSEntries({ media: (await alRequest({ method: 'SearchIDSingle', id: sequel.id })).data.Media, episode, mode: mode || 'check' }))
const checkPrequelDate = (media.status === 'FINISHED' || media.status === 'RELEASING') && prequel?.status === 'FINISHED' && prequel?.endDate
const prequelEndDate = checkPrequelDate && new Date(Object.values(checkPrequelDate).join(' '))
// 1 month in MS, a bit of jitter for pre-releases and releasers being late as fuck, lets hope it doesnt cause issues
const month = 2674848460
if (prequelEndDate) {
entries = entries.filter(entry => entry.date > new Date(+prequelEndDate + month))
}
function play (entry) {
updateMedia(fileMedia)
if (entry.seeders !== '?' && entry.seeders <= 15) {
addToast({
text: 'This release is poorly seeded and likely will have playback issues such as buffering!',
title: 'Availability Warning',
type: 'secondary'
})
if (sequelStartDate && media.format === 'TV') {
entries = entries.filter(entry => entry.date < new Date(+sequelStartDate - month))
}
if (sequelEntries?.length) {
if (mode === 'check') {
entries = [...entries, ...sequelEntries]
} else {
entries = entries.filter(entry => !sequelEntries.find(sequel => sequel.link === entry.link))
}
add(entry.link)
table = null
}
// this gets entries without any episode limiting, and for batches
const batchEntries = !mode && isBatch && (await getRSSEntries({ media, episode, ignoreQuality, mode: 'batch' })).filter(entry => {
return !epNumRx.test(entry.title)
})
if (batchEntries?.length) {
entries = [...entries, ...batchEntries]
}
// some archaic shows only have shit DVD's in weird qualities, so try to look up without any quality restrictions when there are no results
if (!entries.length && !ignoreQuality && !mode) {
entries = await getRSSEntries({ media, episode, ignoreQuality: true })
}
// dedupe
const ids = entries.map(e => e.link)
return entries.filter(({ link }, index) => !ids.includes(link, index + 1))
}
function parseRSSNodes (nodes) {
return nodes.map(item => {
const pubDate = item.querySelector('pubDate')?.textContent
return {
title: item.querySelector('title')?.textContent || '?',
link: item.querySelector('link')?.textContent || '?',
seeders: item.querySelector('seeders')?.textContent ?? '?',
leechers: item.querySelector('leechers')?.textContent ?? '?',
downloads: item.querySelector('downloads')?.textContent ?? '?',
size: item.querySelector('size')?.textContent ?? '?',
date: pubDate && new Date(pubDate)
}
})
}
// create an array of potentially valid titles from a given media
function createTitle (media) {
// group and de-duplicate
const grouped = [
...new Set(
Object.values(media.title)
.concat(media.synonyms)
.filter(name => name != null && name.length > 3)
)
]
const titles = []
for (const t of grouped) {
// replace & with encoded
const title = t.replace(/&/g, '%26').replace(/\?/g, '%3F').replace(/#/g, '%23')
titles.push(title)
// replace Season 2 with S2, else replace 2nd Season with S2, but keep the original title
const match1 = title.match(/(\d)(?:nd|rd|th) Season/i)
const match2 = title.match(/Season (\d)/i)
if (match2) {
titles.push(title.replace(/Season \d/i, `S${match2[1]}`))
} else if (match1) {
titles.push(title.replace(/(\d)(?:nd|rd|th) Season/i, `S${match1[1]}`))
}
}
return titles
}
</script>
<div class="modal" class:show={table} id="viewAnime" on:keydown={checkClose} tabindex="-1">
<script>
import { add } from '@/modules/torrent.js'
$: parseRss($rss)
let table = null
let fileMedia = null
export async function parseRss ({ media, episode }) {
if (!media) return
const entries = await getRSSEntries({ media, episode })
if (!entries?.length) {
addToast({
text: /* html */`Couldn't find torrent for ${media.title.userPreferred} Episode ${parseInt(episode)}! Try specifying a torrent manually.`,
title: 'Search Failed',
type: 'danger'
})
return
}
entries.sort((a, b) => b.seeders - a.seeders)
const streamingEpisode = media?.streamingEpisodes.filter(episode => episodeRx.exec(episode.title) && Number(episodeRx.exec(episode.title)[1]) === Number(episode))[0]
fileMedia = {
mediaTitle: media?.title.userPreferred,
episodeNumber: Number(episode),
episodeTitle: streamingEpisode ? episodeRx.exec(streamingEpisode.title)[2] : undefined,
episodeThumbnail: streamingEpisode?.thumbnail,
mediaCover: media?.coverImage.medium,
name: 'Miru',
media
}
if (settings.rssAutoplay) {
play(entries[0])
} else {
table = entries
}
}
function close () {
table = null
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
function play (entry) {
updateMedia(fileMedia)
if (entry.seeders !== '?' && entry.seeders <= 15) {
addToast({
text: 'This release is poorly seeded and likely will have playback issues such as buffering!',
title: 'Availability Warning',
type: 'secondary'
})
}
add(entry.link)
table = null
}
</script>
<div class='modal' class:show={table} id='viewAnime' on:keydown={checkClose} tabindex='-1'>
{#if table}
<div class="modal-dialog p-20" role="document" on:click|self={close}>
<div class="modal-content w-auto">
<button class="close pointer" type="button" on:click={close}> &times; </button>
<table class="table table-hover">
<div class='modal-dialog p-20' role='document' on:click|self={close}>
<div class='modal-content w-auto'>
<button class='close pointer' type='button' on:click={close}> &times; </button>
<table class='table table-hover'>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Name</th>
<th scope="col">Size</th>
<th scope="col">Seed</th>
<th scope="col">Leech</th>
<th scope="col">Downloads</th>
<th scope="col">Play</th>
<th scope='col'>#</th>
<th scope='col'>Name</th>
<th scope='col'>Size</th>
<th scope='col'>Seed</th>
<th scope='col'>Leech</th>
<th scope='col'>Downloads</th>
<th scope='col'>Play</th>
</tr>
</thead>
<tbody class="results pointer">
<tbody class='results pointer'>
{#each table as row, index}
<tr on:click={() => play(row)}>
<th>{index + 1}</th>
@ -298,7 +297,7 @@
<td>{row.seeders}</td>
<td>{row.leechers}</td>
<td>{row.downloads}</td>
<td class="material-icons font-size-20">play_arrow</td>
<td class='material-icons font-size-20'>play_arrow</td>
</tr>
{/each}
</tbody>

View file

@ -1,21 +1,21 @@
<script context="module">
import { writable } from 'svelte/store'
<script context='module'>
import { writable } from 'svelte/store'
export const files = writable([])
export const files = writable([])
</script>
<script>
import { getContext } from 'svelte'
import Home from './pages/home/Home.svelte'
import Player from './pages/Player.svelte'
import Settings from './pages/Settings.svelte'
import WatchTogether from './pages/watchtogether/WatchTogether.svelte'
import Miniplayer from 'svelte-miniplayer'
export let page = 'home'
const current = getContext('gallery')
import { getContext } from 'svelte'
import Home from './pages/home/Home.svelte'
import Player from './pages/Player.svelte'
import Settings from './pages/Settings.svelte'
import WatchTogether from './pages/watchtogether/WatchTogether.svelte'
import Miniplayer from 'svelte-miniplayer'
export let page = 'home'
const current = getContext('gallery')
</script>
<div class="overflow-hidden content-wrapper">
<div class='overflow-hidden content-wrapper'>
<Miniplayer active={page !== 'player'} class='bg-dark z-10 {page === 'player' ? 'h-full' : ''}' minwidth='35rem' maxwidth='45rem' width='300px' padding='2rem'>
<Player files={$files} miniplayer={page !== 'player'} bind:page />
</Miniplayer>

View file

@ -1,115 +1,115 @@
<script>
import { getContext } from 'svelte'
import { alID } from '@/modules/anilist.js'
import { media } from './pages/Player.svelte'
import { platformMap } from './pages/Settings.svelte'
import { addToast } from './Toasts.svelte'
const sidebar = getContext('sidebar')
const view = getContext('view')
const gallery = getContext('gallery')
export let page
const links = [
{
click: () => {
$sidebar = !$sidebar
},
image: 'logo_cut.png',
icon: 'menu',
text: 'Open Menu'
import { getContext } from 'svelte'
import { alID } from '@/modules/anilist.js'
import { media } from './pages/Player.svelte'
import { platformMap } from './pages/Settings.svelte'
import { addToast } from './Toasts.svelte'
const sidebar = getContext('sidebar')
const view = getContext('view')
const gallery = getContext('gallery')
export let page
const links = [
{
click: () => {
$sidebar = !$sidebar
},
{
click: () => {
page = 'home'
$gallery = null
},
icon: 'home',
text: 'Home Page'
image: 'logo_cut.png',
icon: 'menu',
text: 'Open Menu'
},
{
click: () => {
page = 'home'
$gallery = null
},
{
click: () => {
page = 'home'
$gallery = 'schedule'
},
icon: 'schedule',
text: 'Airing Schedule'
icon: 'home',
text: 'Home Page'
},
{
click: () => {
page = 'home'
$gallery = 'schedule'
},
{
click: () => {
if (media) $view = media
},
icon: 'queue_music',
text: 'Now Playing'
icon: 'schedule',
text: 'Airing Schedule'
},
{
click: () => {
if (media) $view = media
},
{
click: () => {
page = 'watchtogether'
},
icon: 'groups',
text: 'Watch Together'
icon: 'queue_music',
text: 'Now Playing'
},
{
click: () => {
page = 'watchtogether'
},
{
click: () => {
page = 'settings'
},
icon: 'tune',
text: 'Settings'
icon: 'groups',
text: 'Watch Together'
},
{
click: () => {
page = 'settings'
},
{
click: () => {
if (alID) {
localStorage.removeItem('ALtoken')
location.hash = ''
location.reload()
} else {
window.IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
if (platformMap[window.version.platform] === 'Linux') {
addToast({
text: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
title: 'Support Notification',
type: 'secondary',
duration: '300000'
})
}
icon: 'tune',
text: 'Settings'
},
{
click: () => {
if (alID) {
localStorage.removeItem('ALtoken')
location.hash = ''
location.reload()
} else {
window.IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
if (platformMap[window.version.platform] === 'Linux') {
addToast({
text: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
title: 'Support Notification',
type: 'secondary',
duration: '300000'
})
}
},
icon: 'login',
text: 'Login With AniList'
}
]
if (alID) {
alID.then(result => {
if (result?.data?.Viewer) {
links[links.length - 1].image = result.data.Viewer.avatar.medium
links[links.length - 1].text = result.data.Viewer.name + '\nLogout'
}
})
},
icon: 'login',
text: 'Login With AniList'
}
]
if (alID) {
alID.then(result => {
if (result?.data?.Viewer) {
links[links.length - 1].image = result.data.Viewer.avatar.medium
links[links.length - 1].text = result.data.Viewer.name + '\nLogout'
}
})
}
</script>
<div class="sidebar shadow-lg">
<div class="sidebar-menu h-full d-flex flex-column m-0 pb-5">
<div class='sidebar shadow-lg'>
<div class='sidebar-menu h-full d-flex flex-column m-0 pb-5'>
{#each links as { click, icon, text, image }, i (i)}
<div
class="sidebar-link sidebar-link-with-icon pointer"
class='sidebar-link sidebar-link-with-icon pointer'
class:brand={i === 0}
data-toggle="tooltip"
data-placement="right"
data-toggle='tooltip'
data-placement='right'
data-title={text}
on:click={click}
class:mt-auto={i === links.length - 2}>
<span class="text-nowrap d-flex align-items-center" class:justify-content-between={i === 0}>
<span class='text-nowrap d-flex align-items-center' class:justify-content-between={i === 0}>
{#if image}
{#if i === 0}
<img src={image} alt="logo" class="text" />
<span class="material-icons menu">{icon}</span>
<img src={image} alt='logo' class='text' />
<span class='material-icons menu'>{icon}</span>
{:else}
<img src={image} alt="logo" />
<span class="text">{text}</span>
<span class="material-icons menu text">{icon}</span>
<img src={image} alt='logo' />
<span class='text'>{text}</span>
<span class='material-icons menu text'>{icon}</span>
{/if}
{:else}
<span class="material-icons">{icon}</span>
<span class="text">{text}</span>
<span class='material-icons'>{icon}</span>
<span class='text'>{text}</span>
{/if}
</span>
</div>

View file

@ -1,11 +1,11 @@
<script>
import { getContext } from 'svelte'
import { TABS } from './Tabs.svelte'
import { getContext } from 'svelte'
import { TABS } from './Tabs.svelte'
const panel = {}
const { registerPanel, selectedPanel } = getContext(TABS)
const panel = {}
const { registerPanel, selectedPanel } = getContext(TABS)
registerPanel(panel)
registerPanel(panel)
</script>
{#if $selectedPanel === panel}
@ -15,8 +15,8 @@
<style>
slot {
margin-bottom: 10px;
padding: 40px;
border: 1px solid #dee2e6;
padding: 40px;
border: 1px solid #dee2e6;
border-radius: 0 0 .5rem .5rem;
border-top: 0;
}

View file

@ -1,11 +1,11 @@
<script>
import { getContext } from 'svelte'
import { TABS } from './Tabs.svelte'
import { getContext } from 'svelte'
import { TABS } from './Tabs.svelte'
const tab = {}
const { registerTab, selectTab, selectedTab } = getContext(TABS)
const tab = {}
const { registerTab, selectTab, selectedTab } = getContext(TABS)
registerTab(tab)
registerTab(tab)
</script>
<div class={'pointer border-bottom ' + ($selectedTab === tab ? 'bg-dark-light' : '')} on:click={() => selectTab(tab)}>

View file

@ -1,48 +1,48 @@
<script context="module">
export const TABS = {}
<script context='module'>
export const TABS = {}
</script>
<script>
import { setContext, onDestroy } from 'svelte'
import { writable } from 'svelte/store'
import { setContext, onDestroy } from 'svelte'
import { writable } from 'svelte/store'
const tabs = []
const panels = []
const selectedTab = writable(null)
const selectedPanel = writable(null)
const tabs = []
const panels = []
const selectedTab = writable(null)
const selectedPanel = writable(null)
setContext(TABS, {
registerTab: tab => {
tabs.push(tab)
selectedTab.update(current => current || tab)
setContext(TABS, {
registerTab: tab => {
tabs.push(tab)
selectedTab.update(current => current || tab)
onDestroy(() => {
const i = tabs.indexOf(tab)
tabs.splice(i, 1)
selectedTab.update(current => (current === tab ? tabs[i] || tabs[tabs.length - 1] : current))
})
},
registerPanel: panel => {
panels.push(panel)
selectedPanel.update(current => current || panel)
onDestroy(() => {
const i = panels.indexOf(panel)
panels.splice(i, 1)
selectedPanel.update(current => (current === panel ? panels[i] || panels[panels.length - 1] : current))
})
},
selectTab: tab => {
onDestroy(() => {
const i = tabs.indexOf(tab)
selectedTab.set(tab)
selectedPanel.set(panels[i])
},
tabs.splice(i, 1)
selectedTab.update(current => (current === tab ? tabs[i] || tabs[tabs.length - 1] : current))
})
},
selectedTab,
selectedPanel
})
registerPanel: panel => {
panels.push(panel)
selectedPanel.update(current => current || panel)
onDestroy(() => {
const i = panels.indexOf(panel)
panels.splice(i, 1)
selectedPanel.update(current => (current === panel ? panels[i] || panels[panels.length - 1] : current))
})
},
selectTab: tab => {
const i = tabs.indexOf(tab)
selectedTab.set(tab)
selectedPanel.set(panels[i])
},
selectedTab,
selectedPanel
})
</script>
<slot />

View file

@ -1,33 +1,33 @@
<script context="module">
import { writable } from 'svelte/store'
const toasts = writable({})
let index = 0
export function addToast (opts) {
// type, click, title, text
toasts.update(toasts => {
const i = ++index
toasts[i] = opts
setTimeout(() => {
close(i)
}, opts.duration || 10000)
return toasts
})
}
function close (index) {
toasts.update(toasts => {
if (toasts[index]) {
delete toasts[index]
}
return toasts
})
}
<script context='module'>
import { writable } from 'svelte/store'
const toasts = writable({})
let index = 0
export function addToast (opts) {
// type, click, title, text
toasts.update(toasts => {
const i = ++index
toasts[i] = opts
setTimeout(() => {
close(i)
}, opts.duration || 10000)
return toasts
})
}
function close (index) {
toasts.update(toasts => {
if (toasts[index]) {
delete toasts[index]
}
return toasts
})
}
</script>
<div class="sticky-alerts d-flex flex-column-reverse">
<div class='sticky-alerts d-flex flex-column-reverse'>
{#each Object.entries($toasts) as [index, toast] (index)}
<div class="alert alert-{toast.type} filled" class:pointer={toast.click} on:click={toast.click}>
<button class="close" type="button" on:click={() => close(index)}><span aria-hidden="true">×</span></button>
<h4 class="alert-heading">{toast.title}</h4>
<div class='alert alert-{toast.type} filled' class:pointer={toast.click} on:click={toast.click}>
<button class='close' type='button' on:click={() => close(index)}><span aria-hidden='true'>×</span></button>
<h4 class='alert-heading'>{toast.title}</h4>
{@html toast.text}
</div>
{/each}

View file

@ -1,72 +1,72 @@
<script>
import { playAnime } from './RSSView.svelte'
import { alRequest } from '@/modules/anilist.js'
import { getMediaMaxEp } from '@/modules/anime.js'
import { getContext } from 'svelte'
import Details from './ViewAnime/Details.svelte'
import Following from './ViewAnime/Following.svelte'
import Controls from './ViewAnime/Controls.svelte'
import ToggleList from './ViewAnime/ToggleList.svelte'
import { playAnime } from './RSSView.svelte'
import { alRequest } from '@/modules/anilist.js'
import { getMediaMaxEp } from '@/modules/anime.js'
import { getContext } from 'svelte'
import Details from './ViewAnime/Details.svelte'
import Following from './ViewAnime/Following.svelte'
import Controls from './ViewAnime/Controls.svelte'
import ToggleList from './ViewAnime/ToggleList.svelte'
const view = getContext('view')
const trailer = getContext('trailer')
function close () {
$view = null
}
$: media = $view
let modal
$: media && modal?.focus()
$: !$trailer && modal?.focus()
$: maxPlayEp = getMediaMaxEp($view || {}, true)
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
const view = getContext('view')
const trailer = getContext('trailer')
function close () {
$view = null
}
$: media = $view
let modal
$: media && modal?.focus()
$: !$trailer && modal?.focus()
$: maxPlayEp = getMediaMaxEp($view || {}, true)
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
</script>
<div class="modal modal-full" class:show={media} on:keydown={checkClose} tabindex="-1" bind:this={modal}>
<div class='modal modal-full' class:show={media} on:keydown={checkClose} tabindex='-1' bind:this={modal}>
{#if media}
<div class="h-full modal-content bg-very-dark p-0 overflow-y-auto">
<button class="close pointer z-30 bg-dark shadow-lg top-20 right-0" type="button" on:click={close}> &times; </button>
<div class="h-md-half w-full position-relative z-20">
<div class="h-full w-full position-absolute bg-dark-light banner" style:--bannerurl={`url('${media.bannerImage || ''}')`} />
<div class="d-flex h-full top w-full">
<div class="container-xl w-full">
<div class="row d-flex justify-content-end flex-row h-full px-20 pt-20 px-xl-0">
<div class="col-md-3 col-4 d-flex h-full justify-content-end flex-column pb-15 align-items-center">
<img class="contain-img rounded mw-full mh-full shadow" alt="cover" src={media.coverImage?.extraLarge || media.coverImage?.medium} />
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto'>
<button class='close pointer z-30 bg-dark shadow-lg top-20 right-0' type='button' on:click={close}> &times; </button>
<div class='h-md-half w-full position-relative z-20'>
<div class='h-full w-full position-absolute bg-dark-light banner' style:--bannerurl={`url('${media.bannerImage || ''}')`} />
<div class='d-flex h-full top w-full'>
<div class='container-xl w-full'>
<div class='row d-flex justify-content-end flex-row h-full px-20 pt-20 px-xl-0'>
<div class='col-md-3 col-4 d-flex h-full justify-content-end flex-column pb-15 align-items-center'>
<img class='contain-img rounded mw-full mh-full shadow' alt='cover' src={media.coverImage?.extraLarge || media.coverImage?.medium} />
</div>
<div class="col-md-9 col-8 row align-content-end pl-20">
<div class="col-md-8 col-12 d-flex justify-content-end flex-column">
<div class="px-md-20 d-flex flex-column font-size-12">
<span class="title font-weight-bold pb-sm-15 text-white">
<div class='col-md-9 col-8 row align-content-end pl-20'>
<div class='col-md-8 col-12 d-flex justify-content-end flex-column'>
<div class='px-md-20 d-flex flex-column font-size-12'>
<span class='title font-weight-bold pb-sm-15 text-white'>
{media.title.userPreferred}
</span>
<div class="d-flex flex-row font-size-18 pb-sm-15">
<div class='d-flex flex-row font-size-18 pb-sm-15'>
{#if media.averageScore}
<span class="material-icons mr-10 font-size-24"> trending_up </span>
<span class="mr-20">
<span class='material-icons mr-10 font-size-24'> trending_up </span>
<span class='mr-20'>
Rating: {media.averageScore + '%'}
</span>
{/if}
<span class="material-icons mx-10 font-size-24"> monitor </span>
<span class="mr-20 text-capitalize">
<span class='material-icons mx-10 font-size-24'> monitor </span>
<span class='mr-20 text-capitalize'>
Format: {media.format === 'TV' ? media.format : media.format?.replace(/_/g, ' ').toLowerCase()}
</span>
{#if media.episodes !== 1 && getMediaMaxEp(media)}
<span class="material-icons mx-10 font-size-24"> theaters </span>
<span class="mr-20">
<span class='material-icons mx-10 font-size-24'> theaters </span>
<span class='mr-20'>
Episodes: {getMediaMaxEp(media)}
</span>
{:else if media.duration}
<span class="material-icons mx-10 font-size-24"> timer </span>
<span class="mr-20">
<span class='material-icons mx-10 font-size-24'> timer </span>
<span class='mr-20'>
Length: {media.duration + ' min'}
</span>
{/if}
</div>
<div class="pb-15 pt-5 overflow-x-auto text-nowrap font-weight-bold">
<div class='pb-15 pt-5 overflow-x-auto text-nowrap font-weight-bold'>
{#each media.genres as genre}
<div class="badge badge-pill shadow">
<div class='badge badge-pill shadow'>
{genre}
</div>
{/each}
@ -79,25 +79,25 @@
</div>
</div>
</div>
<div class="container-xl bg-very-dark z-10">
<div class="row p-20 px-xl-0 flex-column-reverse flex-md-row">
<div class="col-md-9 px-20">
<h1 class="title font-weight-bold text-white">Synopsis</h1>
<div class="font-size-16 pr-15">
<div class='container-xl bg-very-dark z-10'>
<div class='row p-20 px-xl-0 flex-column-reverse flex-md-row'>
<div class='col-md-9 px-20'>
<h1 class='title font-weight-bold text-white'>Synopsis</h1>
<div class='font-size-16 pr-15'>
{@html media.description}
</div>
<ToggleList list={media.relations?.edges?.filter(({ node }) => node.type === 'ANIME')} let:item>
<div class="w-150 mx-15 mb-10 rel pointer" on:click={async () => { $view = null; $view = (await alRequest({ method: '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" />
<div class="pt-5">{item.relationType.replace(/_/g, ' ').toLowerCase()}</div>
<h5 class="font-weight-bold text-white">{item.node.title.userPreferred}</h5>
<div class='w-150 mx-15 mb-10 rel pointer' on:click={async () => { $view = null; $view = (await alRequest({ method: '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' />
<div class='pt-5'>{item.relationType.replace(/_/g, ' ').toLowerCase()}</div>
<h5 class='font-weight-bold text-white'>{item.node.title.userPreferred}</h5>
</div>
</ToggleList>
{#if maxPlayEp}
<table class="table table-hover w-500 table-auto">
<table class='table table-hover w-500 table-auto'>
<thead>
<tr>
<th class="px-0"><h2 class="title font-weight-bold text-white pt-20 mb-5">Episodes</h2></th>
<th class='px-0'><h2 class='title font-weight-bold text-white pt-20 mb-5'>Episodes</h2></th>
<th />
</tr>
</thead>
@ -109,21 +109,21 @@
playAnime(media, ep)
close()
}}>
<td class="w-full font-weight-semi-bold">Episode {ep}</td>
<td class="material-icons text-right h-full d-table-cell">play_arrow</td>
<td class='w-full font-weight-semi-bold'>Episode {ep}</td>
<td class='material-icons text-right h-full d-table-cell'>play_arrow</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<ToggleList list={media.recommendations.edges.filter(edge => edge.node.mediaRecommendation)} let:item>
<div class="w-150 mx-15 mb-10 rel pointer" on:click={async () => { $view = null; $view = (await alRequest({ method: '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" />
<h5 class="font-weight-bold text-white">{item.node.mediaRecommendation.title.userPreferred}</h5>
<div class='w-150 mx-15 mb-10 rel pointer' on:click={async () => { $view = null; $view = (await alRequest({ method: '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' />
<h5 class='font-weight-bold text-white'>{item.node.mediaRecommendation.title.userPreferred}</h5>
</div>
</ToggleList>
</div>
<div class="col-md-3 px-sm-0 px-20">
<div class='col-md-3 px-sm-0 px-20'>
<Details {media}/>
<Following {media}/>
</div>

View file

@ -1,123 +1,123 @@
<script>
import { alToken } from '@/lib/pages/Settings.svelte'
import { addToast } from '../Toasts.svelte'
import { alRequest } from '@/modules/anilist.js'
import { getContext } from 'svelte'
import { getMediaMaxEp } from '@/modules/anime.js'
import { playAnime } from '../RSSView.svelte'
export let media = null
import { alToken } from '@/lib/pages/Settings.svelte'
import { addToast } from '../Toasts.svelte'
import { alRequest } from '@/modules/anilist.js'
import { getContext } from 'svelte'
import { getMediaMaxEp } from '@/modules/anime.js'
import { playAnime } from '../RSSView.svelte'
export let media = null
const toggleStatusMap = {
CURRENT: true,
COMPLETED: true,
PAUSED: true,
REPEATING: true
}
async function toggleStatus () {
if (media.mediaListEntry?.status !== 'PLANNING') {
// add
await setStatus((media.mediaListEntry?.status in toggleStatusMap) ? 'DROPPED' : 'CURRENT')
} else {
// delete
const variables = {
method: 'Delete',
id: media.mediaListEntry.id
}
await alRequest(variables)
}
update()
}
function getStatusText () {
if (media.mediaListEntry) {
const { status } = media.mediaListEntry
if (status === 'PLANNING') return 'Remove From List'
if (media.mediaListEntry?.status in toggleStatusMap) return 'Drop From Watching'
}
return 'Add To List'
}
function setStatus (status, other = {}) {
const toggleStatusMap = {
CURRENT: true,
COMPLETED: true,
PAUSED: true,
REPEATING: true
}
async function toggleStatus () {
if (media.mediaListEntry?.status !== 'PLANNING') {
// add
await setStatus((media.mediaListEntry?.status in toggleStatusMap) ? 'DROPPED' : 'CURRENT')
} else {
// delete
const variables = {
method: 'Entry',
id: media.id,
status,
...other
}
return alRequest(variables)
}
async function update () {
media = (await alRequest({ method: 'SearchIDSingle', id: media.id })).data.Media
}
async function score (score) {
const variables = {
method: 'Entry',
id: media.id,
score: score * 10
method: 'Delete',
id: media.mediaListEntry.id
}
await alRequest(variables)
media = (await alRequest({ method: 'SearchIDSingle', id: media.id })).data.Media
}
const trailer = getContext('trailer')
function viewTrailer (media) {
$trailer = media.trailer.id
update()
}
function getStatusText () {
if (media.mediaListEntry) {
const { status } = media.mediaListEntry
if (status === 'PLANNING') return 'Remove From List'
if (media.mediaListEntry?.status in toggleStatusMap) return 'Drop From Watching'
}
function copyToClipboard (text) {
navigator.clipboard.writeText(text)
addToast({
title: 'Copied to clipboard',
text: 'Copied share URL to clipboard',
type: 'primary',
duration: '5000'
})
return 'Add To List'
}
function setStatus (status, other = {}) {
const variables = {
method: 'Entry',
id: media.id,
status,
...other
}
function openInBrowser (url) {
window.IPC.emit('open', url)
return alRequest(variables)
}
async function update () {
media = (await alRequest({ method: 'SearchIDSingle', id: media.id })).data.Media
}
async function score (score) {
const variables = {
method: 'Entry',
id: media.id,
score: score * 10
}
function getPlayText (media) {
if (media.mediaListEntry) {
const { status, progress } = media.mediaListEntry
if (progress) {
if (status === 'COMPLETED') return 'Rewatch'
return 'Continue ' + Math.min(getMediaMaxEp(media), progress + 1)
await alRequest(variables)
media = (await alRequest({ method: 'SearchIDSingle', id: media.id })).data.Media
}
const trailer = getContext('trailer')
function viewTrailer (media) {
$trailer = media.trailer.id
}
function copyToClipboard (text) {
navigator.clipboard.writeText(text)
addToast({
title: 'Copied to clipboard',
text: 'Copied share URL to clipboard',
type: 'primary',
duration: '5000'
})
}
function openInBrowser (url) {
window.IPC.emit('open', url)
}
function getPlayText (media) {
if (media.mediaListEntry) {
const { status, progress } = media.mediaListEntry
if (progress) {
if (status === 'COMPLETED') return 'Rewatch'
return 'Continue ' + Math.min(getMediaMaxEp(media), progress + 1)
}
}
return 'Play'
}
async function play () {
let ep = 1
if (media.mediaListEntry) {
const { status, progress } = media.mediaListEntry
if (progress) {
if (status === 'COMPLETED') {
setStatus('REPEATING', { episode: 0 })
} else {
ep = Math.min(getMediaMaxEp(media, true), progress + 1)
}
}
return 'Play'
}
async function play () {
let ep = 1
if (media.mediaListEntry) {
const { status, progress } = media.mediaListEntry
if (progress) {
if (status === 'COMPLETED') {
setStatus('REPEATING', { episode: 0 })
} else {
ep = Math.min(getMediaMaxEp(media, true), progress + 1)
}
}
}
playAnime(media, ep)
media = null
}
playAnime(media, ep)
media = null
}
</script>
<div class="col-md-4 d-flex justify-content-end flex-column">
<div class="d-flex flex-column flex-wrap">
<div class='col-md-4 d-flex justify-content-end flex-column'>
<div class='d-flex flex-column flex-wrap'>
<button
class="btn btn-primary d-flex align-items-center font-weight-bold font-size-24 h-50 mb-5 shadow-lg"
type="button"
class='btn btn-primary d-flex align-items-center font-weight-bold font-size-24 h-50 mb-5 shadow-lg'
type='button'
on:click={() => play(media)}>
<span class="material-icons mr-10 font-size-24 w-30"> play_arrow </span>
<span class='material-icons mr-10 font-size-24 w-30'> play_arrow </span>
<span>{getPlayText(media)}</span>
</button>
{#if alToken}
<button class="btn d-flex align-items-center mb-5 font-weight-bold font-size-16 btn-primary shadow-lg" on:click={toggleStatus}>
<span class="material-icons mr-10 font-size-18 w-30"> {(media.mediaListEntry?.status in toggleStatusMap) ? 'remove' : 'add'} </span>
<button class='btn d-flex align-items-center mb-5 font-weight-bold font-size-16 btn-primary shadow-lg' on:click={toggleStatus}>
<span class='material-icons mr-10 font-size-18 w-30'> {(media.mediaListEntry?.status in toggleStatusMap) ? 'remove' : 'add'} </span>
{getStatusText(media)}
</button>
<div class="input-group shadow-lg mb-5 font-size-16">
<div class="input-group-prepend">
<span class="input-group-text bg-tp pl-15 d-flex material-icons font-size-18">hotel_class</span>
<div class='input-group shadow-lg mb-5 font-size-16'>
<div class='input-group-prepend'>
<span class='input-group-text bg-tp pl-15 d-flex material-icons font-size-18'>hotel_class</span>
</div>
<select class="form-control" required value={(media.mediaListEntry?.score || '').toString()} on:change={({ target }) => { score(media, target.value) }}>
<select class='form-control' required value={(media.mediaListEntry?.score || '').toString()} on:change={({ target }) => { score(media, target.value) }}>
<option value selected disabled hidden>Score</option>
<option>1</option>
<option>2</option>
@ -133,18 +133,18 @@
</div>
{/if}
{#if media.trailer}
<button class="btn d-flex align-items-center mb-5 font-weight-bold font-size-16 shadow-lg" on:click={() => viewTrailer(media)}>
<span class="material-icons mr-15 font-size-18 w-30"> live_tv </span>
<button class='btn d-flex align-items-center mb-5 font-weight-bold font-size-16 shadow-lg' on:click={() => viewTrailer(media)}>
<span class='material-icons mr-15 font-size-18 w-30'> live_tv </span>
Trailer
</button>
{/if}
<div class="d-flex mb-5 w-full">
<button class="btn flex-fill font-weight-bold font-size-16 shadow-lg d-flex align-items-center" on:click={() => { openInBrowser(`https://anilist.co/anime/${media.id}`) }}>
<span class="material-icons mr-15 font-size-18 w-30"> open_in_new </span>
<div class='d-flex mb-5 w-full'>
<button class='btn flex-fill font-weight-bold font-size-16 shadow-lg d-flex align-items-center' on:click={() => { openInBrowser(`https://anilist.co/anime/${media.id}`) }}>
<span class='material-icons mr-15 font-size-18 w-30'> open_in_new </span>
Open
</button>
<button class="btn flex-fill font-weight-bold font-size-16 ml-5 shadow-lg d-flex align-items-center" on:click={() => { copyToClipboard(`<miru://anime/${media.id}>`) }}>
<span class="material-icons mr-15 font-size-18 w-30"> share </span>
<button class='btn flex-fill font-weight-bold font-size-16 ml-5 shadow-lg d-flex align-items-center' on:click={() => { copyToClipboard(`<miru://anime/${media.id}>`) }}>
<span class='material-icons mr-15 font-size-18 w-30'> share </span>
Share
</button>
</div>
@ -161,4 +161,4 @@
.w-30 {
width: 3rem
}
</style>
</style>

View file

@ -1,62 +1,62 @@
<script>
import { countdown } from '@/modules/util.js'
import { getMediaMaxEp } from '@/modules/anime.js'
export let media = null
import { countdown } from '@/modules/util.js'
import { getMediaMaxEp } from '@/modules/anime.js'
export let media = null
const detailsMap = [
{ property: 'episode', label: 'Airing', icon: 'schedule', custom: 'property' },
{ property: 'genres', label: 'Genres', icon: 'theater_comedy' },
{ property: 'season', label: 'Season', icon: 'spa', custom: 'property' },
{ property: 'episodes', label: 'Episodes', icon: 'theaters', custom: 'property' },
{ property: 'duration', label: 'Duration', icon: 'timer', custom: 'property' },
{ property: 'format', label: 'Format', icon: 'monitor' },
{ property: 'status', label: 'Status', icon: 'live_tv' },
{ property: 'nodes', label: 'Studio', icon: 'business' },
{ property: 'source', label: 'Source', icon: 'source' },
{ property: 'averageScore', label: 'Rating', icon: 'trending_up', custom: 'property' },
{ property: 'english', label: 'English', icon: 'title' },
{ property: 'romaji', label: 'Romaji', icon: 'translate' },
{ property: 'native', label: 'Native', icon: '語', custom: 'icon' }
]
function getCustomProperty (detail, media) {
if (detail.property === 'episodes') {
if (media.mediaListEntry?.progress) {
return `Watched <b>${media.mediaListEntry.progress}</b> of <b>${getMediaMaxEp(media)}</b>`
}
return `${getMediaMaxEp(media)} Episodes`
} else if (detail.property === 'averageScore') {
return media.averageScore + '%'
} else if (detail.property === 'duration') {
return `${media.duration} minutes`
} else if (detail.property === 'season') {
return [media.season?.toLowerCase(), media.seasonYear].filter(f => f).join(' ')
} else if (detail.property === 'episode') {
return `Ep ${media.nextAiringEpisode.episode}: ${countdown(media.nextAiringEpisode.timeUntilAiring)}`
} else {
return media[detail.property]
const detailsMap = [
{ property: 'episode', label: 'Airing', icon: 'schedule', custom: 'property' },
{ property: 'genres', label: 'Genres', icon: 'theater_comedy' },
{ property: 'season', label: 'Season', icon: 'spa', custom: 'property' },
{ property: 'episodes', label: 'Episodes', icon: 'theaters', custom: 'property' },
{ property: 'duration', label: 'Duration', icon: 'timer', custom: 'property' },
{ property: 'format', label: 'Format', icon: 'monitor' },
{ property: 'status', label: 'Status', icon: 'live_tv' },
{ property: 'nodes', label: 'Studio', icon: 'business' },
{ property: 'source', label: 'Source', icon: 'source' },
{ property: 'averageScore', label: 'Rating', icon: 'trending_up', custom: 'property' },
{ property: 'english', label: 'English', icon: 'title' },
{ property: 'romaji', label: 'Romaji', icon: 'translate' },
{ property: 'native', label: 'Native', icon: '語', custom: 'icon' }
]
function getCustomProperty (detail, media) {
if (detail.property === 'episodes') {
if (media.mediaListEntry?.progress) {
return `Watched <b>${media.mediaListEntry.progress}</b> of <b>${getMediaMaxEp(media)}</b>`
}
return `${getMediaMaxEp(media)} Episodes`
} else if (detail.property === 'averageScore') {
return media.averageScore + '%'
} else if (detail.property === 'duration') {
return `${media.duration} minutes`
} else if (detail.property === 'season') {
return [media.season?.toLowerCase(), media.seasonYear].filter(f => f).join(' ')
} else if (detail.property === 'episode') {
return `Ep ${media.nextAiringEpisode.episode}: ${countdown(media.nextAiringEpisode.timeUntilAiring)}`
} else {
return media[detail.property]
}
function getProperty (property, media) {
if (property === 'episode') {
return media.nextAiringEpisode?.episode
} else if (property === 'english' || property === 'romaji' || property === 'native') {
return media.title[property]
}
return media[property]
}
function getProperty (property, media) {
if (property === 'episode') {
return media.nextAiringEpisode?.episode
} else if (property === 'english' || property === 'romaji' || property === 'native') {
return media.title[property]
}
return media[property]
}
</script>
<h1 class="title font-weight-bold text-white">Details</h1>
<div class="card m-0 px-20 py-10 d-flex flex-md-column flex-row overflow-x-auto text-capitalize">
<h1 class='title font-weight-bold text-white'>Details</h1>
<div class='card m-0 px-20 py-10 d-flex flex-md-column flex-row overflow-x-auto text-capitalize'>
{#each detailsMap as detail}
{@const property = getProperty(detail.property, media)}
{#if property}
<div class="d-flex flex-row px-10 py-5">
<div class='d-flex flex-row px-10 py-5'>
<div class={'mr-10 ' + (detail.custom === 'icon' ? 'd-flex align-items-center text-nowrap font-size-20 font-weight-bold' : 'material-icons font-size-24')}>
{detail.icon}
</div>
<div class="d-flex flex-column justify-content-center text-nowrap">
<div class="font-weight-bold">
<div class='d-flex flex-column justify-content-center text-nowrap'>
<div class='font-weight-bold'>
{#if detail.custom === 'property'}
{@html getCustomProperty(detail, media)}
{:else if property.constructor === Array}
@ -70,4 +70,4 @@
</div>
{/if}
{/each}
</div>
</div>

View file

@ -1,35 +1,34 @@
<script>
import { alToken } from '@/lib/pages/Settings.svelte'
import { alRequest } from '@/modules/anilist.js'
export let media = null
let following = null
async function updateFollowing (media) {
if (media) {
following = null
following = (await alRequest({ method: 'Following', id: media.id })).data?.Page?.mediaList
}
}
$: updateFollowing(media)
const statusMap = {
CURRENT: 'Watching',
PLANNING: 'Planning',
COMPLETED: 'Completed',
DROPPED: 'Dropped',
PAUSED: 'Paused',
REPEATING: 'Repeating'
import { alToken } from '@/lib/pages/Settings.svelte'
import { alRequest } from '@/modules/anilist.js'
export let media = null
let following = null
async function updateFollowing (media) {
if (media) {
following = null
following = (await alRequest({ method: 'Following', id: media.id })).data?.Page?.mediaList
}
}
$: updateFollowing(media)
const statusMap = {
CURRENT: 'Watching',
PLANNING: 'Planning',
COMPLETED: 'Completed',
DROPPED: 'Dropped',
PAUSED: 'Paused',
REPEATING: 'Repeating'
}
</script>
{#if following?.length && alToken}
<h2 class="font-weight-bold text-white mt-20">Following</h2>
<div class="card m-0 px-20 pt-15 pb-5 flex-column">
<h2 class='font-weight-bold text-white mt-20'>Following</h2>
<div class='card m-0 px-20 pt-15 pb-5 flex-column'>
{#each following as friend}
<div class="d-flex align-items-center w-full pb-10 px-10">
<img src={friend.user.avatar.medium} alt="avatar" class="w-30 h-30 img-fluid rounded cover-img" />
<span class="my-0 pl-10 mr-auto text-truncate">{friend.user.name}</span>
<span class="my-0 px-10 text-capitalize">{statusMap[friend.status]}</span>
<span class="material-icons pointer text-primary font-size-18" on:click={() => window.IPC.emit('open', 'https://anilist.co/user/' + friend.user.name)}> open_in_new </span>
<div class='d-flex align-items-center w-full pb-10 px-10'>
<img src={friend.user.avatar.medium} alt='avatar' class='w-30 h-30 img-fluid rounded cover-img' />
<span class='my-0 pl-10 mr-auto text-truncate'>{friend.user.name}</span>
<span class='my-0 px-10 text-capitalize'>{statusMap[friend.status]}</span>
<span class='material-icons pointer text-primary font-size-18' on:click={() => window.IPC.emit('open', 'https://anilist.co/user/' + friend.user.name)}> open_in_new </span>
</div>
{/each}
</div>
@ -47,8 +46,8 @@
background-image: var(--dm-button-bg-image) !important;
box-shadow: var(--dm-button-box-shadow) !important;
}
.cover-img {
object-fit: cover;
}
</style>
</style>

View file

@ -1,16 +1,16 @@
<script>
export let list = null
let showMore = false
function toggleList () {
showMore = !showMore
}
export let list = null
let showMore = false
function toggleList () {
showMore = !showMore
}
</script>
{#if list?.length}
<span class="d-flex align-items-end pointer text-decoration-none mt-20 pt-20" on:click={toggleList}>
<h1 class="font-weight-bold text-white">Relations</h1>
<h6 class="ml-auto font-size-12 more text-muted">{showMore ? 'Show Less' : 'Show More'}</h6>
<span class='d-flex align-items-end pointer text-decoration-none mt-20 pt-20' on:click={toggleList}>
<h1 class='font-weight-bold text-white'>Relations</h1>
<h6 class='ml-auto font-size-12 more text-muted'>{showMore ? 'Show Less' : 'Show More'}</h6>
</span>
<div class="d-flex text-capitalize flex-wrap pt-20 justify-center">
<div class='d-flex text-capitalize flex-wrap pt-20 justify-center'>
{#each list.slice(0, showMore ? 100 : 4) as item}
<slot {item} />
{/each}
@ -21,4 +21,4 @@
.more:hover {
color: var(--dm-link-text-color-hover) !important;
}
</style>
</style>

View file

@ -1,32 +1,32 @@
<script>
import { getContext } from 'svelte'
const url = getContext('trailer')
import { getContext } from 'svelte'
const url = getContext('trailer')
let modal
function close () {
$url = null
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $url && modal?.focus()
let modal
function close () {
$url = null
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $url && modal?.focus()
</script>
<div class="modal" class:show={$url} on:keydown={checkClose} tabindex="-1" bind:this={modal}>
<div class='modal' class:show={$url} on:keydown={checkClose} tabindex='-1' bind:this={modal}>
{#if $url}
<div class="modal-dialog" role="document" on:click|self={close}>
<div class="modal-content w-three-quarter h-full bg-transparent d-flex justify-content-center flex-column">
<button class="close pointer z-30 bg-dark shadow-lg top-20 right-0" type="button" on:click={close}>
<div class='modal-dialog' role='document' on:click|self={close}>
<div class='modal-content w-three-quarter h-full bg-transparent d-flex justify-content-center flex-column'>
<button class='close pointer z-30 bg-dark shadow-lg top-20 right-0' type='button' on:click={close}>
×
</button>
<div class="trailer w-full position-relative">
<div class='trailer w-full position-relative'>
<iframe
id="trailerVideo"
id='trailerVideo'
src={'https://www.youtube.com/embed/' + $url}
frameborder="0"
title="trailer"
allowfullscreen="allowfullscreen"
class="w-full h-full position-absolute rounded top-0 left-0" />
frameborder='0'
title='trailer'
allowfullscreen='allowfullscreen'
class='w-full h-full position-absolute rounded top-0 left-0' />
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,302 +1,302 @@
<script context="module">
import { addToast } from '@/lib/Toasts.svelte'
export let alToken = localStorage.getItem('ALtoken') || null
const defaults = {
playerAutoplay: true,
playerPause: true,
playerAutocomplete: true,
rssQuality: '1080',
rssFeeds: [['New Releases', 'SubsPlease']],
rssAutoplay: true,
rssTrusted: true,
rssBatch: false,
torrentSpeed: 10,
torrentPersist: false,
torrentDHT: false,
torrentPeX: false
<script context='module'>
import { addToast } from '@/lib/Toasts.svelte'
export let alToken = localStorage.getItem('ALtoken') || null
const defaults = {
playerAutoplay: true,
playerPause: true,
playerAutocomplete: true,
rssQuality: '1080',
rssFeeds: [['New Releases', 'SubsPlease']],
rssAutoplay: true,
rssTrusted: true,
rssBatch: false,
torrentSpeed: 10,
torrentPersist: false,
torrentDHT: false,
torrentPeX: false
}
localStorage.removeItem('relations') // TODO: remove
export const set = JSON.parse(localStorage.getItem('settings')) || { ...defaults }
if (!set.rssFeeds) { // TODO: remove ;-;
if (set.rssFeed) {
set.rssFeeds = [['New Releases', set.rssFeed]]
} else {
set.rssFeeds = [['New Releases', 'SubsPlease']]
}
localStorage.removeItem('relations') // TODO: remove
export let set = JSON.parse(localStorage.getItem('settings')) || { ...defaults }
if (!set.rssFeeds) { // TODO: remove ;-;
if (set.rssFeed) {
set.rssFeeds = [['New Releases', set.rssFeed]]
} else {
set.rssFeeds = [['New Releases', 'SubsPlease']]
}
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)
}
})
}
}
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)
}
})
}
}
})
window.IPC.on('altoken', handleToken)
function handleToken (data) {
localStorage.setItem('ALtoken', data)
alToken = data
location.reload()
}
export const platformMap = {
aix: 'Aix',
darwin: 'MacOS',
freebsd: 'Linux',
linux: 'Linux',
openbsd: 'Linux',
sunos: 'SunOS',
win32: 'Windows'
}
let version = '1.0.0'
window.IPC.on('version', data => (version = data))
window.IPC.emit('version')
})
window.IPC.on('altoken', handleToken)
function handleToken (data) {
localStorage.setItem('ALtoken', data)
alToken = data
location.reload()
}
export const platformMap = {
aix: 'Aix',
darwin: 'MacOS',
freebsd: 'Linux',
linux: 'Linux',
openbsd: 'Linux',
sunos: 'SunOS',
win32: 'Windows'
}
let version = '1.0.0'
window.IPC.on('version', data => (version = data))
window.IPC.emit('version')
window.IPC.on('update', () => {
addToast({
title: 'Auto Updater',
text: 'A new version of Miru is available. Downloading!'
})
window.IPC.on('update', () => {
addToast({
title: 'Auto Updater',
text: 'A new version of Miru is available. Downloading!'
})
function checkUpdate () {
window.IPC.emit('update')
}
setInterval(checkUpdate, 1200000)
})
function checkUpdate () {
window.IPC.emit('update')
}
setInterval(checkUpdate, 1200000)
</script>
<script>
import { Tabs, TabLabel, Tab } from '../Tabination.js'
import { onDestroy } from 'svelte'
import { Tabs, TabLabel, Tab } from '../Tabination.js'
import { onDestroy } from 'svelte'
onDestroy(() => {
window.IPC.off('path')
})
onDestroy(() => {
window.IPC.off('path')
})
const groups = {
player: {
name: 'Player',
icon: 'play_arrow',
desc: 'Player configuration, playback behavior, and other.'
},
rss: {
name: 'RSS',
icon: 'rss_feed',
desc: 'RSS configuration, URLs, quality, and options.'
},
torrent: {
name: 'Torrent',
icon: 'hub',
desc: 'Torrent client settings, and preferences.'
}
const groups = {
player: {
name: 'Player',
icon: 'play_arrow',
desc: 'Player configuration, playback behavior, and other.'
},
rss: {
name: 'RSS',
icon: 'rss_feed',
desc: 'RSS configuration, URLs, quality, and options.'
},
torrent: {
name: 'Torrent',
icon: 'hub',
desc: 'Torrent client settings, and preferences.'
}
let settings = set
$: saveSettings(settings)
function saveSettings () {
localStorage.setItem('settings', JSON.stringify(settings))
}
function restoreSettings () {
localStorage.removeItem('settings')
settings = { ...defaults }
}
function handleFolder () {
window.IPC.emit('dialog')
}
window.IPC.on('path', data => {
settings.torrentPath = data
})
}
let settings = set
$: saveSettings(settings)
function saveSettings () {
localStorage.setItem('settings', JSON.stringify(settings))
}
function restoreSettings () {
localStorage.removeItem('settings')
settings = { ...defaults }
}
function handleFolder () {
window.IPC.emit('dialog')
}
window.IPC.on('path', data => {
settings.torrentPath = data
})
</script>
<Tabs>
<div class="d-flex w-full h-full">
<div class="d-flex flex-column h-full w-300 bg-dark">
<div class="px-20 py-15 font-size-20 font-weight-semi-bold border-bottom root">Settings</div>
<div class='d-flex w-full h-full'>
<div class='d-flex flex-column h-full w-300 bg-dark'>
<div class='px-20 py-15 font-size-20 font-weight-semi-bold border-bottom root'>Settings</div>
{#each Object.values(groups) as group}
<TabLabel>
<div class="px-20 py-15 d-flex root">
<span class="material-icons font-size-24 pr-10">{group.icon}</span>
<div class='px-20 py-15 d-flex root'>
<span class='material-icons font-size-24 pr-10'>{group.icon}</span>
<div>
<div class="font-weight-bold font-size-16">{group.name}</div>
<div class="font-size-12">{group.desc}</div>
<div class='font-weight-bold font-size-16'>{group.name}</div>
<div class='font-size-12'>{group.desc}</div>
</div>
</div>
</TabLabel>
{/each}
<button
on:click={() => window.IPC.emit('open', 'https://ko-fi.com/thaunknown_')}
class="btn btn-secondary mx-20 mt-auto"
type="button"
data-toggle="tooltip"
data-placement="top"
data-title="Opens The Donate Site">
on:click={() => window.IPC.emit('open', 'https://github.com/sponsors/ThaUnknown/')}
class='btn btn-primary mx-20 mt-auto'
type='button'
data-toggle='tooltip'
data-placement='top'
data-title='Opens The Donate Site'>
Donate
</button>
<button
on:click={checkUpdate}
class="btn btn-primary mx-20 mt-10"
type="button"
data-toggle="tooltip"
data-placement="top"
data-title="Checks For Available Updates And Notifies The User">
class='btn btn-primary mx-20 mt-10'
type='button'
data-toggle='tooltip'
data-placement='top'
data-title='Checks For Available Updates And Notifies The User'>
Check For Updates
</button>
<button
on:click={restoreSettings}
class="btn btn-danger mx-20 mt-10"
type="button"
data-toggle="tooltip"
data-placement="top"
data-title="Restores All Settings Back To Their Recommended Defaults">
class='btn btn-danger mx-20 mt-10'
type='button'
data-toggle='tooltip'
data-placement='top'
data-title='Restores All Settings Back To Their Recommended Defaults'>
Restore Defaults
</button>
<p class="text-muted px-20 py-10 m-0">Restart may be required for some settings to take effect.</p>
<p class="text-muted px-20 pb-10 m-0">If you don't know what shit does, use default settings.</p>
<p class="text-muted px-20 m-0 mb-20">v{version} {platformMap[window.version.platform]} {window.version.arch}</p>
<p class='text-muted px-20 py-10 m-0'>Restart may be required for some settings to take effect.</p>
<p class='text-muted px-20 pb-10 m-0'>If you don't know what shit does, use default settings.</p>
<p class='text-muted px-20 m-0 mb-20'>v{version} {platformMap[window.version.platform]} {window.version.arch}</p>
</div>
<div class="h-full p-20 m-20">
<div class='h-full p-20 m-20'>
<Tab>
<div class="root">
<div class='root'>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Automatically Starts Playing Next Episode When A Video Ends">
<input type="checkbox" id="player-autoplay" bind:checked={settings.playerAutoplay} />
<label for="player-autoplay">Autoplay Next Episode</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Automatically Starts Playing Next Episode When A Video Ends'>
<input type='checkbox' id='player-autoplay' bind:checked={settings.playerAutoplay} />
<label for='player-autoplay'>Autoplay Next Episode</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Pauses/Resumes Video Playback When Tabbing In/Out Of The App">
<input type="checkbox" id="player-pause" bind:checked={settings.playerPause} />
<label for="player-pause">Pause When Tabbing Out</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Pauses/Resumes Video Playback When Tabbing In/Out Of The App'>
<input type='checkbox' id='player-pause' bind:checked={settings.playerPause} />
<label for='player-pause'>Pause When Tabbing Out</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Automatically Marks Episodes As Complete On AniList When You Finish Watching Them, Requires AniList Login">
<input type="checkbox" id="player-autocomplete" bind:checked={settings.playerAutocomplete} />
<label for="player-autocomplete">Autocomplete Episodes</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Automatically Marks Episodes As Complete On AniList When You Finish Watching Them, Requires AniList Login'>
<input type='checkbox' id='player-autocomplete' bind:checked={settings.playerAutocomplete} />
<label for='player-autocomplete'>Autocomplete Episodes</label>
</div>
</div>
</Tab>
<Tab>
<div class="root">
<div class='root'>
{#each settings.rssFeeds as _, i}
<div
class="input-group mb-10 w-700 form-control-lg"
data-toggle="tooltip"
data-placement="bottom"
data-title="What RSS Feed To Fetch Releases From, Allows For Custom CORS Enabled Feeds">
<div class="input-group-prepend">
<span class="input-group-text w-100 justify-content-center">Feed</span>
class='input-group mb-10 w-700 form-control-lg'
data-toggle='tooltip'
data-placement='bottom'
data-title='What RSS Feed To Fetch Releases From, Allows For Custom CORS Enabled Feeds'>
<div class='input-group-prepend'>
<span class='input-group-text w-100 justify-content-center'>Feed</span>
</div>
<input type="text" class="form-control form-control-lg w-150 flex-reset" placeholder='New Releases' autocomplete="off" bind:value={settings.rssFeeds[i][0]} />
<input id="rss-feed-{i}" type="text" list="rss-feed-list-{i}" class="w-400 form-control form-control-lg" placeholder='https://nyaa.si/?page=rss&c=0_0&f=0&q=' autocomplete="off" bind:value={settings.rssFeeds[i][1]} />
<datalist id="rss-feed-list-{i}">
<option value="SubsPlease">https://nyaa.si/?page=rss&c=0_0&f=0&u=subsplease&q=</option>
<option value="Erai-raws [Multi-Sub]">https://nyaa.si/?page=rss&c=0_0&f=0&u=Erai-raws&q=</option>
<option value="NanDesuKa">https://nyaa.si/?page=rss&c=0_0&f=0&u=NanDesuKa&q=</option>
<input type='text' class='form-control form-control-lg w-150 flex-reset' placeholder='New Releases' autocomplete='off' bind:value={settings.rssFeeds[i][0]} />
<input id='rss-feed-{i}' type='text' list='rss-feed-list-{i}' class='w-400 form-control form-control-lg' placeholder='https://nyaa.si/?page=rss&c=0_0&f=0&q=' autocomplete='off' bind:value={settings.rssFeeds[i][1]} />
<datalist id='rss-feed-list-{i}'>
<option value='SubsPlease'>https://nyaa.si/?page=rss&c=0_0&f=0&u=subsplease&q=</option>
<option value='Erai-raws [Multi-Sub]'>https://nyaa.si/?page=rss&c=0_0&f=0&u=Erai-raws&q=</option>
<option value='NanDesuKa'>https://nyaa.si/?page=rss&c=0_0&f=0&u=NanDesuKa&q=</option>
</datalist>
<div class="input-group-append">
<button type="button" on:click={() => { settings.rssFeeds.splice(i, 1); settings.rssFeeds = settings.rssFeeds }} class="btn btn-danger btn-lg input-group-append">Remove</button>
<div class='input-group-append'>
<button type='button' on:click={() => { settings.rssFeeds.splice(i, 1); settings.rssFeeds = settings.rssFeeds }} class='btn btn-danger btn-lg input-group-append'>Remove</button>
</div>
</div>
{/each}
<div class="input-group input-group-lg form-control-lg mb-10 w-500">
<button type="button" on:click={() => { settings.rssFeeds[settings.rssFeeds.length] = ['New Releases', null] }} class="btn btn-lg btn-primary mb-10">Add Feed</button>
<div class='input-group input-group-lg form-control-lg mb-10 w-500'>
<button type='button' on:click={() => { settings.rssFeeds[settings.rssFeeds.length] = ['New Releases', null] }} class='btn btn-lg btn-primary mb-10'>Add Feed</button>
</div>
<div class="input-group mb-10 w-300 form-control-lg" data-toggle="tooltip" data-placement="top" data-title="What Quality To Find Torrents In">
<div class="input-group-prepend">
<span class="input-group-text w-100 justify-content-center">Quality</span>
<div class='input-group mb-10 w-300 form-control-lg' data-toggle='tooltip' data-placement='top' data-title='What Quality To Find Torrents In'>
<div class='input-group-prepend'>
<span class='input-group-text w-100 justify-content-center'>Quality</span>
</div>
<select class="form-control form-control-lg" bind:value={settings.rssQuality}>
<option value="1080" selected>1080p</option>
<option value="720">720p</option>
<option value="480||540">SD</option>
<select class='form-control form-control-lg' bind:value={settings.rssQuality}>
<option value='1080' selected>1080p</option>
<option value='720'>720p</option>
<option value='480||540'>SD</option>
<option value="">None</option>
</select>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Skips The Torrent Selection Popup, Might Lead To Unwanted Videos Being
Played">
<input type="checkbox" id="rss-autoplay" bind:checked={settings.rssAutoplay} />
<label for="rss-autoplay">Auto-Play Torrents</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Skips The Torrent Selection Popup, Might Lead To Unwanted Videos Being
Played'>
<input type='checkbox' id='rss-autoplay' bind:checked={settings.rssAutoplay} />
<label for='rss-autoplay'>Auto-Play Torrents</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Finds Only Trusted Torrents, Gives Less Results But Higher Quality And With More Seeders">
<input type="checkbox" id="rss-trusted" bind:checked={settings.rssTrusted} />
<label for="rss-trusted">Trusted Only</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Finds Only Trusted Torrents, Gives Less Results But Higher Quality And With More Seeders'>
<input type='checkbox' id='rss-trusted' bind:checked={settings.rssTrusted} />
<label for='rss-trusted'>Trusted Only</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Tries To Find Batches For Finished Shows Instead Of Downloading 1 Episode At A Time">
<input type="checkbox" id="rss-batch" bind:checked={settings.rssBatch} />
<label for="rss-batch">Batch Lookup</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Tries To Find Batches For Finished Shows Instead Of Downloading 1 Episode At A Time'>
<input type='checkbox' id='rss-batch' bind:checked={settings.rssBatch} />
<label for='rss-batch'>Batch Lookup</label>
</div>
</div>
</Tab>
<Tab>
<div class="root">
<div class='root'>
<div
class="input-group input-group-lg form-control-lg mb-10 w-500"
data-toggle="tooltip"
data-placement="bottom"
data-title="Path To Folder Which To Use To Store Torrent Files">
<div class="input-group-prepend">
<button type="button" on:click={handleFolder} class="btn btn-primary input-group-append">Select Folder</button>
class='input-group input-group-lg form-control-lg mb-10 w-500'
data-toggle='tooltip'
data-placement='bottom'
data-title='Path To Folder Which To Use To Store Torrent Files'>
<div class='input-group-prepend'>
<button type='button' on:click={handleFolder} class='btn btn-primary input-group-append'>Select Folder</button>
</div>
<input type="text" class="form-control" bind:value={settings.torrentPath} placeholder="Folder Path" />
<input type='text' class='form-control' bind:value={settings.torrentPath} placeholder='Folder Path' />
</div>
<div
class="input-group w-300 form-control-lg mb-10"
data-toggle="tooltip"
data-placement="bottom"
data-title="Download/Upload Speed Limit For Torrents, Higher Values Increase CPU Usage">
<div class="input-group-prepend">
<span class="input-group-text w-150 justify-content-center">Max Speed</span>
class='input-group w-300 form-control-lg mb-10'
data-toggle='tooltip'
data-placement='bottom'
data-title='Download/Upload Speed Limit For Torrents, Higher Values Increase CPU Usage'>
<div class='input-group-prepend'>
<span class='input-group-text w-150 justify-content-center'>Max Speed</span>
</div>
<input type="number" bind:value={settings.torrentSpeed} min="0" max="50" class="form-control text-right form-control-lg" />
<div class="input-group-append">
<span class="input-group-text">MB/s</span>
<input type='number' bind:value={settings.torrentSpeed} min='0' max='50' class='form-control text-right form-control-lg' />
<div class='input-group-append'>
<span class='input-group-text'>MB/s</span>
</div>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title="Doesn't Delete Files Of Old Torrents When A New Torrent Is Played">
<input type="checkbox" id="torrent-persist" bind:checked={settings.torrentPersist} />
<label for="torrent-persist">Persist Files</label>
<input type='checkbox' id='torrent-persist' bind:checked={settings.torrentPersist} />
<label for='torrent-persist'>Persist Files</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Disables Distributed Hash Tables For Use In Private Trackers To Improve Piracy">
<input type="checkbox" id="torrent-dht" bind:checked={settings.torrentDHT} />
<label for="torrent-dht">Disable DHT</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Disables Distributed Hash Tables For Use In Private Trackers To Improve Piracy'>
<input type='checkbox' id='torrent-dht' bind:checked={settings.torrentDHT} />
<label for='torrent-dht'>Disable DHT</label>
</div>
<div
class="custom-switch mb-10 pl-10 font-size-16 w-300"
data-toggle="tooltip"
data-placement="bottom"
data-title="Disables Peer Exchange For Use In Private Trackers To Improve Piracy">
<input type="checkbox" id="torrent-pex" bind:checked={settings.torrentPeX} />
<label for="torrent-pex">Disable PeX</label>
class='custom-switch mb-10 pl-10 font-size-16 w-300'
data-toggle='tooltip'
data-placement='bottom'
data-title='Disables Peer Exchange For Use In Private Trackers To Improve Piracy'>
<input type='checkbox' id='torrent-pex' bind:checked={settings.torrentPeX} />
<label for='torrent-pex'>Disable PeX</label>
</div>
</div>
</Tab>
@ -308,4 +308,4 @@
select.form-control:invalid {
color: var(--dm-input-placeholder-text-color);
}
</style>
</style>

View file

@ -1,23 +1,23 @@
<script>
import { countdown } from '@/modules/util.js'
import { getContext } from 'svelte'
export let cards = new Promise(() => {})
const view = getContext('view')
function viewMedia (media) {
$view = media
}
export let length = 5
import { countdown } from '@/modules/util.js'
import { getContext } from 'svelte'
export let cards = new Promise(() => {})
const view = getContext('view')
function viewMedia (media) {
$view = media
}
export let length = 5
</script>
{#await cards}
{#each Array(length) as _}
<div class="card m-0 p-0">
<div class="row h-full">
<div class="col-4 skeloader" />
<div class="col-8 bg-very-dark px-15 py-10">
<p class="skeloader w-300 h-25 rounded bg-dark" />
<p class="skeloader w-150 h-10 rounded bg-dark" />
<p class="skeloader w-150 h-10 rounded bg-dark" />
<div class='card m-0 p-0'>
<div class='row h-full'>
<div class='col-4 skeloader' />
<div class='col-8 bg-very-dark px-15 py-10'>
<p class='skeloader w-300 h-25 rounded bg-dark' />
<p class='skeloader w-150 h-10 rounded bg-dark' />
<p class='skeloader w-150 h-10 rounded bg-dark' />
</div>
</div>
</div>
@ -25,38 +25,38 @@
{:then cards}
{#each cards || [] as card}
{#if typeof card === 'string'}
<div class="day-row font-size-24 font-weight-bold h-50 d-flex align-items-end">{card}</div>
<div class='day-row font-size-24 font-weight-bold h-50 d-flex align-items-end'>{card}</div>
{:else if !card.media}
<div class="card m-0 p-0" on:click={card.onclick}>
<div class="row h-full">
<div class="col-4 skeloader" />
<div class="col-8 bg-very-dark px-15 py-10">
<h5 class="m-0 text-capitalize font-weight-bold pb-10">{[card.parseObject.anime_title, card.parseObject.episode_number].filter(s => s).join(' - ')}</h5>
<p class="skeloader w-150 h-10 rounded bg-dark" />
<p class="skeloader w-150 h-10 rounded bg-dark" />
<div class='card m-0 p-0' on:click={card.onclick}>
<div class='row h-full'>
<div class='col-4 skeloader' />
<div class='col-8 bg-very-dark px-15 py-10'>
<h5 class='m-0 text-capitalize font-weight-bold pb-10'>{[card.parseObject.anime_title, card.parseObject.episode_number].filter(s => s).join(' - ')}</h5>
<p class='skeloader w-150 h-10 rounded bg-dark' />
<p class='skeloader w-150 h-10 rounded bg-dark' />
</div>
</div>
</div>
{:else}
<div class="card m-0 p-0" on:click={card.onclick || (() => viewMedia(card.media))} style:--color={card.media.coverImage.color || '#1890ff'}>
<div class="row h-full">
<div class="col-4">
<img loading="lazy" src={card.media.coverImage.extraLarge || ''} alt="cover" class="cover-img w-full h-full" />
<div class='card m-0 p-0' on:click={card.onclick || (() => viewMedia(card.media))} style:--color={card.media.coverImage.color || '#1890ff'}>
<div class='row h-full'>
<div class='col-4'>
<img loading='lazy' src={card.media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full h-full' />
</div>
<div class="col-8 h-full card-grid">
<div class="px-15 py-10 bg-very-dark">
<h5 class="m-0 text-capitalize font-weight-bold">
<div class='col-8 h-full card-grid'>
<div class='px-15 py-10 bg-very-dark'>
<h5 class='m-0 text-capitalize font-weight-bold'>
{#if card.failed}
<span class="badge badge-secondary">Uncertain</span>
<span class='badge badge-secondary'>Uncertain</span>
{/if}
{[card.media.title.userPreferred, card.episodeNumber].filter(s => s).join(' - ')}
</h5>
{#if card.schedule && card.media.nextAiringEpisode}
<span class="text-muted font-weight-bold">
<span class='text-muted font-weight-bold'>
{'EP ' + card.media.nextAiringEpisode.episode + ' in ' + countdown(card.media.nextAiringEpisode.timeUntilAiring)}
</span>
{/if}
<p class="text-muted m-0 text-capitalize details">
<p class='text-muted m-0 text-capitalize details'>
{#if card.media.format === 'TV'}
<span>TV Show</span>
{:else if card.media.format}
@ -75,12 +75,12 @@
</span>
</p>
</div>
<div class="overflow-y-auto px-15 pb-5 bg-very-dark card-desc">
<div class='overflow-y-auto px-15 pb-5 bg-very-dark card-desc'>
{@html card.media.description}
</div>
<div class="px-15 pb-10 pt-5 genres">
<div class='px-15 pb-10 pt-5 genres'>
{#each card.media.genres as genre}
<span class="badge badge-pill badge-color text-dark mt-5 mr-5 font-weight-bold">{genre}</span>
<span class='badge badge-pill badge-color text-dark mt-5 mr-5 font-weight-bold'>{genre}</span>
{/each}
</div>
</div>
@ -88,8 +88,8 @@
</div>
{/if}
{:else}
<div class="empty d-flex flex-column align-items-center justify-content-center">
<h2 class="font-weight-semi-bold mb-10">Ooops!</h2>
<div class='empty d-flex flex-column align-items-center justify-content-center'>
<h2 class='font-weight-semi-bold mb-10'>Ooops!</h2>
<div>Looks like there's nothing here.</div>
</div>
{/each}

View file

@ -1,18 +1,18 @@
<script>
import Cards from './Cards.svelte'
import Cards from './Cards.svelte'
export let media
$: update(media)
let loading = true
async function update(med) {
loading = true
const index = med.length - 1
await med[index]
if (med[index] === media[media.length - 1]) loading = false
}
export let media
$: update(media)
let loading = true
async function update (med) {
loading = true
const index = med.length - 1
await med[index]
if (med[index] === media[media.length - 1]) loading = false
}
</script>
<div class="gallery browse" class:loading>
<div class='gallery browse' class:loading>
{#each media as cards, i (i)}
<Cards {cards} length={4} />
{/each}

View file

@ -1,286 +1,310 @@
<script context='module'>
import { readable } from 'svelte/store'
const noop = () => {}
async function getVal (set) {
const res = await fetch('https://gh.miru.workers.dev/')
const amt = await res.text() / 100
set(amt.toFixed(2))
}
const progress = readable(0, set => {
getVal(set)
return noop
})
</script>
<script>
import Search from './Search.svelte'
import Section from './Section.svelte'
import Gallery from './Gallery.svelte'
import { add } from '@/modules/torrent.js'
import { alToken, set } from '../Settings.svelte'
import { alRequest } from '@/modules/anilist.js'
import { resolveFileMedia } from '@/modules/anime.js'
import { getRSSContent, getReleasesRSSurl } from '@/lib/RSSView.svelte'
import Search from './Search.svelte'
import Section from './Section.svelte'
import Gallery from './Gallery.svelte'
import { add } from '@/modules/torrent.js'
import { alToken, set } from '../Settings.svelte'
import { alRequest } from '@/modules/anilist.js'
import { resolveFileMedia } from '@/modules/anime.js'
import { getRSSContent, getReleasesRSSurl } from '@/lib/RSSView.svelte'
let media = []
let search = {}
export let current = null
let page = 1
let media = []
let search = {}
export let current = null
let page = 1
let canScroll = true
let hasNext = true
let container = null
let canScroll = true
let hasNext = true
let container = null
function sanitiseObject (object) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
function sanitiseObject (object) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
function customFilter (mediaList) {
return mediaList?.filter(({ media }) => {
let condition = true
if (!media) return condition
if (search.genre && !media.genres?.includes(search.genre)) condition = false
if (search.season && media.season !== search.season) condition = false
if (search.year && media.seasonYear !== search.year) condition = false
if (search.format && media.format !== search.format) condition = false
if (search.status && media.status !== search.status) condition = false
if (search.search) {
const titles = Object.values(media.title)
.concat(media.synonyms)
.filter(name => name != null)
.map(title => title.toLowerCase())
if (!titles.find(title => title.includes(search.search.toLowerCase()))) condition = false
}
return safe
}
function customFilter (mediaList) {
return mediaList?.filter(({ media }) => {
let condition = true
if (!media) return condition
if (search.genre && !media.genres?.includes(search.genre)) condition = false
if (search.season && media.season !== search.season) condition = false
if (search.year && media.seasonYear !== search.year) condition = false
if (search.format && media.format !== search.format) condition = false
if (search.status && media.status !== search.status) condition = false
if (search.search) {
const titles = Object.values(media.title)
.concat(media.synonyms)
.filter(name => name != null)
.map(title => title.toLowerCase())
if (!titles.find(title => title.includes(search.search.toLowerCase()))) condition = false
}
return condition
})
}
async function infiniteScroll () {
if (current && canScroll && hasNext && this.scrollTop + this.clientHeight > this.scrollHeight - 800) {
canScroll = false
const res = sections[current].load(++page)
media.push(res)
media = media
await res
canScroll = hasNext
}
}
async function loadCurrent (initial = true) {
page = 1
return condition
})
}
async function infiniteScroll () {
if (current && canScroll && hasNext && this.scrollTop + this.clientHeight > this.scrollHeight - 800) {
canScroll = false
const res = sections[current].load(1, 50, initial)
media = [res]
const res = sections[current].load(++page)
media.push(res)
media = media
await res
canScroll = hasNext
}
}
$: load(current)
async function load (current) {
if (sections[current]) {
loadCurrent()
} else {
if (container) container.scrollTop = 0
media = []
canScroll = true
lastDate = null
search = {
format: '',
genre: '',
season: '',
sort: '',
status: ''
}
async function loadCurrent (initial = true) {
page = 1
canScroll = false
const res = sections[current].load(1, 50, initial)
media = [res]
await res
canScroll = hasNext
}
$: load(current)
async function load (current) {
if (sections[current]) {
loadCurrent()
} else {
if (container) container.scrollTop = 0
media = []
canScroll = true
lastDate = null
search = {
format: '',
genre: '',
season: '',
sort: '',
status: ''
}
}
}
let lastDate = null
let lastDate = null
function processMedia (res) {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return res?.data?.Page.media.map(media => {
return { media }
})
}
function processMedia (res) {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return res?.data?.Page.media.map(media => {
return { media }
})
}
let lastRSSDate = 0
async function releasesCards (page, limit, force, val) {
const doc = await getRSSContent(getReleasesRSSurl(val))
if (doc) {
const pubDate = doc.querySelector('pubDate').textContent
if (force || lastRSSDate !== pubDate) {
lastRSSDate = pubDate
const index = (page - 1) * limit
const items = [...doc.querySelectorAll('item')].slice(index, index + limit)
hasNext = items.length === limit
const media = await resolveFileMedia({ fileName: items.map(item => item.querySelector('title').textContent), isRelease: true })
media.forEach((mediaInformation, index) => {
mediaInformation.onclick = () => {
add(items[index].querySelector('link').textContent)
}
})
media.hasNext = hasNext
return media
}
}
}
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
const getSeason = d => seasons[Math.floor((d.getMonth() / 12) * 4) % 4]
let sections = {
continue: {
title: 'Continue Watching',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'UPDATED_TIME_DESC'
return alRequest({ method: 'UserLists', status_in: 'CURRENT', page }).then(res => {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return customFilter(
res?.data?.Page.mediaList
.filter(i => {
return i.media.status !== 'RELEASING' || i.media.mediaListEntry?.progress < i.media.nextAiringEpisode?.episode - 1
})
.slice(0, perPage)
)
})
},
hide: !alToken
},
planning: {
title: 'Your List',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'UPDATED_TIME_DESC'
return alRequest({ method: 'UserLists', page, perPage, status_in: 'PLANNING' }).then(res => {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return customFilter(res?.data?.Page.mediaList)
})
},
hide: !alToken
},
trending: {
title: 'Trending Now',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'TRENDING_DESC'
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
seasonal: {
title: 'Popular This Season',
load: (page = 1, perPage = 50, initial = false) => {
const date = new Date()
if (initial) {
search.season = getSeason(date)
search.year = date.getFullYear()
search.sort = 'POPULARITY_DESC'
let lastRSSDate = 0
async function releasesCards (page, limit, force, val) {
const doc = await getRSSContent(getReleasesRSSurl(val))
if (doc) {
const pubDate = doc.querySelector('pubDate').textContent
if (force || lastRSSDate !== pubDate) {
lastRSSDate = pubDate
const index = (page - 1) * limit
const items = [...doc.querySelectorAll('item')].slice(index, index + limit)
hasNext = items.length === limit
const media = await resolveFileMedia({ fileName: items.map(item => item.querySelector('title').textContent), isRelease: true })
media.forEach((mediaInformation, index) => {
mediaInformation.onclick = () => {
add(items[index].querySelector('link').textContent)
}
return alRequest({ method: 'Search', page, perPage, year: date.getFullYear(), season: getSeason(date), sort: 'POPULARITY_DESC', ...sanitiseObject(search) }).then(res =>
processMedia(res)
})
media.hasNext = hasNext
return media
}
}
}
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
const getSeason = d => seasons[Math.floor((d.getMonth() / 12) * 4) % 4]
let sections = {
continue: {
title: 'Continue Watching',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'UPDATED_TIME_DESC'
return alRequest({ method: 'UserLists', status_in: 'CURRENT', page }).then(res => {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return customFilter(
res?.data?.Page.mediaList
.filter(i => {
return i.media.status !== 'RELEASING' || i.media.mediaListEntry?.progress < i.media.nextAiringEpisode?.episode - 1
})
.slice(0, perPage)
)
}
})
},
popular: {
title: 'All Time Popular',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'POPULARITY_DESC'
return alRequest({ method: 'Search', page, perPage, sort: 'POPULARITY_DESC', ...sanitiseObject(search) }).then(res => processMedia(res))
}
hide: !alToken
},
planning: {
title: 'Your List',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'UPDATED_TIME_DESC'
return alRequest({ method: 'UserLists', page, perPage, status_in: 'PLANNING' }).then(res => {
hasNext = res?.data?.Page.pageInfo.hasNextPage
return customFilter(res?.data?.Page.mediaList)
})
},
romance: {
title: 'Romance',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Romance'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Romance', ...sanitiseObject(search) }).then(res => processMedia(res))
hide: !alToken
},
trending: {
title: 'Trending Now',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'TRENDING_DESC'
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
seasonal: {
title: 'Popular This Season',
load: (page = 1, perPage = 50, initial = false) => {
const date = new Date()
if (initial) {
search.season = getSeason(date)
search.year = date.getFullYear()
search.sort = 'POPULARITY_DESC'
}
},
action: {
title: 'Action',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Action'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Action', ...sanitiseObject(search) }).then(res => processMedia(res))
return alRequest({ method: 'Search', page, perPage, year: date.getFullYear(), season: getSeason(date), sort: 'POPULARITY_DESC', ...sanitiseObject(search) }).then(res =>
processMedia(res)
)
}
},
popular: {
title: 'All Time Popular',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.sort = 'POPULARITY_DESC'
return alRequest({ method: 'Search', page, perPage, sort: 'POPULARITY_DESC', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
romance: {
title: 'Romance',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Romance'
}
},
adventure: {
title: 'Adventure',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Adventure'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Adventure', ...sanitiseObject(search) }).then(res => processMedia(res))
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Romance', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
action: {
title: 'Action',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Action'
}
},
fantasy: {
title: 'Fantasy',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Fantasy'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Fantasy', ...sanitiseObject(search) }).then(res => processMedia(res))
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Action', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
adventure: {
title: 'Adventure',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Adventure'
}
},
comedy: {
title: 'Comedy',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Comedy'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Comedy', ...sanitiseObject(search) }).then(res => processMedia(res))
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Adventure', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
fantasy: {
title: 'Fantasy',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Fantasy'
}
},
schedule: {
title: 'Schedule',
hide: true,
load: (page = 1, perPage = 50, initial = false) => {
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Fantasy', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
comedy: {
title: 'Comedy',
load: (page = 1, perPage = 50, initial = false) => {
if (initial) {
search.sort = 'TRENDING_DESC'
search.genre = 'Comedy'
}
return alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Comedy', ...sanitiseObject(search) }).then(res => processMedia(res))
}
},
schedule: {
title: 'Schedule',
hide: true,
load: (page = 1, perPage = 50, initial = false) => {
const date = new Date()
if (initial) {
search.sort = 'START_DATE_DESC'
search.status = 'RELEASING'
}
if (perPage !== 6) date.setHours(0, 0, 0, 0)
return alRequest({ method: 'AiringSchedule', page, from: parseInt(date.getTime() / 1000) }).then(res => {
const entries = customFilter(res?.data?.Page.airingSchedules.filter(entry => entry.media.countryOfOrigin !== 'CN' && !entry.media.isAdult) || []).slice(0, perPage)
const media = []
hasNext = res?.data?.Page.pageInfo.hasNextPage
const date = new Date()
if (initial) {
search.sort = 'START_DATE_DESC'
search.status = 'RELEASING'
}
if (perPage !== 6) date.setHours(0, 0, 0, 0)
return alRequest({ method: 'AiringSchedule', page, from: parseInt(date.getTime() / 1000) }).then(res => {
const entries = customFilter(res?.data?.Page.airingSchedules.filter(entry => entry.media.countryOfOrigin !== 'CN' && !entry.media.isAdult) || []).slice(0, perPage)
const media = []
hasNext = res?.data?.Page.pageInfo.hasNextPage
const date = new Date()
for (const entry of entries) {
if (entry.timeUntilAiring && perPage !== 6 && (!lastDate || new Date(+date + entry.timeUntilAiring * 1000).getDay() !== lastDate.getDay())) {
lastDate = new Date(+date + entry.timeUntilAiring * 1000)
media.push(lastDate.toLocaleDateString('en-US', { weekday: 'long' }))
}
entry.schedule = true
media.push(entry)
for (const entry of entries) {
if (entry.timeUntilAiring && perPage !== 6 && (!lastDate || new Date(+date + entry.timeUntilAiring * 1000).getDay() !== lastDate.getDay())) {
lastDate = new Date(+date + entry.timeUntilAiring * 1000)
media.push(lastDate.toLocaleDateString('en-US', { weekday: 'long' }))
}
return media
})
entry.schedule = true
media.push(entry)
}
return media
})
}
},
search: {
title: 'Search',
hide: true,
load: (page = 1, perPage = 50) => {
const opts = {
method: 'Search',
page,
perPage,
...sanitiseObject(search)
}
return alRequest(opts).then(res => processMedia(res))
}
}
}
for (let i = 0; i < set.rssFeeds.length; ++i) {
const [title, val] = set.rssFeeds[i]
sections = {
['releases-' + i]: {
title,
releases: true,
load: async (page = 1, perPage = 20, initial = false, force = true) => {
if (initial) search.sort = 'START_DATE_DESC'
return customFilter(await releasesCards(page, perPage, force, val))
}
},
search: {
title: 'Search',
hide: true,
load: (page = 1, perPage = 50) => {
const opts = {
method: 'Search',
page,
perPage,
...sanitiseObject(search)
}
return alRequest(opts).then(res => processMedia(res))
}
}
}
for (let i = 0; i < set.rssFeeds.length; ++i) {
const [title, val] = set.rssFeeds[i]
sections = {
['releases-' + i]: {
title,
releases: true,
load: async (page = 1, perPage = 20, initial = false, force = true) => {
if (initial) search.sort = 'START_DATE_DESC'
return customFilter(await releasesCards(page, perPage, force, val))
}
},
...sections
}
...sections
}
}
</script>
<div class="d-flex h-full flex-column overflow-y-scroll root" on:scroll={infiniteScroll} bind:this={container}>
<div class="h-full py-10">
<div class='d-flex h-full flex-column overflow-y-scroll root' on:scroll={infiniteScroll} bind:this={container}>
<div class='h-full py-10'>
<Search bind:media bind:search bind:current {loadCurrent} />
<div class='container'>
We're ${100 - $progress} short of our monthly goal! That's only {Math.ceil(20 / $progress)} people donating $5.00!
<div class='progress-group py-5'>
<div class='progress'>
<div class='progress-bar progress-bar-animated' role='progressbar' style='width: {$progress}%;'></div>
</div>
<span class='progress-group-label'>${$progress} / $100.00</span>
</div>
<button class='btn btn-primary' type='button' on:click={() => { window.IPC.emit('open', 'https://github.com/sponsors/ThaUnknown/') }}>Donate</button>
</div>
{#if media.length}
<Gallery {media} />
{:else}

View file

@ -1,57 +1,57 @@
<script>
import { getContext } from 'svelte'
import { getContext } from 'svelte'
export let search
export let current
export let media = null
export let loadCurrent
let searchTimeout = null
let searchTextInput
export let search
export let current
export let media = null
export let loadCurrent
let searchTimeout = null
let searchTextInput
const view = getContext('view')
const view = getContext('view')
$: !$view && searchTextInput?.focus()
function searchClear () {
search = {
format: '',
genre: '',
season: '',
sort: '',
status: ''
}
current = null
searchTextInput?.focus()
$: !$view && searchTextInput?.focus()
function searchClear () {
search = {
format: '',
genre: '',
season: '',
sort: '',
status: ''
}
function input () {
if (!searchTimeout) {
if (Object.values(search).filter(v => v).length) media = [new Promise(() => {})]
current = null
searchTextInput?.focus()
}
function input () {
if (!searchTimeout) {
if (Object.values(search).filter(v => v).length) media = [new Promise(() => {})]
} else {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
if (current === null) {
if (Object.values(search).filter(v => v).length) current = 'search'
} else {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
if (current === null) {
if (Object.values(search).filter(v => v).length) current = 'search'
if (Object.values(search).filter(v => v).length) {
loadCurrent(false)
} else {
if (Object.values(search).filter(v => v).length) {
loadCurrent(false)
} else {
current = null
}
current = null
}
searchTimeout = null
}, 500)
}
}
searchTimeout = null
}, 500)
}
</script>
<div class="container-fluid row p-20" on:input={input}>
<div class="col-lg col-4 p-10 d-flex flex-column justify-content-end">
<div class="pb-10 font-size-24 font-weight-semi-bold d-flex">
<div class="material-icons mr-10 font-size-30">title</div>
<div class='container-fluid row p-20' on:input={input}>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<div class='material-icons mr-10 font-size-30'>title</div>
Title
</div>
<div class="input-group shadow-lg">
<div class="input-group-prepend">
<span class="input-group-text d-flex material-icons bg-dark pr-0 font-size-18">search</span>
<div class='input-group shadow-lg'>
<div class='input-group-prepend'>
<span class='input-group-text d-flex material-icons bg-dark pr-0 font-size-18'>search</span>
</div>
<!-- svelte-ignore missing-declaration -->
<!-- svelte-ignore a11y-autofocus -->
@ -64,56 +64,56 @@
}}
bind:this={searchTextInput}
autofocus
type="search"
class="form-control bg-dark border-left-0 shadow-none text-capitalize"
autocomplete="off"
type='search'
class='form-control bg-dark border-left-0 shadow-none text-capitalize'
autocomplete='off'
bind:value={search.search}
data-option="search"
placeholder="Any" />
data-option='search'
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">
<div class="material-icons mr-10 font-size-30">theater_comedy</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'>
<div class='material-icons mr-10 font-size-30'>theater_comedy</div>
Genre
</div>
<select class="form-control bg-dark shadow-lg" required bind:value={search.genre}>
<select class='form-control bg-dark shadow-lg' required bind:value={search.genre}>
<option value selected disabled hidden>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>
<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>
</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">
<div class="material-icons mr-10 font-size-30">spa</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'>
<div class='material-icons mr-10 font-size-30'>spa</div>
Season
</div>
<div class="input-group shadow-lg">
<select class="form-control bg-dark shadow-none border-right-dark" required bind:value={search.season}>
<div class='input-group shadow-lg'>
<select class='form-control bg-dark shadow-none border-right-dark' required bind:value={search.season}>
<option value selected disabled hidden>Any</option>
<option value="WINTER">Winter</option>
<option value="SPRING">Spring</option>
<option value="SUMMER">Summer</option>
<option value="FALL">Fall</option>
<option value='WINTER'>Winter</option>
<option value='SPRING'>Spring</option>
<option value='SUMMER'>Summer</option>
<option value='FALL'>Fall</option>
</select>
<input type="number" placeholder="Any" min="1940" max="2100" list="search-year" class="form-control bg-dark shadow-none" bind:value={search.year} />
<datalist id="search-year">
<input type='number' placeholder='Any' min='1940' max='2100' list='search-year' class='form-control bg-dark shadow-none' bind:value={search.year} />
<datalist id='search-year'>
{#each Array(new Date().getFullYear() - 1940 + 2) as _, i}
{@const year = new Date().getFullYear() + 2 - i}
<option>{year}</option>
@ -121,49 +121,49 @@
</datalist>
</div>
</div>
<div class="col p-10 d-flex flex-column justify-content-end">
<div class="pb-10 font-size-24 font-weight-semi-bold d-flex">
<div class="material-icons mr-10 font-size-30">monitor</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<div class='material-icons mr-10 font-size-30'>monitor</div>
Format
</div>
<select class="form-control bg-dark shadow-lg" required bind:value={search.format}>
<select class='form-control bg-dark shadow-lg' required bind:value={search.format}>
<option value selected disabled hidden>Any</option>
<option value="TV">TV Show</option>
<option value="MOVIE">Movie</option>
<option value="TV_SHORT">TV Short</option>
<option value="OVA">OVA</option>
<option value="ONA">ONA</option>
<option value='TV'>TV Show</option>
<option value='MOVIE'>Movie</option>
<option value='TV_SHORT'>TV Short</option>
<option value='OVA'>OVA</option>
<option value='ONA'>ONA</option>
</select>
</div>
<div class="col p-10 d-flex flex-column justify-content-end">
<div class="pb-10 font-size-24 font-weight-semi-bold d-flex">
<div class="material-icons mr-10 font-size-30">live_tv</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<div class='material-icons mr-10 font-size-30'>live_tv</div>
Status
</div>
<select class="form-control bg-dark shadow-lg" required bind:value={search.status}>
<select class='form-control bg-dark shadow-lg' required bind:value={search.status}>
<option value selected disabled hidden>Any</option>
<option value="RELEASING">Airing</option>
<option value="FINISHED">Finished</option>
<option value="NOT_YET_RELEASED">Not Yet Aired</option>
<option value="CANCELLED">Cancelled</option>
<option value='RELEASING'>Airing</option>
<option value='FINISHED'>Finished</option>
<option value='NOT_YET_RELEASED'>Not Yet Aired</option>
<option value='CANCELLED'>Cancelled</option>
</select>
</div>
<div class="col p-10 d-flex flex-column justify-content-end">
<div class="pb-10 font-size-24 font-weight-semi-bold d-flex">
<div class="material-icons mr-10 font-size-30">sort</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<div class='material-icons mr-10 font-size-30'>sort</div>
Sort
</div>
<select class="form-control bg-dark shadow-lg" required bind:value={search.sort}>
<select class='form-control bg-dark shadow-lg' required bind:value={search.sort}>
<option value selected disabled hidden>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_TIME_DESC" disabled hidden>Updated Date</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_TIME_DESC' disabled hidden>Updated Date</option>
</select>
</div>
<div class="col-auto p-10 d-flex">
<button class="btn bg-dark material-icons font-size-18 px-5 align-self-end shadow-lg border-0" type="button" on:click={searchClear} class:text-primary={!!current}>
<div class='col-auto p-10 d-flex'>
<button class='btn bg-dark material-icons font-size-18 px-5 align-self-end shadow-lg border-0' type='button' on:click={searchClear} class:text-primary={!!current}>
delete
</button>
</div>

View file

@ -1,26 +1,26 @@
<script>
import { onDestroy } from 'svelte'
import { onDestroy } from 'svelte'
import Cards from './Cards.svelte'
export let opts
let cards = opts.load(1, 6)
let interval = null
if (opts.releases) {
interval = setInterval(async () => {
const media = await opts.load(1, 6, false, false)
if (media) cards = media
}, 30000)
}
onDestroy(() => {
if (interval) clearInterval(interval)
})
import Cards from './Cards.svelte'
export let opts
let cards = opts.load(1, 6)
let interval = null
if (opts.releases) {
interval = setInterval(async () => {
const media = await opts.load(1, 6, false, false)
if (media) cards = media
}, 30000)
}
onDestroy(() => {
if (interval) clearInterval(interval)
})
</script>
<span class="d-flex px-20 align-items-end pointer text-decoration-none text-muted" on:click={opts.onclick}>
<div class="pl-10 font-size-24 font-weight-semi-bold">{opts.title}</div>
<div class="pr-10 ml-auto font-size-12">View More</div>
<span class='d-flex px-20 align-items-end pointer text-decoration-none text-muted' on:click={opts.onclick}>
<div class='pl-10 font-size-24 font-weight-semi-bold'>{opts.title}</div>
<div class='pr-10 ml-auto font-size-12'>View More</div>
</span>
<div class="gallery pt-10 pb-20 w-full overflow-x-hidden position-relative">
<div class='gallery pt-10 pb-20 w-full overflow-x-hidden position-relative'>
<Cards {cards} />
</div>

View file

@ -1,91 +1,91 @@
<script>
import { handleCode } from './WatchTogether.svelte'
export let state
export let peer
export let cancel
import { handleCode } from './WatchTogether.svelte'
export let state
export let peer
export let cancel
let values = []
let code = ''
let values = []
let code = ''
peer.pc.addEventListener('signalingstatechange', () => {
console.log(peer.pc.signalingState)
if (peer.pc.signalingState === 'have-remote-offer') {
value = null
index = index + 1
}
})
peer.signalingPort.onmessage = ({ data }) => {
// console.log(data)
const { description, candidate } = typeof data === 'string' ? JSON.parse(data) : data
if (description) {
if (description.type === 'answer') values = []
values.push(description.sdp)
} else if (candidate) {
if (candidate.candidate.includes('srflx')) values.push(candidate.candidate)
}
if (values.length > 1) {
code = btoa(JSON.stringify(values))
}
}
$: value = (step?.mode === 'copy' && code) || null
function handleInput ({ target }) {
handleCode(target.value)
peer.pc.addEventListener('signalingstatechange', () => {
console.log(peer.pc.signalingState)
if (peer.pc.signalingState === 'have-remote-offer') {
value = null
}
function copyData () {
navigator.clipboard.writeText(`<miru://w2g/${state === 'host' ? 'invite' : 'join'}/${value}>`)
index = index + 1
}
})
let index = 0
const map = {
guest: [
{
title: 'Paste Invite Code',
description: 'Paste the one time invite code sent to you by the lobby host.',
mode: 'paste'
},
{
title: 'Copy Invite Confirmation',
description: 'Send the host this code, which required to request a connection.',
mode: 'copy'
}
],
host: [
{
title: 'Copy Invite Code',
description: 'Copy the one time invite code, and send it to the person you wish to invite.',
mode: 'copy'
},
{
title: 'Paste Invite Confirmation',
description: 'Paste the code sent to you by the user which wants to join your lobby.',
mode: 'paste'
}
]
peer.signalingPort.onmessage = ({ data }) => {
// console.log(data)
const { description, candidate } = typeof data === 'string' ? JSON.parse(data) : data
if (description) {
if (description.type === 'answer') values = []
values.push(description.sdp)
} else if (candidate) {
if (candidate.candidate.includes('srflx')) values.push(candidate.candidate)
}
if (values.length > 1) {
code = btoa(JSON.stringify(values))
}
}
$: value = (step?.mode === 'copy' && code) || null
$: step = map[state]?.[index]
function handleInput ({ target }) {
handleCode(target.value)
value = null
}
function copyData () {
navigator.clipboard.writeText(`<miru://w2g/${state === 'host' ? 'invite' : 'join'}/${value}>`)
index = index + 1
}
let index = 0
const map = {
guest: [
{
title: 'Paste Invite Code',
description: 'Paste the one time invite code sent to you by the lobby host.',
mode: 'paste'
},
{
title: 'Copy Invite Confirmation',
description: 'Send the host this code, which required to request a connection.',
mode: 'copy'
}
],
host: [
{
title: 'Copy Invite Code',
description: 'Copy the one time invite code, and send it to the person you wish to invite.',
mode: 'copy'
},
{
title: 'Paste Invite Confirmation',
description: 'Paste the code sent to you by the user which wants to join your lobby.',
mode: 'paste'
}
]
}
$: step = map[state]?.[index]
</script>
<div class="h-full d-flex flex-column justify-content-center mb-20 pb-20 root container">
<div class='h-full d-flex flex-column justify-content-center mb-20 pb-20 root container'>
{#if step && (value || step.mode === 'paste')}
<h1 class="font-weight-bold">
<h1 class='font-weight-bold'>
{step.title}
</h1>
<p class="font-size-18 mt-0">
<p class='font-size-18 mt-0'>
{step.description}
</p>
<textarea disabled={step.mode === 'copy'} on:input={handleInput} bind:value class="form-control h-300 w-full mb-15" />
<textarea disabled={step.mode === 'copy'} on:input={handleInput} bind:value class='form-control h-300 w-full mb-15' />
{#if step.mode === 'copy' && value}
<button class="btn btn-primary mt-5" type="button" on:click={copyData} disabled={!value}>Copy</button>
<button class='btn btn-primary mt-5' type='button' on:click={copyData} disabled={!value}>Copy</button>
{/if}
{:else}
<h1 class="font-weight-bold">Connecting...</h1>
<h1 class='font-weight-bold'>Connecting...</h1>
{/if}
<button class="btn btn-danger mt-5" type="button" on:click={cancel}>Cancel</button>
<button class='btn btn-danger mt-5' type='button' on:click={cancel}>Cancel</button>
</div>

View file

@ -1,31 +1,31 @@
<script>
export let peers
export let state
export let invite
export let cleanup
export let peers
export let state
export let invite
export let cleanup
</script>
<div class="d-flex flex-column py-20 root container card">
<div class="d-flex align-items-center w-full">
<h1 class="font-weight-bold mr-auto">Lobby</h1>
<div class='d-flex flex-column py-20 root container card'>
<div class='d-flex align-items-center w-full'>
<h1 class='font-weight-bold mr-auto'>Lobby</h1>
{#if state === 'host'}
<button class="btn btn-success btn-lg ml-20" type="button" on:click={invite}>Invite To Lobby</button>
<button class='btn btn-success btn-lg ml-20' type='button' on:click={invite}>Invite To Lobby</button>
{/if}
<button class="btn btn-danger ml-20 btn-lg" type="button" on:click={cleanup}>Leave lobby</button>
<button class='btn btn-danger ml-20 btn-lg' type='button' on:click={cleanup}>Leave lobby</button>
</div>
{#each Object.values(peers) as peer}
<div class="d-flex align-items-center pb-10">
<div class='d-flex align-items-center pb-10'>
{#if peer.user?.avatar?.medium}
<img src={peer.user?.avatar?.medium} alt="avatar" class="w-50 h-50 img-fluid rounded" />
<img src={peer.user?.avatar?.medium} alt='avatar' class='w-50 h-50 img-fluid rounded' />
{:else}
<span class="material-icons w-50 h-50 anon"> person </span>
<span class='material-icons w-50 h-50 anon'> person </span>
{/if}
<h4 class="my-0 pl-20 mr-auto">{peer.user?.name || 'Anonymous'}</h4>
<h4 class='my-0 pl-20 mr-auto'>{peer.user?.name || 'Anonymous'}</h4>
{#if peer.user?.name}
<span class="material-icons pointer text-primary" on:click={() => window.IPC.emit('open', 'https://anilist.co/user/' + peer.user?.name)}> open_in_new </span>
<span class='material-icons pointer text-primary' on:click={() => window.IPC.emit('open', 'https://anilist.co/user/' + peer.user?.name)}> open_in_new </span>
{/if}
{#if state === 'host'}
<span class="material-icons ml-15 pointer text-danger" on:click={() => peer.peer.pc.close()}> logout </span>
<span class='material-icons ml-15 pointer text-danger' on:click={() => peer.peer.pc.close()}> logout </span>
{/if}
</div>
{/each}

View file

@ -1,249 +1,249 @@
<script context="module">
import { writable, get } from 'svelte/store'
import { alID } from '@/modules/anilist.js'
import { add, client } from '@/modules/torrent.js'
import Peer from '@/modules/Peer.js'
import { generateRandomHexCode } from '@/modules/util.js'
import { addToast } from '@/lib/Toasts.svelte'
import { page } from '@/App.svelte'
import 'browser-event-target-emitter'
<script context='module'>
import { writable, get } from 'svelte/store'
import { alID } from '@/modules/anilist.js'
import { add, client } from '@/modules/torrent.js'
import Peer from '@/modules/Peer.js'
import { generateRandomHexCode } from '@/modules/util.js'
import { addToast } from '@/lib/Toasts.svelte'
import { page } from '@/App.svelte'
import 'browser-event-target-emitter'
export const w2gEmitter = new EventTarget()
export const w2gEmitter = new EventTarget()
const state = writable(null)
const state = writable(null)
const peers = writable({})
const peers = writable({})
let peersExternal = {}
let peersExternal = {}
peers.subscribe(value => (peersExternal = value))
peers.subscribe(value => (peersExternal = value))
const pending = writable(null)
const pending = writable(null)
function invite () {
pending.set(new Peer({ polite: false }))
function invite () {
pending.set(new Peer({ polite: false }))
}
pending.subscribe(peer => {
if (peer) peer.ready.then(() => handlePeer(peer))
})
function setPlayerState (detail) {
let emit = false
for (const key of ['paused', 'time']) {
emit = emit || detail[key] !== playerState[key]
playerState[key] = detail[key]
}
return emit
}
pending.subscribe(peer => {
if (peer) peer.ready.then(() => handlePeer(peer))
})
w2gEmitter.on('player', ({ detail }) => {
if (setPlayerState(detail)) emit('player', detail)
})
function setPlayerState (detail) {
let emit = false
for (const key of ['paused', 'time']) {
emit = emit || detail[key] !== playerState[key]
playerState[key] = detail[key]
}
return emit
w2gEmitter.on('index', ({ detail }) => {
if (playerState.index !== detail.index) {
emit('index', detail)
playerState.index = detail.index
}
})
w2gEmitter.on('player', ({ detail }) => {
if (setPlayerState(detail)) emit('player', detail)
})
w2gEmitter.on('index', ({ detail }) => {
if (playerState.index !== detail.index) {
emit('index', detail)
playerState.index = detail.index
queueMicrotask(() => {
client.on('magnet', ({ detail }) => {
if (detail.hash !== playerState.hash) {
playerState.hash = detail.hash
playerState.index = 0
emit('torrent', detail)
}
})
})
queueMicrotask(() => {
client.on('magnet', ({ detail }) => {
if (detail.hash !== playerState.hash) {
playerState.hash = detail.hash
playerState.index = 0
emit('torrent', detail)
}
function emit (type, data) {
for (const peer of Object.values(peersExternal)) {
peer.channel.send(JSON.stringify({ type, ...data }))
}
}
const playerState = {
paused: null,
time: null,
hash: null,
index: 0
}
function cancel () {
pending.update(peer => {
peer.pc.close()
})
if (get(state) === 'guest') state.set(null)
}
function cleanup () {
if (get(state)) {
addToast({
text: 'The lobby you were previously in has disbanded.',
title: 'Lobby Disbanded',
type: 'danger'
})
})
function emit (type, data) {
for (const peer of Object.values(peersExternal)) {
peer.channel.send(JSON.stringify({ type, ...data }))
}
}
const playerState = {
paused: null,
time: null,
hash: null,
index: 0
}
function cancel () {
pending.update(peer => {
peer.pc.close()
state.set(null)
pending.set(null)
peers.update(peers => {
for (const peer of Object.values(peers)) peer?.peer?.pc.close()
return {}
})
if (get(state) === 'guest') state.set(null)
}
}
function cleanup () {
if (get(state)) {
function handlePeer (peer) {
pending.set(null)
if (get(state) === 'guest') peer.dc.onclose = cleanup
// add event listeners and store in peers
const protocolChannel = peer.pc.createDataChannel('w2gprotocol', { negotiated: true, id: 2 })
protocolChannel.onopen = async () => {
protocolChannel.onmessage = ({ data }) => handleMessage(JSON.parse(data), protocolChannel, peer)
const user = (await alID)?.data?.Viewer || {}
protocolChannel.send(
JSON.stringify({
type: 'init',
id: user.id || generateRandomHexCode(16),
user
})
)
}
}
const base64Rx = /((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)/
export function handleCode (text) {
console.log(text)
const match = text.match(base64Rx)
if (match) {
const code = match[1]
const pend = get(pending)
let val = null
try {
val = JSON.parse(atob(code))
} catch (e) {
addToast({
text: 'The lobby you were previously in has disbanded.',
title: 'Lobby Disbanded',
text: 'The provided invite code was invalid, try copying it again?',
title: 'Invalid Invite Code',
type: 'danger'
})
state.set(null)
pending.set(null)
peers.update(peers => {
for (const peer of Object.values(peers)) peer?.peer?.pc.close()
return {}
})
}
}
if (!val) return
const [description, ...candidates] = val
function handlePeer (peer) {
pending.set(null)
if (get(state) === 'guest') peer.dc.onclose = cleanup
// add event listeners and store in peers
const protocolChannel = peer.pc.createDataChannel('w2gprotocol', { negotiated: true, id: 2 })
protocolChannel.onopen = async () => {
protocolChannel.onmessage = ({ data }) => handleMessage(JSON.parse(data), protocolChannel, peer)
const user = (await alID)?.data?.Viewer || {}
protocolChannel.send(
JSON.stringify({
type: 'init',
id: user.id || generateRandomHexCode(16),
user
})
)
}
}
const base64Rx = /((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)/
export function handleCode (text) {
console.log(text)
const match = text.match(base64Rx)
if (match) {
const code = match[1]
const pend = get(pending)
let val = null
try {
val = JSON.parse(atob(code))
} catch (e) {
addToast({
text: 'The provided invite code was invalid, try copying it again?',
title: 'Invalid Invite Code',
type: 'danger'
})
pend.signalingPort.postMessage({
description: {
type: get(state) === 'host' ? 'answer' : 'offer',
sdp: description
}
if (!val) return
const [description, ...candidates] = val
})
for (const candidate of candidates) {
pend.signalingPort.postMessage({
description: {
type: get(state) === 'host' ? 'answer' : 'offer',
sdp: description
candidate: {
candidate,
sdpMid: '0',
sdpMLineIndex: 0
}
})
for (const candidate of candidates) {
pend.signalingPort.postMessage({
candidate: {
candidate,
sdpMid: '0',
sdpMLineIndex: 0
}
})
}
}
}
}
function handleMessage (data, channel, peer) {
if (get(state) === 'host') emit(data.type, data)
switch (data.type) {
case 'init':
function handleMessage (data, channel, peer) {
if (get(state) === 'host') emit(data.type, data)
switch (data.type) {
case 'init':
peers.update(object => {
object[data.id] = {
peer,
channel,
user: data.user
}
return object
})
channel.onclose = () => {
peers.update(object => {
object[data.id] = {
peer,
channel,
user: data.user
}
delete object[data.id]
return object
})
channel.onclose = () => {
peers.update(object => {
delete object[data.id]
return object
})
}
break
case 'player': {
if (setPlayerState(data)) w2gEmitter.emit('playerupdate', data)
break
}
case 'torrent': {
if (data.hash !== playerState.hash) {
playerState.hash = data.hash
add(data.magnet)
}
break
break
case 'player': {
if (setPlayerState(data)) w2gEmitter.emit('playerupdate', data)
break
}
case 'torrent': {
if (data.hash !== playerState.hash) {
playerState.hash = data.hash
add(data.magnet)
}
case 'index': {
if (playerState.index !== data.index) {
playerState.index = data.index
w2gEmitter.emit('setindex', data.index)
}
break
}
default: {
console.error('Invalid message type', data, channel)
break
}
case 'index': {
if (playerState.index !== data.index) {
playerState.index = data.index
w2gEmitter.emit('setindex', data.index)
}
break
}
default: {
console.error('Invalid message type', data, channel)
}
}
}
function setState (newstate) {
if (newstate === 'guest') {
const peer = new Peer({ polite: true })
pending.set(peer)
}
state.set(newstate)
function setState (newstate) {
if (newstate === 'guest') {
const peer = new Peer({ polite: true })
pending.set(peer)
}
state.set(newstate)
}
function waitToCompleteIceGathering (pc, state = pc.iceGatheringState) {
return state !== 'complete' && new Promise(resolve => {
pc.addEventListener('icegatheringstatechange', () => (pc.iceGatheringState === 'complete') && resolve())
})
}
const linkRx = /(invite|join)\/(.*)/i
window.IPC.on('w2glink', async link => {
const match = link.match(linkRx)
console.log(link, match)
if (match) {
page.set('watchtogether')
if (match[1] === 'join') {
if (get(state)) {
handleCode(match[2])
} else {
console.log('no')
}
} else {
if (!get(state)) setState('guest')
await waitToCompleteIceGathering(get(pending).pc)
handleCode(match[2])
}
}
function waitToCompleteIceGathering (pc, state = pc.iceGatheringState) {
return state !== 'complete' && new Promise(resolve => {
pc.addEventListener('icegatheringstatechange', () => (pc.iceGatheringState === 'complete') && resolve())
})
}
const linkRx = /(invite|join)\/(.*)/i
window.IPC.on('w2glink', async link => {
const match = link.match(linkRx)
console.log(link, match)
if (match) {
page.set('watchtogether')
if (match[1] === 'join') {
if (get(state)) {
handleCode(match[2])
} else {
console.log('no')
}
} else {
if (!get(state)) setState('guest')
await waitToCompleteIceGathering(get(pending).pc)
handleCode(match[2])
}
}
})
</script>
<script>
import Lobby from './Lobby.svelte'
import Connect from './Connect.svelte'
import Lobby from './Lobby.svelte'
import Connect from './Connect.svelte'
</script>
<div class="d-flex h-full align-items-center flex-column content">
<div class="font-size-50 font-weight-bold pt-20 mt-20 root">Watch Together</div>
<div class='d-flex h-full align-items-center flex-column content'>
<div class='font-size-50 font-weight-bold pt-20 mt-20 root'>Watch Together</div>
{#if !$state}
<div class="d-flex flex-row flex-wrap justify-content-center align-items-center h-full mb-20 pb-20 root">
<div class="card d-flex flex-column align-items-center w-300 h-300 justify-content-end">
<span class="font-size-80 material-icons d-flex align-items-center h-full">add</span>
<button class="btn btn-primary btn-lg mt-10 btn-block" type="button" on:click={() => setState('host')}>Create Lobby</button>
<div class='d-flex flex-row flex-wrap justify-content-center align-items-center h-full mb-20 pb-20 root'>
<div class='card d-flex flex-column align-items-center w-300 h-300 justify-content-end'>
<span class='font-size-80 material-icons d-flex align-items-center h-full'>add</span>
<button class='btn btn-primary btn-lg mt-10 btn-block' type='button' on:click={() => setState('host')}>Create Lobby</button>
</div>
<div class="card d-flex flex-column align-items-center w-300 h-300 justify-content-end">
<span class="font-size-80 material-icons d-flex align-items-center h-full">group_add</span>
<button class="btn btn-primary btn-lg mt-10 btn-block" type="button" on:click={() => setState('guest')}>Join Lobby</button>
<div class='card d-flex flex-column align-items-center w-300 h-300 justify-content-end'>
<span class='font-size-80 material-icons d-flex align-items-center h-full'>group_add</span>
<button class='btn btn-primary btn-lg mt-10 btn-block' type='button' on:click={() => setState('guest')}>Join Lobby</button>
</div>
</div>
{:else if $pending}

View file

@ -50,7 +50,7 @@ export default class Subtitles {
const { subtitle, trackNumber } = detail
if (this.selected) {
const string = JSON.stringify(subtitle)
if (!this._tracksString[trackNumber].has(string)) {
if (!this._tracksString[trackNumber]?.has(string)) {
this._tracksString[trackNumber].add(string)
const assSub = this.constructSub(subtitle, this.headers[trackNumber].type !== 'ass', this.tracks[trackNumber].length)
this.tracks[trackNumber].push(assSub)