Skip to content

Commit 94ac2f1

Browse files
committed
Add base Event and Overlay classes
1 parent c4e142a commit 94ac2f1

File tree

1 file changed

+272
-0
lines changed

1 file changed

+272
-0
lines changed

twitchio/ext/overlays/core.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""
2+
MIT License
3+
4+
Copyright (c) 2017 - Present PythonistaGuild
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import base64
28+
import copy
29+
import html
30+
import importlib.resources
31+
import json
32+
import struct
33+
import zlib
34+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, overload
35+
36+
from .enums import Animation, AnimationSpeed, EventPosition, Font
37+
from .exceptions import BlueprintError
38+
39+
40+
if TYPE_CHECKING:
41+
import os
42+
43+
from aiohttp.web import WebSocketResponse
44+
45+
from .types_ import OverlayEventT, OverlayPartT
46+
47+
48+
__all__ = ("Overlay", "OverlayEvent", "PlayerOverlay")
49+
50+
51+
class OverlayEvent:
52+
__VERSION__: ClassVar[int] = 1
53+
54+
def __init__(self) -> None:
55+
self._raw: OverlayEventT = {
56+
"parts": [],
57+
"audio": "",
58+
"duration": 800000,
59+
"duration_is_audio": False,
60+
"force_override": False,
61+
"stack_event": True,
62+
}
63+
64+
def escape(self, value: str, /) -> str:
65+
return html.escape(value)
66+
67+
def add_html(self) -> Self: ...
68+
69+
def add_text(
70+
self,
71+
content: str,
72+
*,
73+
animation: Animation | None = None,
74+
animation_speed: AnimationSpeed | None = None,
75+
font: Font | None = None, # TODO: Default Font...
76+
size: int = 24,
77+
) -> Self:
78+
# TODO: Font...
79+
80+
if not isinstance(size, int): # pyright: ignore[reportUnnecessaryIsInstance]
81+
raise TypeError(f"Parameter 'size' expected 'int' got {type(size)!r}.")
82+
83+
escaped = self.escape(content)
84+
middle = f" animate__{animation.value}" if animation else ""
85+
speed = f" animate__{animation_speed.value}" if animation_speed else ""
86+
87+
part: OverlayPartT = {"content": escaped, "animation": middle, "speed": speed, "size": size}
88+
self._raw["parts"].append(part)
89+
90+
return self
91+
92+
def add_image(self) -> Self: ...
93+
94+
def set_audio(self) -> Self: ...
95+
96+
def set_position(self) -> Self: ...
97+
98+
def set_duration(self, value: int, *, as_audio: bool = False) -> Self: ...
99+
100+
@overload
101+
def _compress(self, convert: Literal[True] = True) -> str: ...
102+
103+
@overload
104+
def _compress(self, convert: Literal[False]) -> bytearray: ...
105+
106+
def _compress(self, convert: bool = True) -> str | bytearray:
107+
dump = json.dumps(self.as_dict()).encode(encoding="UTF-8")
108+
bites = bytearray()
109+
110+
# Version Header: 2 Bytes
111+
bites[:2] = struct.pack(">H", 0)
112+
# Compressed Data
113+
bites[2:] = zlib.compress(dump, level=9)
114+
115+
if convert:
116+
return base64.b64encode(bites).decode()
117+
118+
return bites
119+
120+
@classmethod
121+
def from_blueprint(cls, template_string: str, /) -> Self:
122+
decoded = base64.b64decode(template_string)
123+
version = struct.unpack(">H", decoded[:2])
124+
125+
if not version:
126+
raise BlueprintError("Blueprint is missing the version header.")
127+
128+
if version[0] != cls.__VERSION__:
129+
raise BlueprintError(f"Blueprint version mismtach: got '{version}' excpected '{cls.__VERSION__}'.")
130+
131+
value = decoded[2:]
132+
decompressed = zlib.decompress(value)
133+
134+
inst = cls()
135+
inst._raw = json.loads(decompressed)
136+
137+
return inst
138+
139+
def to_blueprint(self) -> str:
140+
return self._compress()
141+
142+
def as_dict(self) -> OverlayEventT:
143+
return copy.deepcopy(self._raw)
144+
145+
146+
class Overlay:
147+
__sockets__: dict[str, WebSocketResponse]
148+
__title__: str = ""
149+
__template__: str = ""
150+
__javascript__: str = ""
151+
__stylesheet__: str = ""
152+
153+
def __new__(cls, *args: Any, **Kwargs: Any) -> Self:
154+
inst = super().__new__(cls)
155+
156+
pack = importlib.resources.files(__package__ or "twitchio.ext.overlays")
157+
static = pack / "static"
158+
159+
# Load default static files...
160+
for file in static.iterdir():
161+
name = file.name
162+
163+
if name == "scripts.js":
164+
inst.__javascript__ = inst.__javascript__ or file.read_text(encoding="UTF-8")
165+
elif name == "styles.css":
166+
inst.__stylesheet__ = inst.__stylesheet__ or file.read_text(encoding="UTF-8")
167+
elif name == "template":
168+
inst.__template__ = inst.__template__ or file.read_text(encoding="UTF-8")
169+
170+
# Set default title...
171+
if not inst.__title__:
172+
inst.__title__ = cls.__qualname__
173+
174+
inst.__sockets__ = {}
175+
return inst
176+
177+
def __init__(self, *, secret: str) -> None:
178+
self._secret = secret
179+
self._position = EventPosition.center
180+
181+
@property
182+
def secret(self) -> str:
183+
return self._secret
184+
185+
async def setup(self) -> str | None: ...
186+
187+
async def close(self) -> str | None:
188+
for id_, sock in self.__sockets__.items():
189+
try:
190+
await sock.close()
191+
except Exception:
192+
# TODO: Logging...
193+
pass
194+
195+
self.__sockets__.pop(id_, None)
196+
197+
async def trigger(self, event: OverlayEvent, *, skip_queue: bool = False) -> None:
198+
# TODO: Check connected?
199+
if not isinstance(event, OverlayEvent): # pyright: ignore[reportUnnecessaryIsInstance]
200+
raise TypeError(f"Expected OverlayEvent or derivative for {self!r} trigger, got {event!r}.")
201+
202+
# TODO: Skip Queue...
203+
await self._push(event)
204+
205+
async def _push(self, event: OverlayEvent) -> None:
206+
for id, sock in self.__sockets__.copy().items():
207+
if sock.closed:
208+
self.__sockets__.pop(id, None)
209+
210+
data = {"eventData": event.as_dict(), "position": self._position.value}
211+
try:
212+
await sock.send_json(data)
213+
except Exception:
214+
# TODO: Logging...
215+
pass
216+
217+
def connected(self) -> bool: ...
218+
219+
@property
220+
def template(self) -> str:
221+
return self.__template__
222+
223+
@property
224+
def javascript(self) -> str:
225+
return self.__javascript__
226+
227+
@property
228+
def stylesheet(self) -> str:
229+
return self.__stylesheet__
230+
231+
@property
232+
def title(self) -> str:
233+
return self.__title__
234+
235+
def set_template(self, path: os.PathLike[str] | str, *, content: str | None = None) -> None:
236+
if content:
237+
self.__template__ = content
238+
return
239+
240+
with open(path) as fp:
241+
self.__template__ = fp.read()
242+
243+
def set_stylesheet(self, path: os.PathLike[str] | str, *, content: str | None = None) -> None:
244+
if content:
245+
self.__stylesheet__ = content
246+
return
247+
248+
with open(path) as fp:
249+
self.__stylesheet__ = fp.read()
250+
251+
def set_javascript(self, path: os.PathLike[str] | str, *, content: str | None = None) -> None:
252+
if content:
253+
self.__javascript__ = content
254+
return
255+
256+
with open(path) as fp:
257+
self.__javascript__ = fp.read()
258+
259+
def set_position(self, position: EventPosition, /) -> Self:
260+
self._position = position
261+
return self
262+
263+
def generate_html(self) -> str:
264+
template = self.template
265+
js = self.javascript
266+
css = self.stylesheet
267+
title = self.title
268+
269+
return template.format(stylesheet=css, javascript=js, title=title)
270+
271+
272+
class PlayerOverlay(Overlay): ...

0 commit comments

Comments
 (0)