mirror of
https://github.com/NoCrypt/migu.git
synced 2026-03-14 15:05:57 +00:00
270 lines
9.2 KiB
Svelte
270 lines
9.2 KiB
Svelte
<script context='module'>
|
|
import { writable } from 'simple-store-svelte'
|
|
import AnimeResolver from '@/modules/animeresolver.js'
|
|
import { videoRx } from '@/modules/util.js'
|
|
import { tick } from 'svelte'
|
|
import { state } from '../WatchTogether/WatchTogether.svelte'
|
|
import IPC from '@/modules/ipc.js'
|
|
import { anilistClient } from "@/modules/anilist.js"
|
|
import Debug from 'debug'
|
|
|
|
const debug = Debug('ui:mediahandler')
|
|
|
|
const episodeRx = /Episode (\d+) - (.*)/
|
|
|
|
export const media = writable(null)
|
|
|
|
const nowPlaying = writable({})
|
|
|
|
export const files = writable([])
|
|
|
|
const processed = writable([])
|
|
|
|
const noop = () => {}
|
|
|
|
let playFile
|
|
|
|
media.subscribe((media) => {
|
|
handleMedia(media || {})
|
|
return noop
|
|
})
|
|
|
|
function handleCurrent ({ detail }) {
|
|
media.set(detail.media)
|
|
}
|
|
|
|
export function findInCurrent (obj) {
|
|
const oldNowPlaying = nowPlaying.value
|
|
|
|
if (oldNowPlaying.media?.id === obj.media.id && oldNowPlaying.episode === obj.episode) return false
|
|
|
|
const fileList = files.value
|
|
|
|
const targetFile = fileList.find(file => file.media?.media?.id === obj.media.id &&
|
|
(file.media?.episode === obj.episode || obj.media.episodes === 1 || (!obj.media.episodes && (obj.episode === 1 || !obj.episode) && (oldNowPlaying.episode === 1 || !oldNowPlaying.episode))) // movie check
|
|
)
|
|
if (!targetFile) return false
|
|
if (oldNowPlaying.media?.id !== obj.media.id) {
|
|
// mediachange, filelist change
|
|
media.set({ media: obj.media, episode: obj.episode })
|
|
handleFiles(fileList)
|
|
} else {
|
|
playFile(targetFile)
|
|
}
|
|
return true
|
|
}
|
|
|
|
function handleMedia ({ media, episode, parseObject }) {
|
|
if (media) {
|
|
const ep = Number(episode || parseObject?.episode_number) || null
|
|
const streamingEpisode = media?.streamingEpisodes.find(episode => {
|
|
const match = episodeRx.exec(episode.title)
|
|
return match && Number(match[1]) === ep
|
|
})
|
|
|
|
const np = {
|
|
media,
|
|
title: anilistClient.title(media) || parseObject.anime_title,
|
|
episode: ep,
|
|
episodeTitle: streamingEpisode && episodeRx.exec(streamingEpisode.title)[2],
|
|
thumbnail: streamingEpisode?.thumbnail || media?.coverImage.extraLarge
|
|
}
|
|
setDiscordRPC(np)
|
|
setMediaSession(np)
|
|
nowPlaying.set(np)
|
|
}
|
|
}
|
|
|
|
const TYPE_EXCLUSIONS = ['ED', 'ENDING', 'NCED', 'NCOP', 'OP', 'OPENING', 'PREVIEW', 'PV']
|
|
|
|
// find best media in batch to play
|
|
// currently in progress or unwatched
|
|
// tv, movie, ona, ova
|
|
function findPreferredPlaybackMedia (videoFiles) {
|
|
for (const { media } of videoFiles) {
|
|
if (media.media?.mediaListEntry?.status === 'CURRENT') return { media: media.media, episode: (media.media.mediaListEntry.progress || 0) + 1 }
|
|
}
|
|
|
|
for (const { media } of videoFiles) {
|
|
if (media.media?.mediaListEntry?.status === 'REPEATING') return { media: media.media, episode: (media.media.mediaListEntry.progress || 0) + 1 }
|
|
}
|
|
|
|
let lowestPlanning
|
|
for (const { media, episode } of videoFiles) {
|
|
if (media.media?.mediaListEntry?.status === 'PLANNING' && (!lowestPlanning || episode > lowestPlanning.episode)) lowestPlanning = { media: media.media, episode }
|
|
}
|
|
if (lowestPlanning) return lowestPlanning
|
|
|
|
// unwatched
|
|
for (const format of ['TV', 'MOVIE', 'ONA', 'OVA']) {
|
|
let lowestUnwatched
|
|
for (const { media, episode } of videoFiles) {
|
|
if (media.media?.format === format && !media.media.mediaListEntry && (!lowestUnwatched || episode > lowestUnwatched.episode)) lowestUnwatched = { media: media.media, episode }
|
|
}
|
|
if (lowestUnwatched) return lowestUnwatched
|
|
}
|
|
|
|
// highest occurence if all else fails - unlikely
|
|
|
|
const max = highestOccurence(videoFiles, file => file.media.media?.id).media
|
|
if (max?.media) {
|
|
return { media: max.media, episode: (max.media.mediaListEntry?.progress + 1 || 1) }
|
|
}
|
|
}
|
|
|
|
function fileListToDebug (files) {
|
|
return files?.map(({ name, media, url }) => `\n${name} ${media?.parseObject?.anime_title} ${media?.parseObject?.episode_number} ${media?.media?.title?.userPreferred} ${media?.episode}`).join('')
|
|
}
|
|
|
|
async function handleFiles (files) {
|
|
debug(`Got ${files?.length} files`, fileListToDebug(files))
|
|
if (!files?.length) return processed.set(files)
|
|
let videoFiles = []
|
|
const otherFiles = []
|
|
for (const file of files) {
|
|
if (videoRx.test(file.name)) {
|
|
videoFiles.push(file)
|
|
} else {
|
|
otherFiles.push(file)
|
|
}
|
|
}
|
|
let nowPlaying = media.value
|
|
|
|
const resolved = await AnimeResolver.resolveFileAnime(videoFiles.map(file => file.name))
|
|
|
|
videoFiles.map(file => {
|
|
file.media = resolved.find(({ parseObject }) => file.name.includes(parseObject.file_name))
|
|
return file
|
|
})
|
|
|
|
videoFiles = videoFiles.filter(file => !TYPE_EXCLUSIONS.includes(file.media.parseObject.anime_type?.toUpperCase()))
|
|
|
|
if (nowPlaying?.verified && videoFiles.length === 1) {
|
|
debug('Media was verified, skipping verification')
|
|
videoFiles[0].media.media = nowPlaying.media
|
|
if (nowPlaying.episode) videoFiles[0].media.episode = nowPlaying.episode
|
|
}
|
|
|
|
debug(`Resolved ${videoFiles?.length} video files`, fileListToDebug(videoFiles))
|
|
|
|
if (!nowPlaying) {
|
|
nowPlaying = findPreferredPlaybackMedia(videoFiles)
|
|
debug(`Found preferred playback media: ${nowPlaying?.media?.id}:${nowPlaying?.media?.title?.userPreferred} ${nowPlaying?.episode}`)
|
|
}
|
|
|
|
const filtered = nowPlaying?.media && videoFiles.filter(file => file.media?.media?.id && file.media?.media?.id === nowPlaying.media.id)
|
|
|
|
debug(`Filtered ${filtered?.length} files based on media`, fileListToDebug(filtered))
|
|
|
|
let result
|
|
if (filtered?.length) {
|
|
result = filtered
|
|
} else {
|
|
const max = highestOccurence(videoFiles, file => file.media.parseObject.anime_title).media.parseObject.anime_title
|
|
debug(`Highest occurrence anime title: ${max}`)
|
|
result = videoFiles.filter(file => file.media.parseObject.anime_title === max)
|
|
}
|
|
|
|
result.sort((a, b) => a.media.episode - b.media.episode)
|
|
result.sort((a, b) => (b.media.parseObject.anime_season ?? 1) - (a.media.parseObject.anime_season ?? 1))
|
|
|
|
debug(`Sorted ${result.length} files`, fileListToDebug(result))
|
|
|
|
processed.set([...result, ...otherFiles])
|
|
await tick()
|
|
const file = (nowPlaying?.episode && (result.find(({ media }) => media.episode === nowPlaying.episode) || result.find(({ media }) => media.episode === 1))) || result[0]
|
|
if (nowPlaying) nowPlaying.episode = file.media.parseObject.episode_number
|
|
media.set(nowPlaying)
|
|
playFile(file || 0)
|
|
}
|
|
|
|
// find element with most occurences in array according to map function
|
|
const highestOccurence = (arr = [], mapfn = a => a) => arr.reduce((acc, el) => {
|
|
const mapped = mapfn(el)
|
|
acc.sums[mapped] = (acc.sums[mapped] || 0) + 1
|
|
acc.max = (acc.max !== undefined ? acc.sums[mapfn(acc.max)] : -1) > acc.sums[mapped] ? acc.max : el
|
|
return acc
|
|
}, { sums: {} }).max
|
|
|
|
files.subscribe((files = []) => {
|
|
handleFiles(files)
|
|
return noop
|
|
})
|
|
|
|
function setMediaSession (nowPlaying) {
|
|
if (typeof MediaMetadata === 'undefined') return
|
|
const name = [nowPlaying.title, nowPlaying.episode, nowPlaying.episodeTitle, 'Migu'].filter(i => i).join(' - ')
|
|
|
|
const metadata =
|
|
nowPlaying.thumbnail
|
|
? new MediaMetadata({
|
|
title: name,
|
|
artwork: [
|
|
{
|
|
src: nowPlaying.thumbnail,
|
|
sizes: '256x256',
|
|
type: 'image/jpg'
|
|
}
|
|
]
|
|
})
|
|
: new MediaMetadata({ title: name })
|
|
navigator.mediaSession.metadata = metadata
|
|
}
|
|
|
|
function setDiscordRPC (np = nowPlaying.value) {
|
|
const w2g = state.value?.code
|
|
const details = [np.title, np.episodeTitle].filter(i => i).join(' - ') || undefined
|
|
const activity = {
|
|
details,
|
|
state: details && 'Watching Episode ' + ((!np.media?.episodes && np.episode) || ''),
|
|
timestamps: {
|
|
start: Date.now()
|
|
},
|
|
party: {
|
|
size: (np.episode && np.media?.episodes && [np.episode, np.media.episodes]) || undefined
|
|
},
|
|
assets: {
|
|
large_text: np.title,
|
|
large_image: np.thumbnail,
|
|
small_image: 'https://raw.githubusercontent.com/NoCrypt/migu/main/common/public/logo_filled.png',
|
|
small_text: 'https://github.com/NoCrypt/migu'
|
|
},
|
|
instance: true,
|
|
type: 3
|
|
}
|
|
// cannot have buttons and secrets at once
|
|
if (w2g) {
|
|
activity.secrets = {
|
|
join: w2g,
|
|
match: w2g + 'm'
|
|
}
|
|
activity.party.id = w2g + 'p'
|
|
} else {
|
|
activity.buttons = [
|
|
{
|
|
label: 'Download app',
|
|
url: 'https://github.com/NoCrypt/migu/releases/latest'
|
|
},
|
|
{
|
|
label: 'Watch on Migu',
|
|
url: `https://miguapp.pages.dev/anime/${np.media?.id}`
|
|
}
|
|
]
|
|
}
|
|
IPC.emit('discord', { activity })
|
|
}
|
|
state.subscribe(() => {
|
|
setDiscordRPC()
|
|
return noop
|
|
})
|
|
</script>
|
|
|
|
<script>
|
|
import Player from './Player.svelte'
|
|
|
|
export let miniplayer = false
|
|
export let page = 'home'
|
|
export let overlay = 'none'
|
|
</script>
|
|
|
|
<Player files={$processed} {miniplayer} media={$nowPlaying} bind:playFile bind:page bind:overlay on:current={handleCurrent} />
|