Skip to content

Commit 51853c8

Browse files
committed
Обновление: улучшены анимации и стили в компонентах, добавлен новый компонент MarqueeTrackTitle для прокрутки названий треков
1 parent f883a71 commit 51853c8

File tree

9 files changed

+304
-117
lines changed

9 files changed

+304
-117
lines changed

src/components/OBS_Components/MikuMonday/MikuMonday.module.scss

Lines changed: 54 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -260,120 +260,103 @@
260260
}
261261

262262
.result-stage {
263-
width: min(70vw, 1020px);
264-
max-width: 100%;
265-
display: grid;
266-
grid-template-columns: minmax(220px, 300px) 1fr;
267-
gap: 32px;
268-
padding: 38px 44px;
269-
border-radius: 32px;
270-
border: none;
271-
background: linear-gradient(140deg,
272-
rgba(var(--bs-secondary-rgb), 0.24) 0%,
273-
rgba(18, 22, 40, 0.85) 45%,
274-
rgba(8, 10, 18, 0.95) 100%);
275-
box-shadow:
276-
0 20px 50px rgba(8, 10, 18, 0.7),
277-
inset 0 0 42px rgba(255, 255, 255, 0.06);
278-
color: var(--site-text-light);
279-
}
280-
281-
.result-visual {
263+
width: min(56vw, 720px);
264+
max-width: 90vw;
265+
min-height: 360px;
266+
position: relative;
267+
overflow: hidden;
282268
display: flex;
269+
flex-direction: column;
283270
align-items: center;
284271
justify-content: center;
285-
padding: 6px;
286-
border-radius: 26px;
272+
gap: 10px;
273+
padding: 28px 32px;
274+
border-radius: 28px;
287275
border: none;
288-
background: radial-gradient(circle, rgba(var(--bs-primary-rgb), 0.35) 0%, rgba(0, 0, 0, 0.8) 100%);
276+
background: linear-gradient(140deg,
277+
rgba(var(--bs-secondary-rgb), 0.22) 0%,
278+
rgba(18, 22, 40, 0.68) 45%,
279+
rgba(8, 10, 18, 0.82) 100%);
280+
background-size: cover;
281+
background-position: center;
282+
background-repeat: no-repeat;
289283
box-shadow:
290-
0 0 32px rgba(var(--bs-primary-rgb), 0.55),
291-
inset 0 0 20px rgba(0, 0, 0, 0.65);
292-
}
293-
294-
.result-cover {
295-
width: 100%;
296-
border-radius: 20px;
297-
object-fit: cover;
298-
aspect-ratio: 1;
299-
box-shadow: var(--site-shadow-medium);
300-
}
301-
302-
.result-cover-placeholder {
303-
width: 100%;
304-
aspect-ratio: 1;
305-
display: flex;
306-
align-items: center;
307-
justify-content: center;
308-
border-radius: 20px;
309-
border: 2px dashed var(--site-border-secondary);
310-
background: rgba(var(--bs-primary-rgb), 0.15);
311-
color: rgba(255, 255, 255, 0.75);
312-
font-weight: 600;
313-
text-transform: uppercase;
314-
letter-spacing: 0.1em;
284+
0 18px 45px rgba(8, 10, 18, 0.7),
285+
inset 0 0 36px rgba(255, 255, 255, 0.05);
286+
text-align: center;
287+
color: var(--site-text-light);
315288
}
316289

