Merge pull request #409 from michielcx/feature/progress

feat: Episode Progress Tracking and Resume
This commit is contained in:
Cas_ 2024-02-02 11:13:47 +01:00 committed by GitHub
commit 8bb711a911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 119 additions and 3 deletions

View file

@ -2,8 +2,9 @@
import { statusColorMap } from '@/modules/anime.js'
import EpisodePreviewCard from './EpisodePreviewCard.svelte'
import { hoverClick } from '@/modules/click.js'
import { since } from '@/modules/util'
import { since } from '@/modules/util.js'
import { getContext } from 'svelte'
import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js'
export let data
let preview = false
@ -19,6 +20,8 @@
function setHoverState (state) {
preview = state
}
const progress = liveAnimeEpisodeProgress(media.id, data.episode)
</script>
<div class='d-flex p-20 pb-10 position-relative' use:hoverClick={[data.onclick || viewMedia, setHoverState]}>
@ -34,6 +37,11 @@
{media.duration}m
{/if}
</div>
{#if $progress > 0}
<div class='progress container-fluid position-absolute' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style='width: {$progress}%' />
</div>
{/if}
</div>
<div class='row pt-15'>
<div class='col pr-10'>

View file

@ -1,12 +1,15 @@
<script>
import { statusColorMap, formatMap } from '@/modules/anime.js'
import { since } from '@/modules/util'
import { liveAnimeEpisodeProgress } from '@/modules/animeprogress.js'
export let data
const media = data.media
const episodeThumbnail = ((!media?.mediaListEntry?.status || !(media.mediaListEntry.status === 'CURRENT' && media.mediaListEntry.progress < data.episode)) && data.episodeData?.image) || media?.bannerImage || media?.coverImage.extraLarge || ' '
let hide = true
const progress = liveAnimeEpisodeProgress(media.id, data.episode)
</script>
<div class='position-absolute w-400 mh-400 absolute-container top-0 m-auto bg-dark-light z-30 rounded overflow-hidden pointer d-flex flex-column'>
@ -29,6 +32,11 @@
{media.duration}m
{/if}
</div>
{#if $progress > 0}
<div class='progress container-fluid position-absolute mb-5'>
<div class='progress-bar' style='width: {$progress}%' />
</div>
{/if}
</div>
<div class='w-full d-flex flex-column flex-grow-1 px-20 pb-15'>
<div class='row pt-15'>

View file

@ -37,6 +37,7 @@
</div>
{/if}
<img loading='lazy' src={media.coverImage.extraLarge || ''} alt='cover' class='cover-img w-full rounded' style:--color={media.coverImage.color || '#1890ff'} />
<div class='text-white font-weight-very-bold font-size-16 pt-15 title overflow-hidden'>
{#if media.mediaListEntry?.status}
<div style:--statusColor={statusColorMap[media.mediaListEntry.status]} class='list-status-circle d-inline-flex overflow-hidden mr-5' title={media.mediaListEntry.status} />

View file

@ -0,0 +1,69 @@
import { writable, derived } from 'simple-store-svelte'
// Maximum number of entries to keep in LocalStorage
const maxEntries = 1000
// LocalStorage is structured as an array of objects with the following properties:
// mediaId, episode, currentTime, safeduration, createdAt, updatedAt
function loadFromLocalStorage() {
const data = localStorage.getItem('animeEpisodeProgress')
return data ? JSON.parse(data) : []
}
function saveToLocalStorage(data) {
localStorage.setItem('animeEpisodeProgress', JSON.stringify(data))
animeProgressStore.set(data)
}
const animeProgressStore = writable(loadFromLocalStorage())
// Return an object with the progress of each episode in percent (0-100), keyed by episode number
export function liveAnimeProgress (mediaId){
return derived(animeProgressStore, (data) => {
if (!mediaId) return {}
const results = data.filter(item => item.mediaId === mediaId)
if (!results) return {}
// Return an object with the episode as the key and the progress as the value
return Object.fromEntries(results.map(result => [
result.episode,
Math.ceil(result.currentTime / result.safeduration * 100)
]))
})
}
// Return an individual episode's progress in percent (0-100)
export function liveAnimeEpisodeProgress (mediaId, episode) {
return derived(animeProgressStore, (data) => {
if (!mediaId || !episode) return 0
const result = data.find(item => item.mediaId === mediaId && item.episode === episode)
if (!result) return 0
return Math.ceil(result.currentTime / result.safeduration * 100)
})
}
// Return an individual episode's record { mediaId, episode, currentTime, safeduration, createdAt, updatedAt }
export function getAnimeProgress(mediaId, episode) {
const data = loadFromLocalStorage()
return data.find(item => item.mediaId === mediaId && item.episode === episode)
}
// Set an individual episode's progress
export function setAnimeProgress({ mediaId, episode, currentTime, safeduration }) {
if (!mediaId || !episode || !currentTime || !safeduration) return
const data = loadFromLocalStorage()
// Update the existing entry or create a new one
const existing = data.find(item => item.mediaId === mediaId && item.episode === episode)
if (existing) {
existing.currentTime = currentTime
existing.safeduration = safeduration
existing.updatedAt = Date.now()
} else {
data.push({ mediaId, episode, currentTime, safeduration, createdAt: Date.now(), updatedAt: Date.now() })
}
// Remove the oldest entries if we have too many
while (data.length > maxEntries) {
const oldest = data.reduce((a, b) => a.updatedAt < b.updatedAt ? a : b)
data.splice(data.indexOf(oldest), 1)
}
saveToLocalStorage(data)
}

View file

@ -23,4 +23,4 @@
"video-deband": "^1.0.5",
"webpack-merge": "^5.10.0"
}
}
}

