diff options
| author | Fuwn <[email protected]> | 2026-01-23 05:55:30 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-23 05:55:30 -0800 |
| commit | bf5be037799e4418b86676c1debe8f93fac39a95 (patch) | |
| tree | 9f4dcbd49d79e3398081cfaa798431b7962347d6 /src/lib | |
| parent | deps(houdini): Bump version to next (diff) | |
| download | due.moe-bf5be037799e4418b86676c1debe8f93fac39a95.tar.xz due.moe-bf5be037799e4418b86676c1debe8f93fac39a95.zip | |
feat(List): Add media roulette
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/List/Anime/CleanAnimeList.svelte | 18 | ||||
| -rw-r--r-- | src/lib/List/Manga/CleanMangaList.svelte | 15 | ||||
| -rw-r--r-- | src/lib/List/MediaRoulette.svelte | 294 | ||||
| -rw-r--r-- | src/lib/Settings/Categories/Display.svelte | 7 |
4 files changed, 333 insertions, 1 deletions
diff --git a/src/lib/List/Anime/CleanAnimeList.svelte b/src/lib/List/Anime/CleanAnimeList.svelte index b7b7a6cb..fef76c03 100644 --- a/src/lib/List/Anime/CleanAnimeList.svelte +++ b/src/lib/List/Anime/CleanAnimeList.svelte @@ -18,6 +18,7 @@ import CleanList from '../CleanList.svelte'; import stateBin from '$stores/stateBin'; import localforage from 'localforage'; + import MediaRoulette from '../MediaRoulette.svelte'; export let media: Media[]; export let title: any; @@ -33,6 +34,7 @@ export let dummy = false; export let disableFilter = false; + let showRoulette = false; let keyCacher: NodeJS.Timeout; let totalEpisodeDueCount = media .map((anime) => { @@ -145,7 +147,17 @@ {title} hideTime={dummy} hideCount={dummy} -/> +> + {#if $settings.displayMediaRoulette && !upcoming && !notYetReleased && filteredMedia.length > 0} + <button + class="small-button" + onclick={() => (showRoulette = true)} + title="Pick a random anime to watch" + > + Roulette + </button> + {/if} +</ListTitle> {#if media.length === 0} No anime to display. <button onclick={() => (animeLists = cleanCache(user, $identity))}> @@ -229,3 +241,7 @@ </span> </CleanList> {/if} + +{#if showRoulette && !upcoming && !notYetReleased} + <MediaRoulette media={filteredMedia} type="anime" onClose={() => (showRoulette = false)} /> +{/if} diff --git a/src/lib/List/Manga/CleanMangaList.svelte b/src/lib/List/Manga/CleanMangaList.svelte index ecf7fa5a..c337a053 100644 --- a/src/lib/List/Manga/CleanMangaList.svelte +++ b/src/lib/List/Manga/CleanMangaList.svelte @@ -16,6 +16,7 @@ import CleanList from '../CleanList.svelte'; import stateBin from '$stores/stateBin'; import localforage from 'localforage'; + import MediaRoulette from '../MediaRoulette.svelte'; export let media: Media[]; export let cleanCache: () => void; @@ -33,6 +34,7 @@ export let dummy = false; export let disableFilter = false; + let showRoulette = false; let serviceStatusResponse: Promise<Response>; let totalEpisodeDueCount = media .map((manga) => { @@ -96,6 +98,15 @@ onclick={cleanCache} data-umami-event="Force Refresh Manga">Refresh</button > + {#if $settings.displayMediaRoulette && filteredMedia.length > 0} + <button + class="small-button" + onclick={() => (showRoulette = true)} + title="Pick a random manga to read" + > + Roulette + </button> + {/if} {/if} </ListTitle> {/if} @@ -209,3 +220,7 @@ </span> </CleanList> {/if} + +{#if showRoulette} + <MediaRoulette media={filteredMedia} type="manga" onClose={() => (showRoulette = false)} /> +{/if} diff --git a/src/lib/List/MediaRoulette.svelte b/src/lib/List/MediaRoulette.svelte new file mode 100644 index 00000000..e5b7ccba --- /dev/null +++ b/src/lib/List/MediaRoulette.svelte @@ -0,0 +1,294 @@ +<script lang="ts"> + import type { Media } from '$lib/Data/AniList/media'; + import ParallaxImage from '$lib/Image/ParallaxImage.svelte'; + import { outboundLink } from '$lib/Media/links'; + import settings from '$stores/settings'; + import { mediaTitle } from './mediaTitle'; + + export let media: Media[]; + export let type: 'anime' | 'manga'; + export let onClose: () => void; + export let spinDuration = 2; + + let isSpinning = false; + let selectedIndex = 0; + let displayIndex = 0; + let spinTimeout: ReturnType<typeof setTimeout> | null = null; + let showResult = false; + + const startRoulette = () => { + if (media.length === 0 || isSpinning) return; + + isSpinning = true; + showResult = false; + selectedIndex = Math.floor(Math.random() * media.length); + + const startTime = Date.now(); + const durationMs = spinDuration * 1000; + const minSpeed = 50; + const maxSpeed = 350; + const slowdownStart = 0.8; + + const spin = () => { + displayIndex = (displayIndex + 1) % media.length; + + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / durationMs, 1); + let speed = minSpeed; + + if (progress > slowdownStart) { + const slowdownProgress = (progress - slowdownStart) / (1 - slowdownStart); + + speed = minSpeed + slowdownProgress * (maxSpeed - minSpeed); + } + + if (progress >= 1 && displayIndex === selectedIndex) { + spinTimeout = null; + isSpinning = false; + showResult = true; + + return; + } + + spinTimeout = setTimeout(spin, speed); + }; + + spinTimeout = setTimeout(spin, minSpeed); + }; + + const handleClose = () => { + if (spinTimeout) { + clearTimeout(spinTimeout); + + spinTimeout = null; + } + + isSpinning = false; + showResult = false; + + onClose(); + }; + + $: currentMedia = media[displayIndex]; + + const handleOverlayClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) handleClose(); + }; +</script> + +<svelte:window onkeydown={(e) => e.key === 'Escape' && handleClose()} /> + +<div + class="roulette-overlay" + role="dialog" + aria-modal="true" + tabindex="-1" + onclick={handleOverlayClick} + onkeydown={() => { + /* */ + }} +> + <div class="roulette-container card"> + <button class="close-button" onclick={handleClose} aria-label="Close roulette">×</button> + + <h3 class="roulette-title"> + {type === 'anime' ? 'Watch' : 'Read'} Roulette + </h3> + + {#if media.length === 0} + <p>No media available for roulette.</p> + {:else} + <div class="roulette-display" class:spinning={isSpinning} class:result={showResult}> + {#if currentMedia} + <div class="media-card"> + <div class="cover-wrapper"> + <ParallaxImage + source={$settings.displayDataSaver + ? currentMedia.coverImage.medium + : currentMedia.coverImage.extraLarge} + alternativeText="Cover" + limit={5} + classList={`roulette-cover${ + currentMedia.isAdult && $settings.displayBlurAdultContent ? ' adult' : '' + }`} + /> + </div> + <div class="media-info"> + <span class="media-title-text">{mediaTitle(currentMedia)}</span> + + {#if showResult} + <a + href={outboundLink(currentMedia, type, $settings.displayOutboundLinksTo)} + target="_blank" + class="view-link" + > + View on {$settings.displayOutboundLinksTo === 'anilist' + ? 'AniList' + : $settings.displayOutboundLinksTo === 'livechartme' + ? 'LiveChart.me' + : $settings.displayOutboundLinksTo === 'animeschedule' + ? 'AnimeSchedule' + : 'MyAnimeList'} + </a> + {/if} + </div> + </div> + {/if} + </div> + + <div class="roulette-actions"> + {#if !isSpinning && !showResult} + <button onclick={startRoulette}>Spin!</button> + {:else if showResult} + <button onclick={startRoulette}>Spin Again</button> + {:else} + <button disabled>Spinning ...</button> + {/if} + </div> + {/if} + </div> +</div> + +<style> + .roulette-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + } + + .roulette-container { + max-width: 400px; + width: 90%; + position: relative; + } + + .close-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: transparent; + box-shadow: none; + color: var(--base04); + font-size: 1.5rem; + padding: 0.25rem 0.5rem; + line-height: 1; + } + + .close-button:hover { + background: transparent; + color: var(--base05); + } + + .roulette-title { + text-align: center; + margin: 0 0 1rem; + font-size: 1.25rem; + color: var(--base05); + } + + .roulette-display { + min-height: 280px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + } + + .roulette-display.spinning .media-card { + animation: pulse 0.15s ease-in-out infinite; + } + + .roulette-display.result .media-card { + animation: celebrate 0.5s ease-out; + } + + @keyframes pulse { + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.02); + } + } + + @keyframes celebrate { + 0% { + transform: scale(1.1); + } + + 50% { + transform: scale(1.05); + } + + 100% { + transform: scale(1); + } + } + + .media-card { + text-align: center; + } + + .cover-wrapper { + width: 150px; + height: 220px; + margin: 0 auto 1rem; + border-radius: 8px; + overflow: hidden; + box-shadow: rgba(0, 0, 11, 0.1) 0px 7px 29px 0px; + } + + .cover-wrapper :global(.roulette-cover) { + width: 100%; + height: 100%; + object-fit: cover; + } + + .cover-wrapper :global(.adult) { + filter: blur(8px); + } + + .media-info { + display: flex; + flex-direction: column; + } + + .media-title-text { + font-weight: 500; + color: var(--base05); + font-size: 1rem; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + margin: 0 auto; + } + + .view-link { + color: var(--base0D); + text-decoration: none; + font-size: 0.875rem; + } + + .view-link:hover { + color: var(--base0C); + text-decoration: underline; + } + + .roulette-actions { + display: flex; + justify-content: center; + } +</style> diff --git a/src/lib/Settings/Categories/Display.svelte b/src/lib/Settings/Categories/Display.svelte index d60da05a..a5f16f97 100644 --- a/src/lib/Settings/Categories/Display.svelte +++ b/src/lib/Settings/Categories/Display.svelte @@ -313,6 +313,13 @@ Anime sort order <SettingCheckboxToggle setting="displayReverseSort" text="Reverse anime sort order" /> <SettingCheckboxToggle setting="displayMediaListFilter" text="Enable media list filter" /> <br /> +<SettingCheckboxToggle setting="displayMediaRoulette" text="Enable media roulette"> + <SettingHint lineBreak> + Adds a roulette button to due and completed media lists to randomly pick something to watch or + read + </SettingHint> +</SettingCheckboxToggle> +<br /> <b>{$locale().settings.display.categories.media.title}</b><br /> <SettingCheckboxToggle |