aboutsummaryrefslogtreecommitdiff
path: root/src/lib/List/MediaRoulette.svelte
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-23 05:55:30 -0800
committerFuwn <[email protected]>2026-01-23 05:55:30 -0800
commitbf5be037799e4418b86676c1debe8f93fac39a95 (patch)
tree9f4dcbd49d79e3398081cfaa798431b7962347d6 /src/lib/List/MediaRoulette.svelte
parentdeps(houdini): Bump version to next (diff)
downloaddue.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.svelte294
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">&times;</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>