317-
.result-cover-placeholder-text {
318-
font-size: 1.6rem;
290+
.result-stage::before {
291+
content: "";
292+
position: absolute;
293+
inset: 0;
294+
background: linear-gradient(180deg,
295+
rgba(0, 0, 0, 0.28) 0%,
296+
rgba(0, 0, 0, 0.48) 55%,
297+
rgba(0, 0, 0, 0.62) 100%);
298+
z-index: 0;
319299
}
320300

321301
.result-content {
302+
position: relative;
303+
z-index: 1;
322304
display: flex;
323305
flex-direction: column;
324-
gap: 16px;
306+
align-items: center;
307+
gap: 10px;
308+
max-width: 520px;
325309
color: var(--site-text-light);
310+
text-align: center;
326311
}
327312

328313
.result-heading {
329-
font-size: clamp(2.4rem, 4.5vw, 3rem);
314+
font-size: clamp(1.8rem, 3.2vw, 2.2rem);
330315
font-weight: 700;
331316
letter-spacing: 0.04em;
332-
color: #fff;
317+
text-wrap: balance;
333318
}
334319

335320
.result-user {
336-
font-size: clamp(2.6rem, 5vw, 3.5rem);
321+
font-size: clamp(2rem, 4vw, 2.6rem);
337322
font-weight: 800;
338-
}
339-
340-
.result-user-login {
341-
font-size: 1.6rem;
342-
font-weight: 600;
343-
letter-spacing: 0.1em;
344-
text-transform: uppercase;
345-
color: rgba(255, 255, 255, 0.7);
323+
text-wrap: balance;
346324
}
347325

348326
.result-track-info {
349327
display: flex;
350328
flex-direction: column;
329+
align-items: center;
351330
gap: 6px;
352-
margin-top: 12px;
331+
margin-top: 6px;
353332
}
354333

355334
.result-track-title {
356-
font-size: clamp(2.2rem, 4vw, 3rem);
335+
font-size: clamp(1.6rem, 3vw, 2.2rem);
357336
font-weight: 700;
358-
color: #fff;
337+
width: 100%;
338+
display: flex;
339+
align-items: center;
359340
}
360341

361342
.result-track-meta {
362-
font-size: 1.4rem;
343+
font-size: 1.2rem;
363344
font-weight: 600;
364-
color: rgba(255, 255, 255, 0.75);
345+
color: #fff;
365346
letter-spacing: 0.08em;
366347
text-transform: uppercase;
367348
}
368349

369350
.result-message {
370-
margin-top: 14px;
371-
font-size: 1.4rem;
351+
margin-top: 6px;
352+
font-size: 1.2rem;
372353
font-weight: 500;
373-
color: rgba(255, 255, 255, 0.82);
374-
max-width: 520px;
375-
line-height: 1.6;
354+
color: rgba(255, 255, 255, 0.65);
355+
max-width: 460px;
356+
line-height: 1.4;
357+
text-wrap: balance;
376358
}
359+
377360
.custom-prize {
378361
position: relative;
379362
display: flex;

src/components/OBS_Components/MikuMonday/MikuMonday.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function MikuMondayContent() {
164164
if (!currentAlert || stage === "waiting") {
165165
return (
166166
<div className={styles.layout}>
167-
<WaitingStage />
167+
<WaitingStage onComplete={() => {}} />
168168
</div>
169169
);
170170
}

src/components/OBS_Components/MikuMonday/components/CustomRoulette/CustomRoulette.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export default function CustomRoulette({
3838
spinningTime,
3939
});
4040

41-
setIsSpinning(true);
41+
// Запускаем флаг анимации асинхронно, чтобы избежать каскадных рендеров
42+
const frameId = requestAnimationFrame(() => setIsSpinning(true));
4243

4344
// Создаем зацикленный массив призов (повторяем 5 раз для плавной прокрутки)
4445
const containerWidth = containerRef.current?.offsetWidth || 1000;
@@ -67,8 +68,10 @@ export default function CustomRoulette({
6768
centerOffset,
6869
});
6970

70-
// Запускаем анимацию
71-
setTranslateX(endPosition);
71+
// Запускаем анимацию асинхронно, чтобы избежать синхронного setState в эффекте
72+
const translateFrameId = requestAnimationFrame(() => {
73+
setTranslateX(endPosition);
74+
});
7275

7376
// Уведомляем о завершении
7477
const timer = setTimeout(() => {
@@ -80,8 +83,20 @@ export default function CustomRoulette({
8083
onComplete?.();
8184
}, spinningTime * 1000);
8285

83-
return () => clearTimeout(timer);
84-
}, [start, prizes, prizeIndex, isReversed, spinningTime, onComplete]);
86+
return () => {
87+
cancelAnimationFrame(frameId);
88+
cancelAnimationFrame(translateFrameId);
89+
clearTimeout(timer);
90+
};
91+
}, [
92+
start,
93+
prizes,
94+
prizeIndex,
95+
isReversed,
96+
spinningTime,
97+
onComplete,
98+
isSpinning,
99+
]);
85100

86101
// Создаем повторяющийся массив призов
87102
const repeatedPrizes = Array(5).fill(prizes).flat();
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { motion } from "framer-motion";
2+
import { useRef, useState } from "react";
3+
4+
interface MarqueeTrackTitleProps {
5+
text: string;
6+
className: string;
7+
}
8+
9+
const GAP_SIZE = 56; // пиксели для промежутка
10+
const ANIMATION_DURATION = 12; // секунды на полный цикл
11+
12+
export default function MarqueeTrackTitle({
13+
text,
14+
className,
15+
}: MarqueeTrackTitleProps) {
16+
const [key, setKey] = useState(0);
17+
const containerRef = useRef<HTMLDivElement>(null);
18+
19+
const handleAnimationComplete = () => {
20+
setKey(prev => prev + 1);
21+
};
22+
23+
return (
24+
<div
25+
ref={containerRef}
26+
className={className}
27+
style={{
28+
overflow: "hidden",
29+
width: "100%",
30+
position: "relative",
31+
display: "flex",
32+
alignItems: "center",
33+
}}
34+
>
35+
<motion.div
36+
key={key}
37+
initial={{ x: 0 }}
38+
animate={{ x: "calc(-100% - 56px)" }}
39+
transition={{
40+
duration: ANIMATION_DURATION,
41+
ease: "linear",
42+
}}
43+
onAnimationComplete={handleAnimationComplete}
44+
style={{
45+
display: "flex",
46+
whiteSpace: "nowrap",
47+
gap: `${GAP_SIZE}px`,
48+
willChange: "transform",
49+
}}
50+
>
51+
<span style={{ color: "white", flexShrink: 0 }}>{text}</span>
52+
</motion.div>
53+
</div>
54+
);
55+
}

src/components/OBS_Components/MikuMonday/components/stages/IntroStage.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22

33
import type { TwitchUser } from "@/shared/api";
44
import animate from "@/shared/styles/animate.module.scss";
@@ -14,17 +14,26 @@ interface IntroStageProps {
1414
}
1515

1616
const DEFAULT_INTRO_DURATION = 3200;
17+
const FADE_OUT_DURATION = 600;
1718

1819
export default function IntroStage({
1920
twitchUser,
2021
fallbackAvatar,
2122
durationMs = DEFAULT_INTRO_DURATION,
2223
onComplete,
2324
}: IntroStageProps) {
25+
const [isFadingOut, setIsFadingOut] = useState(false);
26+
2427
useEffect(() => {
25-
const timerId = window.setTimeout(onComplete, durationMs);
28+
const fadeOutTimerId = window.setTimeout(() => {
29+
setIsFadingOut(true);
30+
}, durationMs - FADE_OUT_DURATION);
31+
32+
const completeTimerId = window.setTimeout(onComplete, durationMs);
33+
2634
return () => {
27-
window.clearTimeout(timerId);
35+
window.clearTimeout(fadeOutTimerId);
36+
window.clearTimeout(completeTimerId);
2837
};
2938
}, [durationMs, onComplete]);
3039

@@ -42,7 +51,9 @@ export default function IntroStage({
4251

4352
return (
4453
<article
45-
className={`${styles["intro-stage"]} ${animate.animated} ${animate.fadeIn}`}
54+
className={`${styles["intro-stage"]} ${animate.animated} ${
55+
isFadingOut ? animate.fadeOut : animate.fadeIn
56+
}`}
4657
>
4758
<div className={styles["intro-text-block"]}>
4859
<span

0 commit comments

Comments
 (0)