Files
video-sync/desktop-client/fight_reactions.py
T

226 lines
9.7 KiB
Python

"""Native ringside overlay and small, original synthesized reaction sounds."""
import math
from pathlib import Path
import struct
import wave
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent
from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform
from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
REACTIONS = {
"ko": ("K.O.", "That's a wrap.", "#ff5676"),
"bell": ("DING!", "Back to your corners.", "#f5c86c"),
"tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
"robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
}
def write_reaction_sound(directory, kind):
"""Generate mono PCM once per cache directory, with no downloaded samples."""
if kind not in REACTIONS:
raise ValueError("Unknown reaction")
path = Path(directory) / f"{kind}.wav"
if path.exists():
return path
path.parent.mkdir(parents=True, exist_ok=True)
rate = 22050
duration = .85 if kind == "bell" else .4
samples = bytearray()
for i in range(int(rate * duration)):
t = i / rate
attack = min(1, t / .008)
if kind == "ko":
value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
elif kind == "bell":
value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
elif kind == "tap":
pulse = t if t < .16 else t - .16
value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
else:
value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
release = min(1, (duration - t) / .02)
samples.extend(struct.pack("<h", int(16000 * value * attack * release)))
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
output.writeframes(samples)
return path
class ArcadeKnockout(QWidget):
"""Vector-painted arcade lettering scales cleanly with the video surface."""
def __init__(self, parent):
super().__init__(parent)
self.progress = 0.0
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def set_progress(self, value):
self.progress = value
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
t = self.progress
opacity = min(1.0, t / .035, max(0.0, (1.0 - t) / .18))
painter.setOpacity(opacity)
painter.translate(self.width() / 2, self.height() / 2)
entry = min(1.0, t / .18)
scale = 1.0 + .65 * (1 - entry) ** 3 - .06 * math.sin(entry * math.pi)
painter.scale(scale, scale)
font = QFont("Impact")
font.setPixelSize(180)
font.setBold(True)
letters = QPainterPath()
letters.addText(0, 0, font, "K.O.")
letters = QTransform().shear(-.12, 0).map(letters)
bounds = letters.boundingRect()
fit = min(self.width() * .63 / bounds.width(), self.height() * .40 / bounds.height())
painter.scale(fit, fit)
painter.translate(-bounds.center())
shadow = QTransform().translate(7, 10).map(letters)
painter.setPen(QPen(QColor("#131017"), 18, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#131017"))
painter.drawPath(shadow)
painter.setPen(QPen(QColor("#8b1028"), 14, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#f33c35"))
painter.drawPath(letters)
gradient = QLinearGradient(0, bounds.top(), 0, bounds.bottom())
gradient.setColorAt(0, QColor("#fff7b0"))
gradient.setColorAt(.40, QColor("#ffe252"))
gradient.setColorAt(1, QColor("#ff9c22"))
painter.setPen(QPen(QColor("#ff583e"), 5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(gradient)
painter.drawPath(letters)
if t < .32:
painter.setOpacity(opacity * (1 - t / .32) * .55)
painter.setClipPath(letters)
sweep = bounds.left() + bounds.width() * min(1.0, t / .32)
painter.fillRect(int(sweep), int(bounds.top() - 10), 16, int(bounds.height() + 20), QColor("#fffef0"))
class RingsideStrip(QWidget):
"""A separate native window stays above VLC's native video surface."""
def __init__(self, video_frame):
super().__init__(video_frame, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.WindowTransparentForInput)
self.video_frame = video_frame
self.owner_window = None
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.panel = QFrame()
outer.addWidget(self.panel)
self.ko_canvas = ArcadeKnockout(self)
outer.addWidget(self.ko_canvas)
self.ko_canvas.hide()
self.kind = None
self.ko_animation = QVariantAnimation(self)
self.ko_animation.setDuration(2200)
self.ko_animation.setStartValue(0.0)
self.ko_animation.setEndValue(1.0)
self.ko_animation.valueChanged.connect(self.ko_canvas.set_progress)
row = QHBoxLayout(self.panel)
row.setContentsMargins(14, 10, 14, 10)
row.setSpacing(14)
self.headline = QLabel()
self.headline.setTextFormat(Qt.TextFormat.PlainText)
self.caption = QLabel()
self.caption.setTextFormat(Qt.TextFormat.PlainText)
self.caption.setWordWrap(True)
self.caption.setMinimumWidth(0)
self.caption.setStyleSheet("color: #f2f2f5; background: transparent; font-size: 13px;")
self.metadata = QLabel()
self.metadata.setStyleSheet("color: #a6a9b5; background: transparent; font-size: 11px;")
details = QVBoxLayout()
details.setSpacing(3)
details.addWidget(self.caption)
details.addWidget(self.metadata)
row.addWidget(self.headline)
row.addLayout(details, 1)
self.dismiss_timer = QTimer(self)
self.dismiss_timer.setSingleShot(True)
self.dismiss_timer.timeout.connect(self.dismiss)
self.slide = QPropertyAnimation(self, b"pos", self)
self.slide.setDuration(180)
self.slide.setEasingCurve(QEasingCurve.Type.OutCubic)
video_frame.installEventFilter(self)
def show_reaction(self, kind, author, position):
if kind not in REACTIONS or not self.video_frame.isVisible():
return False
owner = self.video_frame.window()
# Avoid displaying an always-on-top effect over another application.
active = QApplication.activeWindow()
while active is not None and active is not owner:
active = active.parentWidget()
if active is None or owner.isMinimized():
return False
if self.owner_window is not owner:
if self.owner_window is not None:
self.owner_window.removeEventFilter(self)
self.owner_window = owner
owner.installEventFilter(self)
title, caption, color = REACTIONS[kind]
self.headline.setText(title)
self.headline.setStyleSheet(f"color: {color}; background: transparent; font-size: 24px; font-weight: bold;")
self.caption.setText(f"{author}: {caption}")
self.metadata.setText(f"Room reaction · {position}")
self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}")
self.kind = kind
self.panel.setVisible(kind != "ko")
self.ko_canvas.setVisible(kind == "ko")
self.ko_animation.stop()
self.slide.stop()
self._place()
if kind == "ko":
self.ko_canvas.set_progress(0.0)
self.show()
self.ko_animation.start()
self.dismiss_timer.start(2200)
return True
end = self.pos()
self.move(end + QPoint(0, 12))
self.show()
self.slide.setStartValue(self.pos())
self.slide.setEndValue(end)
self.slide.start()
self.dismiss_timer.start(2200)
return True
def _place(self):
if self.kind == "ko":
self.setFixedSize(self.video_frame.size())
self.move(self.video_frame.mapToGlobal(QPoint(0, 0)))
return
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
width = max(1, min(540, self.video_frame.width() - 32))
self.setFixedWidth(width)
height = self.layout().heightForWidth(width)
self.setFixedHeight(max(64, height))
origin = self.video_frame.mapToGlobal(QPoint(16, self.video_frame.height() - self.height() - 16))
self.move(origin)
def eventFilter(self, watched, event):
if self.isVisible():
if event.type() in (QEvent.Type.Hide, QEvent.Type.WindowDeactivate, QEvent.Type.Close):
self.dismiss()
elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
self.slide.stop()
self._place()
return super().eventFilter(watched, event)
def dismiss(self):
self.ko_animation.stop()
self.slide.stop()
self.dismiss_timer.stop()
self.hide()