mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-03-29 01:28:51 +00:00
feat: new viewanime page
fix: support 4k movies fix: no quality searches feat: support nyaa feeds
This commit is contained in:
parent
2f9b1e5792
commit
4e68d5349f
14 changed files with 320 additions and 234 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Miru",
|
||||
"version": "4.1.13",
|
||||
"version": "4.1.14",
|
||||
"author": "ThaUnknown_ <ThaUnknown@users.noreply.github.com>",
|
||||
"description": "Stream anime torrents, real-time with no waiting for downloads.",
|
||||
"main": "build/main.js",
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ function createWindow () {
|
|||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['https://sneedex.moe/api/public/nyaa', 'http://animetosho.org/storage/torrent/*'] }, ({ responseHeaders }, fn) => {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['https://sneedex.moe/api/public/nyaa', 'http://animetosho.org/storage/torrent/*', atob('aHR0cHM6Ly9ueWFhLnNpLyo=')] }, ({ responseHeaders }, fn) => {
|
||||
responseHeaders['Access-Control-Allow-Origin'] = '*'
|
||||
|
||||
fn({ responseHeaders })
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@
|
|||
contain-intrinsic-height: 25.7rem;
|
||||
}
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
background-color: var(--color) !important;
|
||||
}
|
||||
.list-status-circle {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@
|
|||
border-color: var(--color) !important;
|
||||
}
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
background-color: var(--color) !important;
|
||||
}
|
||||
.list-status-circle {
|
||||
|
|
|
|||
|
|
@ -82,9 +82,12 @@
|
|||
{media.title.userPreferred}
|
||||
</div>
|
||||
<div class='d-flex flex-row pt-5'>
|
||||
<button class='btn btn-secondary flex-grow-1 text-dark font-weight-bold shadow-none border-0'
|
||||
<button class='btn btn-secondary flex-grow-1 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center'
|
||||
use:click={play}
|
||||
disabled={media.status === 'NOT_YET_RELEASED'}>
|
||||
<span class='material-symbols-outlined font-size-20 filled pr-10'>
|
||||
play_arrow
|
||||
</span>
|
||||
{playButtonText}
|
||||
</button>
|
||||
<button class='btn btn-square ml-10 material-symbols-outlined font-size-16 shadow-none border-0' class:filled={media.isFavourite} use:click={toggleFavourite}>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@
|
|||
contain-intrinsic-height: 36.7rem;
|
||||
}
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
background-color: var(--color) !important;
|
||||
}
|
||||
.list-status-circle {
|
||||
|
|
|
|||
|
|
@ -158,4 +158,11 @@ img[src=''], img[src=' '] {
|
|||
|
||||
.align-content-start {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
}
|
||||
.pre-wrap {
|
||||
white-space: pre-wrap
|
||||
}
|
||||
|
|
@ -11,9 +11,11 @@ export default async function tosho ({ media, episode }) {
|
|||
|
||||
const aniDBEpisode = await getAniDBEpisodeFromAL({ media, episode }, json)
|
||||
|
||||
let entries = await getToshoEntries(media, aniDBEpisode, json, set.rssQuality)
|
||||
const movie = isMovie(media) // don't query movies with qualities, to allow 4k
|
||||
|
||||
if (!entries.length) entries = await getToshoEntries(media, aniDBEpisode, json)
|
||||
let entries = await getToshoEntries(media, aniDBEpisode, json, !movie && set.rssQuality)
|
||||
|
||||
if (!entries.length && !movie) entries = await getToshoEntries(media, aniDBEpisode, json)
|
||||
|
||||
return mapBestRelease(mapTosho2dDeDupedEntry(entries))
|
||||
}
|
||||
|
|
@ -193,7 +195,11 @@ function isMovie (media) {
|
|||
|
||||
function buildQuery (quality) {
|
||||
let query = `&qx=1&q=!("${exclusions.join('"|"')}")`
|
||||
if (quality) query += ` "${quality}"`
|
||||
if (quality) {
|
||||
query += ` "${quality}"`
|
||||
} else {
|
||||
query += 'e*' // HACK: tosho NEEDS a search string, so we lazy search a single common vowel
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function parseRSSNodes (nodes) {
|
|||
|
||||
return {
|
||||
title: item.querySelector('title')?.textContent || '?',
|
||||
link: item.querySelector('enclosure')?.attributes.url.value || '?',
|
||||
link: item.querySelector('enclosure')?.attributes.url.value || item.querySelector('link')?.textContent || '?',
|
||||
seeders: item.querySelector('seeders')?.textContent ?? '?',
|
||||
leechers: item.querySelector('leechers')?.textContent ?? '?',
|
||||
downloads: item.querySelector('downloads')?.textContent ?? '?',
|
||||
|
|
@ -46,16 +46,25 @@ export function getReleasesRSSurl (val) {
|
|||
|
||||
export async function getRSSContent (url) {
|
||||
if (!url) return null
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
addToast({
|
||||
text: 'Failed fetching RSS!<br>' + res.statusText,
|
||||
title: 'Search Failed',
|
||||
type: 'danger'
|
||||
})
|
||||
console.error('Failed to fetch rss', res.statusText)
|
||||
}
|
||||
return DOMPARSER(await res.text(), 'text/xml')
|
||||
} catch (e) {
|
||||
addToast({
|
||||
text: 'Failed fetching RSS!<br>' + res.statusText,
|
||||
text: 'Failed fetching RSS!<br>' + e.message,
|
||||
title: 'Search Failed',
|
||||
type: 'danger'
|
||||
})
|
||||
console.error('Failed to fetch rss', res.statusText)
|
||||
console.error('Failed to fetch rss', e)
|
||||
}
|
||||
return DOMPARSER(await res.text(), 'text/xml')
|
||||
}
|
||||
|
||||
class RSSMediaManager {
|
||||
|
|
@ -76,6 +85,9 @@ class RSSMediaManager {
|
|||
|
||||
async getContentChanged (page, perPage, url) {
|
||||
const content = await getRSSContent(getReleasesRSSurl(url))
|
||||
|
||||
if (!content) return false
|
||||
|
||||
const pubDate = new Date(content.querySelector('pubDate').textContent) * page * perPage
|
||||
if (this.resultMap[url]?.date === pubDate) return false
|
||||
return { content, pubDate }
|
||||
|
|
@ -106,7 +118,11 @@ class RSSMediaManager {
|
|||
await this.lastResult
|
||||
const res = (await resolveFileMedia(title))[0]
|
||||
if (res.media?.id) {
|
||||
res.episodeData = (await getEpisodeMetadataForMedia(res.media))?.[res.episode]
|
||||
try {
|
||||
res.episodeData = (await getEpisodeMetadataForMedia(res.media))?.[res.episode]
|
||||
} catch (e) {
|
||||
console.warn('failed fetching episode metadata', e)
|
||||
}
|
||||
}
|
||||
res.date = date
|
||||
res.onclick = () => add(link)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,20 @@
|
|||
<script>
|
||||
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') {
|
||||
return `${getMediaMaxEp(media)} Episodes`
|
||||
} else if (detail.property === 'averageScore') {
|
||||
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]
|
||||
}
|
||||
|
|
@ -43,8 +29,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<h1 class='title font-weight-bold text-white'>Details</h1>
|
||||
<div class='card m-0 px-20 pb-0 pb-md-10 pt-10 d-flex flex-md-column flex-row overflow-x-scroll text-capitalize align-items-start'>
|
||||
<div class='card m-0 px-20 pb-0 pt-10 d-flex flex-row overflow-x-scroll text-capitalize align-items-start'>
|
||||
{#each detailsMap as detail}
|
||||
{@const property = getProperty(detail.property, media)}
|
||||
{#if property}
|
||||
|
|
|
|||
85
src/renderer/views/ViewAnime/EpisodeList.svelte
Normal file
85
src/renderer/views/ViewAnime/EpisodeList.svelte
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script>
|
||||
import { since } from '@/modules/util'
|
||||
import { click } from '@/modules/click.js'
|
||||
|
||||
export let id
|
||||
|
||||
export let episodeCount
|
||||
|
||||
export let userProgress = 0
|
||||
|
||||
export let duration
|
||||
|
||||
export let play
|
||||
|
||||
const episodeList = Array.from({ length: episodeCount }, (_, i) => ({ episode: i + 1 }))
|
||||
async function load () {
|
||||
const res = await fetch('https://api.ani.zip/mappings?anilist_id=' + id)
|
||||
const { episodes } = await res.json()
|
||||
for (const { episode, image, summary, rating, title, length, airdate } of Object.values(episodes)) {
|
||||
const episodeNumber = Number(episode)
|
||||
if (!episodeNumber) continue
|
||||
|
||||
// TODO: AL airing times
|
||||
|
||||
episodeList[episodeNumber - 1] = { episode: episodeNumber, image, summary, rating, title, length: length || duration, airdate }
|
||||
}
|
||||
}
|
||||
load()
|
||||
</script>
|
||||
|
||||
{#each episodeList as { episode, image, summary, rating, title, length, airdate }}
|
||||
{@const completed = userProgress >= episode}
|
||||
{@const target = userProgress + 1 === episode}
|
||||
<div class='w-full my-20 content-visibility-auto scale' class:px-20={!target} class:h-150={image || summary} use:click={() => play(episode)}>
|
||||
<div class='rounded w-full h-full overflow-hidden d-flex flex-row pointer' class:bg-dark-light={completed} class:bg-dark={!completed}>
|
||||
{#if image}
|
||||
<div class='w-450 h-full'>
|
||||
<img alt='thumbnail' src={image} class='img-cover h-full w-full' class:opacity-half={completed} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class='h-full w-full px-20 py-15 d-flex flex-column'>
|
||||
<div class='w-full d-flex flex-row mb-15'>
|
||||
<div class='text-white font-weight-bold font-size-16 overflow-hidden title'>
|
||||
{episode}. {title?.en || 'Episode ' + episode}
|
||||
</div>
|
||||
{#if length}
|
||||
<div class='ml-auto pl-5'>
|
||||
{length}m
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completed}
|
||||
<div class='progress mb-15' style='height: 2px; min-height: 2px;'>
|
||||
<div class='progress-bar w-full' />
|
||||
</div>
|
||||
{/if}
|
||||
<div class='font-size-12 overflow-hidden'>
|
||||
{summary || ''}
|
||||
</div>
|
||||
<div class='pt-10 font-size-12 mt-auto'>
|
||||
{#if airdate}
|
||||
{since(new Date(airdate))}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.opacity-half {
|
||||
opacity: 50%;
|
||||
}
|
||||
.title {
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.scale {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.scale:hover{
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -11,44 +11,22 @@
|
|||
}
|
||||
}
|
||||
$: 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'>
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Following</div>
|
||||
<hr class='w-full' />
|
||||
</div>
|
||||
<div class='px-15 pt-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>
|
||||
<div class='d-flex align-items-center w-full pt-20 font-size-16'>
|
||||
<img src={friend.user.avatar.medium} alt='avatar' class='w-50 h-50 img-fluid rounded cover-img' />
|
||||
<span class='my-0 pl-20 mr-auto text-truncate'>{friend.user.name}</span>
|
||||
<span class='my-0 px-10 text-capitalize'>{friend.status.toLowerCase()}</span>
|
||||
<span class='material-symbols-outlined pointer text-primary font-size-18' use:click={() => window.IPC.emit('open', 'https://anilist.co/user/' + friend.user.name)}> open_in_new </span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.h-30 {
|
||||
height: 3rem
|
||||
}
|
||||
.w-30 {
|
||||
width: 3rem
|
||||
}
|
||||
.card {
|
||||
background-color: var(--dm-button-bg-color) !important;
|
||||
background-image: var(--dm-button-bg-image) !important;
|
||||
box-shadow: var(--dm-button-box-shadow) !important;
|
||||
}
|
||||
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@
|
|||
export let title = 'Relations'
|
||||
</script>
|
||||
{#if list?.length}
|
||||
<span class='d-flex align-items-end pointer text-decoration-none pt-20' use:click={toggleList}>
|
||||
<h1 class='font-weight-bold text-white'>{title}</h1>
|
||||
{#if list.length > 4}
|
||||
<h6 class='ml-auto font-size-12 more text-muted'>{showMore ? 'Show Less' : 'Show More'}</h6>
|
||||
{/if}
|
||||
<span class='d-flex align-items-end pointer' use:click={toggleList}>
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>{title}</div>
|
||||
<hr class='w-full' />
|
||||
{#if list.length > 4}
|
||||
<div class='ml-auto pl-20 font-size-12 more text-muted text-nowrap'>{showMore ? 'Show Less' : 'Show More'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
<div class='d-flex text-capitalize flex-wrap card m-0'>
|
||||
<div class='d-flex text-capitalize flex-wrap pt-10'>
|
||||
{#each list.slice(0, showMore ? 100 : 4) as item}
|
||||
<slot {item} />
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -1,145 +1,177 @@
|
|||
<script>
|
||||
import { getContext, setStatus } from 'svelte'
|
||||
import { getMediaMaxEp, formatMap, playMedia } from '@/modules/anime.js'
|
||||
import { playAnime } from '../RSSView.svelte'
|
||||
import { addToast } from '../../components/Toasts.svelte'
|
||||
import { alRequest } from '@/modules/anilist.js'
|
||||
import { getMediaMaxEp } from '@/modules/anime.js'
|
||||
import { getContext } from 'svelte'
|
||||
import Details from './Details.svelte'
|
||||
import Following from './Following.svelte'
|
||||
import Controls from './Controls.svelte'
|
||||
import ToggleList from './ToggleList.svelte'
|
||||
import { click } from '@/modules/click.js'
|
||||
import Details from './Details.svelte'
|
||||
import EpisodeList from './EpisodeList.svelte'
|
||||
import ToggleList from './ToggleList.svelte'
|
||||
import Following from './Following.svelte'
|
||||
import smoothScroll from '@/modules/scroll.js'
|
||||
|
||||
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()
|
||||
}
|
||||
function play (episode) {
|
||||
if (episode) return playAnime(media, episode)
|
||||
if (media.status === 'NOT_YET_RELEASED') return
|
||||
playMedia(media)
|
||||
}
|
||||
function getPlayButtonText (media) {
|
||||
if (media?.mediaListEntry) {
|
||||
const { status, progress } = media.mediaListEntry
|
||||
if (progress) {
|
||||
if (status === 'COMPLETED') {
|
||||
return 'Rewatch Now'
|
||||
} else {
|
||||
return 'Continue Now'
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'Watch Now'
|
||||
}
|
||||
$: playButtonText = getPlayButtonText(media)
|
||||
async function toggleStatus () {
|
||||
if (!media.mediaListEntry) {
|
||||
// add
|
||||
const res = await setStatus('PLANNING', {}, media)
|
||||
media.mediaListEntry = res.data.SaveMediaListEntry
|
||||
} else {
|
||||
// delete
|
||||
alRequest({
|
||||
method: 'Delete',
|
||||
id: media.mediaListEntry.id
|
||||
})
|
||||
media.mediaListEntry = undefined
|
||||
}
|
||||
}
|
||||
function toggleFavourite () {
|
||||
alRequest({
|
||||
method: 'Favourite',
|
||||
id: media.id
|
||||
})
|
||||
media.isFavourite = !media.isFavourite
|
||||
}
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class='modal modal-full z-40' class:show={media} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
|
||||
{#if media}
|
||||
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto'>
|
||||
<button class='close pointer z-30 bg-dark top-20 right-0 position-absolute' type='button' use: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'>
|
||||
<div class='col-md-8 col-12 d-flex justify-content-end flex-column pl-20'>
|
||||
<div class='px-md-20 d-flex flex-column font-size-12'>
|
||||
<span class='title font-weight-bold pb-sm-15 text-white select-all'>
|
||||
{media.title.userPreferred}
|
||||
</span>
|
||||
<div class='d-flex flex-row font-size-18 pb-sm-15'>
|
||||
{#if media.averageScore}
|
||||
<span class='material-symbols-outlined mr-10 font-size-24'> trending_up </span>
|
||||
<span class='mr-20'>
|
||||
Rating: {media.averageScore + '%'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if media.format}
|
||||
<span class='material-symbols-outlined 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}
|
||||
{#if media.episodes !== 1 && getMediaMaxEp(media)}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> theaters </span>
|
||||
<span class='mr-20'>
|
||||
Episodes: {getMediaMaxEp(media)}
|
||||
</span>
|
||||
{:else if media.duration}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> timer </span>
|
||||
<span class='mr-20'>
|
||||
Length: {media.duration + ' min'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class='pb-15 pt-5 px-5 overflow-x-auto text-nowrap font-weight-bold'>
|
||||
{#each media.genres as genre}
|
||||
<div class='badge badge-pill shadow'>
|
||||
{genre}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controls bind:media={$view} />
|
||||
<div class='h-full modal-content bg-very-dark p-0 overflow-y-auto position-relative' use:smoothScroll>
|
||||
<button class='close pointer z-30 bg-dark top-20 right-0 position-fixed' type='button' use:click={close}> × </button>
|
||||
<img class='w-full cover-img banner position-absolute' alt='banner' src={media.bannerImage || ' '} />
|
||||
<div class='row'>
|
||||
<div class='col-7'>
|
||||
<div class='d-flex flex-row align-items-end pb-20 mb-15'>
|
||||
<div class='cover d-flex flex-row align-items-end'>
|
||||
<img class='rounded cover-img w-full overflow-hidden' alt='cover-art' src={media.coverImage?.extraLarge || media.coverImage?.medium} />
|
||||
</div>
|
||||
<div class='pl-20 ml-20'>
|
||||
<h1 class='font-weight-very-bold text-white select-all'>{media.title.userPreferred}</h1>
|
||||
<p class='d-flex flex-row font-size-18'>
|
||||
{#if media.averageScore}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> trending_up </span>
|
||||
<span class='mr-20'>
|
||||
Rating: {media.averageScore + '%'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if media.format}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> monitor </span>
|
||||
<span class='mr-20 text-capitalize'>
|
||||
Format: {formatMap[media.format]}
|
||||
</span>
|
||||
{/if}
|
||||
{#if media.episodes !== 1 && getMediaMaxEp(media)}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> theaters </span>
|
||||
<span class='mr-20'>
|
||||
Episodes: {getMediaMaxEp(media)}
|
||||
</span>
|
||||
{:else if media.duration}
|
||||
<span class='material-symbols-outlined mx-10 font-size-24'> timer </span>
|
||||
<span class='mr-20'>
|
||||
Length: {media.duration + ' min'}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<div class='d-flex flex-row pt-5'>
|
||||
<button class='btn btn-lg btn-secondary w-250 text-dark font-weight-bold shadow-none border-0 d-flex align-items-center justify-content-center'
|
||||
use:click={() => play()}
|
||||
disabled={media.status === 'NOT_YET_RELEASED'}>
|
||||
<span class='material-symbols-outlined font-size-24 filled pr-10'>
|
||||
play_arrow
|
||||
</span>
|
||||
{playButtonText}
|
||||
</button>
|
||||
<button class='btn bg-dark btn-lg btn-square ml-10 material-symbols-outlined font-size-20 shadow-none border-0' class:filled={media.isFavourite} use:click={toggleFavourite}>
|
||||
favorite
|
||||
</button>
|
||||
<button class='btn bg-dark btn-lg btn-square ml-10 material-symbols-outlined font-size-20 shadow-none border-0' class:filled={media.mediaListEntry} use:click={toggleStatus}>
|
||||
bookmark
|
||||
</button>
|
||||
<button class='btn bg-dark btn-lg btn-square ml-10 material-symbols-outlined font-size-20 shadow-none border-0' use:click={() => copyToClipboard(`https://miru.watch/anime/${media.id}`)}>
|
||||
share
|
||||
</button>
|
||||
<button class='btn bg-dark btn-lg btn-square ml-10 material-symbols-outlined font-size-20 shadow-none border-0' use:click={() => openInBrowser(`https://anilist.co/anime/${media.id}`)}>
|
||||
open_in_new
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Details {media} />
|
||||
<div class='d-flex flex-row mt-20 pt-10'>
|
||||
{#each media.genres as genre}
|
||||
<div class='bg-dark px-20 py-10 mr-10 rounded font-size-16'>
|
||||
{genre}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class='w-full d-flex flex-row align-items-center pt-20 mt-10'>
|
||||
<hr class='w-full' />
|
||||
<div class='font-size-18 font-weight-semi-bold px-20 text-white'>Synopsis</div>
|
||||
<hr class='w-full' />
|
||||
</div>
|
||||
<div class='font-size-16 pre-wrap pt-20 select-all'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
<ToggleList list={media.relations?.edges?.filter(({ node }) => node.type === 'ANIME')} let:item title='Relations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await 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 rounded' />
|
||||
<div class='pt-5'>{item.relationType.replace(/_/g, ' ').toLowerCase()}</div>
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.title.userPreferred}</h5>
|
||||
</div>
|
||||
</ToggleList>
|
||||
<Following {media} />
|
||||
<ToggleList list={media.recommendations.edges.filter(edge => edge.node.mediaRecommendation)} let:item title='Recommendations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await 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 rounded' />
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.mediaRecommendation.title.userPreferred}</h5>
|
||||
</div>
|
||||
</ToggleList>
|
||||
</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 pr-50'>
|
||||
<h1 class='title font-weight-bold text-white'>Synopsis</h1>
|
||||
<div class='font-size-16 pre-wrap select-all card m-0'>
|
||||
{media.description?.replace(/<[^>]*>/g, '') || ''}
|
||||
</div>
|
||||
<ToggleList list={media.relations?.edges?.filter(({ node }) => node.type === 'ANIME')} let:item title='Relations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await 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 rounded' />
|
||||
<div class='pt-5'>{item.relationType.replace(/_/g, ' ').toLowerCase()}</div>
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.title.userPreferred}</h5>
|
||||
</div>
|
||||
</ToggleList>
|
||||
{#if maxPlayEp}
|
||||
<h1 class='title font-weight-bold text-white pt-20'>Episodes</h1>
|
||||
<div class='card m-0 d-inline-block'>
|
||||
<table class='table table-hover w-500 table-auto '>
|
||||
<tbody>
|
||||
{#each Array(maxPlayEp) as _, i}
|
||||
{@const ep = maxPlayEp - i}
|
||||
<tr class="font-size-20 py-10 pointer {ep <= media.mediaListEntry?.progress ? 'text-muted' : 'text-white'}"
|
||||
use:click={() => {
|
||||
playAnime(media, ep)
|
||||
close()
|
||||
}}>
|
||||
<td class='w-full font-weight-semi-bold'>Episode {ep}</td>
|
||||
<td class='material-symbols-outlined text-right h-full d-table-cell'>play_arrow</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<ToggleList list={media.recommendations.edges.filter(edge => edge.node.mediaRecommendation)} let:item title='Recommendations'>
|
||||
<div class='w-150 mx-15 my-10 rel pointer'
|
||||
use:click={async () => { $view = null; $view = (await 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 rounded' />
|
||||
<h5 class='font-weight-bold text-white mb-5'>{item.node.mediaRecommendation.title.userPreferred}</h5>
|
||||
</div>
|
||||
</ToggleList>
|
||||
</div>
|
||||
<div class='col-md-3 px-sm-0 px-20'>
|
||||
{#if media.mediaListEntry?.progress}
|
||||
<h1 class='title font-weight-bold text-white'>Progress</h1>
|
||||
<div class='card m-0 pt-20 pb-15 d-flex flex-md-column flex-row text-capitalize align-items-start'>
|
||||
<div class='progress w-full'>
|
||||
<div class='progress-bar' role='progressbar' style='width: {media.mediaListEntry?.progress / getMediaMaxEp(media) * 100}%;' />
|
||||
</div>
|
||||
<div class='font-weight-bold pt-10'>
|
||||
{media.mediaListEntry?.progress} / {getMediaMaxEp(media)} Available Episodes
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Details {media} />
|
||||
<Following {media} />
|
||||
</div>
|
||||
<div class='col-5 d-flex flex-column pl-20'>
|
||||
<EpisodeList id={media.id} userProgress={media.mediaListEntry?.progress} episodeCount={media.episodes} duration={media.duration} {play} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,53 +179,26 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.pre-wrap {
|
||||
white-space: pre-wrap
|
||||
.close {
|
||||
top: 5rem !important;
|
||||
left: unset !important;
|
||||
right: 3rem !important;
|
||||
}
|
||||
.banner {
|
||||
background: no-repeat center center;
|
||||
background-size: cover;
|
||||
background-image: linear-gradient(0deg, rgba(17, 20, 23, 1) 0%, rgba(17, 20, 23, 0.8) 25%, rgba(17, 20, 23, 0.4) 50%, rgba(37, 40, 44, 0) 100%), var(--bannerurl) !important;
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
aspect-ratio: 5/1;
|
||||
}
|
||||
.row {
|
||||
padding: 0 10rem;
|
||||
padding-top: 12rem
|
||||
}
|
||||
.cover {
|
||||
aspect-ratio: 7/10;
|
||||
max-width: 35%;
|
||||
}
|
||||
|
||||
.d-table-cell {
|
||||
display: table-cell !important;
|
||||
}
|
||||
|
||||
.top {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.title {
|
||||
font-size: 4rem;
|
||||
}
|
||||
.pr-50 {
|
||||
padding-right: 5rem;
|
||||
}
|
||||
.close {
|
||||
top: 4rem !important;
|
||||
left: unset !important;
|
||||
right: 2.5rem !important;
|
||||
}
|
||||
.badge {
|
||||
background-color: var(--dm-button-bg-color) !important;
|
||||
padding: 0.6rem 2rem;
|
||||
font-size: 1.4rem;
|
||||
border: none;
|
||||
margin-right: 0.6rem;
|
||||
}
|
||||
.rel-img{
|
||||
height: 27rem;
|
||||
width: 17rem
|
||||
}
|
||||
|
||||
.cover-img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.rel {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.rel:hover {
|
||||
transform: scale(1.05);
|
||||
button.bg-dark:hover {
|
||||
background: #292d33 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue