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/List/MediaRoulette.svelte | |
| 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/List/MediaRoulette.svelte')
| -rw-r--r-- | src/lib/List/MediaRoulette.svelte | 294 |
1 files changed, 294 insertions, 0 deletions
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> |