mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-19 08:02:16 +00:00
feat: donation status, RPC buttons
fix: linting, subtitle track errors
This commit is contained in:
parent
c0254d749b
commit
7267316895
27 changed files with 2506 additions and 2486 deletions
10
.prettierrc
10
.prettierrc
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 180,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"svelteBracketNewLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "none"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}> × </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}> × </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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}> × </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}> × </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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue