224 lines
9.6 KiB
Python
224 lines
9.6 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 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()
|
|
if 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.Close):
|
|
self.dismiss()
|
|
elif event.type() == QEvent.Type.WindowStateChange and self.owner_window.isMinimized():
|
|
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()
|