View file

@ -1,5 +1,6 @@
<script>
import { settings } from '@/modules/settings.js'
import { getAnimeProgress, setAnimeProgress } from '@/modules/animeprogress.js'
import { playAnime } from '../RSSView.svelte'
import { client } from '@/modules/torrent.js'
import { createEventDispatcher } from 'svelte'
@ -173,6 +174,7 @@
client.send('current', file)
subs = new Subtitles(video, files, current, handleHeaders)
video.load()
await loadAnimeProgress()
}
}
@ -198,6 +200,25 @@
}
}
async function loadAnimeProgress () {
if (!current?.media?.media?.id || !current.media.media.episode || current.media.failed || !media?.media?.id || !media.episode) return
const animeProgress = await getAnimeProgress(current.media.media.id, current.media.episode)
if (!animeProgress) return
const currentTime = Math.max(animeProgress.currentTime - 5, 0) // Load 5 seconds before
seek(currentTime - video.currentTime)
}
function saveAnimeProgress () {
if (!current?.media?.media?.id || !current.media.media.episode || current.media.failed || !media?.media?.id || !media.episode) return
if (buffering || paused || video.readyState < 4) return
setAnimeProgress({ mediaId: current.media.media.id, episode: current.media.episode, currentTime: video.currentTime, safeduration })
}
setInterval(saveAnimeProgress, 30000)
function cycleSubtitles () {
if (current && subs?.headers) {
const tracks = subs.headers.filter(header => header)
@ -970,6 +991,7 @@
on:loadedmetadata={autoPlay}
on:loadedmetadata={checkAudio}
on:loadedmetadata={clearLoadInterval}
on:loadedmetadata={loadAnimeProgress}
on:leavepictureinpicture={() => { pip = false }} />
{#if stats}
<div class='position-absolute top-0 bg-tp p-10 m-15 text-monospace rounded z-50'>

View file

@ -2,7 +2,8 @@
import { since } from '@/modules/util'
import { click } from '@/modules/click.js'
import { getEpisodeNumberByAirDate } from '@/modules/providers/tosho.js'
import { alRequest } from '@/modules/anilist'
import { alRequest } from '@/modules/anilist.js'
import { liveAnimeProgress } from '@/modules/animeprogress.js'
export let media
@ -40,11 +41,14 @@
}
}
load()
const animeProgress = liveAnimeProgress(id)
</script>
{#each episodeOrder ? episodeList : [...episodeList].reverse() as { episode, image, summary, rating, title, length, airdate }}
{@const completed = userProgress >= episode}
{@const target = userProgress + 1 === episode}
{@const progress = $animeProgress?.[episode] ?? 0}
<div class='w-full my-20 content-visibility-auto scale' class:opacity-half={completed} 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-xsm-column flex-row pointer' class:border={target} class:bg-black={completed} class:bg-dark={!completed}>
{#if image}
@ -67,6 +71,10 @@
<div class='progress mb-15' style='height: 2px; min-height: 2px;'>
<div class='progress-bar w-full' />
</div>
{:else if progress}
<div class='progress mb-15' style='height: 2px; min-height: 2px;'>
<div class='progress-bar' style="width: {progress}%" />
</div>
{/if}
<div class='font-size-12 overflow-hidden'>
{summary || ''}