From fb12bdedd8eb2abbdef11396b6460356b34289cb Mon Sep 17 00:00:00 2001
From: Christoph Stahl <christoph.stahl@tu-dortmund.de>
Date: Wed, 9 Oct 2024 16:17:55 +0200
Subject: [PATCH] Moved away from this awful global client construction. Client
 is now a class, that can be instantiated and contains its state.

---
 syng/client.py                | 670 +++++++++++++++++-----------------
 typings/socketio/__init__.pyi |   4 +-
 2 files changed, 338 insertions(+), 336 deletions(-)

diff --git a/syng/client.py b/syng/client.py
index 7625fda..f3a6e42 100644
--- a/syng/client.py
+++ b/syng/client.py
@@ -43,14 +43,6 @@ from .sources import configure_sources, Source
 from .log import logger
 
 
-sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
-# logger: logging.Logger = logging"Syng"er(__name__)
-sources: dict[str, Source] = {}
-
-
-currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
-
-
 def default_config() -> dict[str, Optional[int | str]]:
     """
     Return a default configuration for the client.
@@ -119,374 +111,380 @@ class State:
     config: dict[str, Any] = field(default_factory=default_config)
 
 
-state: State = State()
+# state: State = State()
 
 
-@sio.on("update_config")
-async def handle_update_config(data: dict[str, Any]) -> None:
-    """
-    Handle the "update_config" message.
+class Client:
+    def __init__(self, config: dict[str, Any]):
+        self.sio = socketio.AsyncClient(json=jsonencoder)
+        self.config = config
+        self.sources = configure_sources(config["sources"])
+        self.state = State()
+        self.currentLock = asyncio.Semaphore(0)
+        self.register_handlers()
 
-    Currently, this function is untested and should be considered dangerous.
+    def register_handlers(self) -> None:
+        self.sio.on("update_config", self.handle_update_config)
+        self.sio.on("skip-current", self.handle_skip_current)
+        self.sio.on("state", self.handle_state)
+        self.sio.on("connect", self.handle_connect)
+        self.sio.on("get-meta-info", self.handle_get_meta_info)
+        self.sio.on("play", self.handle_play)
+        self.sio.on("search", self.handle_search)
+        self.sio.on("client-registered", self.handle_client_registered)
+        self.sio.on("request-config", self.handle_request_config)
 
-    :param data: A dictionary with the new configuration.
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    state.config = default_config() | data
+    async def handle_update_config(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "update_config" message.
 
+        Currently, this function is untested and should be considered dangerous.
 
-@sio.on("skip-current")
-async def handle_skip_current(data: dict[str, Any]) -> None:
-    """
-    Handle the "skip-current" message.
+        :param data: A dictionary with the new configuration.
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        self.state.config = default_config() | data
 
-    Skips the song, that is currently played. If playback currently waits for
-    buffering, the buffering is also aborted.
+    async def handle_skip_current(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "skip-current" message.
 
-    Since the ``queue`` could already be updated, when this evaluates, the
-    first entry in the queue is send explicitly.
+        Skips the song, that is currently played. If playback currently waits for
+        buffering, the buffering is also aborted.
 
-    :param data: An entry, that should be equivalent to the first entry of the
-        queue.
-    :rtype: None
-    """
-    logger.info("Skipping current")
-    if state.current_source is not None:
-        await state.current_source.skip_current(Entry(**data))
+        Since the ``queue`` could already be updated, when this evaluates, the
+        first entry in the queue is send explicitly.
 
+        :param data: An entry, that should be equivalent to the first entry of the
+            queue.
+        :rtype: None
+        """
+        logger.info("Skipping current")
+        if self.state.current_source is not None:
+            await self.state.current_source.skip_current(Entry(**data))
 
-@sio.on("state")
-async def handle_state(data: dict[str, Any]) -> None:
-    """
-    Handle the "state" message.
+    async def handle_state(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "state" message.
 
-    The "state" message forwards the current queue and recent list from the
-    server. This function saves a copy of both in the global
-    :py:class:`State`:.
+        The "state" message forwards the current queue and recent list from the
+        server. This function saves a copy of both in the global
+        :py:class:`State`:.
 
-    After recieving the new state, a buffering task for the first elements of
-    the queue is started.
+        After recieving the new state, a buffering task for the first elements of
+        the queue is started.
 
-    :param data: A dictionary with the `queue` and `recent` list.
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    state.queue = [Entry(**entry) for entry in data["queue"]]
-    state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
-    state.recent = [Entry(**entry) for entry in data["recent"]]
+        :param data: A dictionary with the `queue` and `recent` list.
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        self.state.queue = [Entry(**entry) for entry in data["queue"]]
+        self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
+        self.state.recent = [Entry(**entry) for entry in data["recent"]]
 
-    for entry in state.queue[:2]:
-        logger.info("Buffering: %s", entry.title)
-        await sources[entry.source].buffer(entry)
+        for entry in self.state.queue[:2]:
+            logger.info("Buffering: %s", entry.title)
+            await self.sources[entry.source].buffer(entry)
 
+    async def handle_connect(self) -> None:
+        """
+        Handle the "connect" message.
 
-@sio.on("connect")
-async def handle_connect() -> None:
-    """
-    Handle the "connect" message.
+        Called when the client successfully connects or reconnects to the server.
+        Sends a `register-client` message to the server with the initial state and
+        configuration of the client, consiting of the currently saved
+        :py:attr:`State.queue` and :py:attr:`State.recent` field of the global
+        :py:class:`State`, as well a room code the client wants to connect to, a
+        secret to secure the access to the room and a config dictionary.
 
-    Called when the client successfully connects or reconnects to the server.
-    Sends a `register-client` message to the server with the initial state and
-    configuration of the client, consiting of the currently saved
-    :py:attr:`State.queue` and :py:attr:`State.recent` field of the global
-    :py:class:`State`, as well a room code the client wants to connect to, a
-    secret to secure the access to the room and a config dictionary.
+        If the room code is `None`, the server will issue a room code.
 
-    If the room code is `None`, the server will issue a room code.
+        This message will be handled by the
+        :py:func:`syng.server.handle_register_client` function of the server.
 
-    This message will be handled by the
-    :py:func:`syng.server.handle_register_client` function of the server.
+        :rtype: None
+        """
+        logger.info("Connected to server")
+        data = {
+            "queue": self.state.queue,
+            "waiting_room": self.state.waiting_room,
+            "recent": self.state.recent,
+            "config": self.state.config,
+        }
+        await self.sio.emit("register-client", data)
 
-    :rtype: None
-    """
-    logger.info("Connected to server")
-    data = {
-        "queue": state.queue,
-        "waiting_room": state.waiting_room,
-        "recent": state.recent,
-        "config": state.config,
-    }
-    await sio.emit("register-client", data)
+    async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
+        """
+        Handle a "get-meta-info" message.
 
+        Collects the metadata for a given :py:class:`Entry`, from its source, and
+        sends them back to the server in a "meta-info" message. On the server side
+        a :py:func:`syng.server.handle_meta_info` function is called.
 
-@sio.on("get-meta-info")
-async def handle_get_meta_info(data: dict[str, Any]) -> None:
-    """
-    Handle a "get-meta-info" message.
+        :param data: A dictionary encoding the entry
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        source: Source = self.sources[data["source"]]
+        meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
+        await self.sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
 
-    Collects the metadata for a given :py:class:`Entry`, from its source, and
-    sends them back to the server in a "meta-info" message. On the server side
-    a :py:func:`syng.server.handle_meta_info` function is called.
+    async def preview(self, entry: Entry) -> None:
+        """
+        Generate and play a preview for a given :py:class:`Entry`.
 
-    :param data: A dictionary encoding the entry
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    source: Source = sources[data["source"]]
-    meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
-    await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
+        This function shows a black screen and prints the artist, title and
+        performer of the entry for a duration.
 
+        This is done by creating a black png file, and showing subtitles in the
+        middle of the screen.... don't ask, it works
 
-async def preview(entry: Entry) -> None:
-    """
-    Generate and play a preview for a given :py:class:`Entry`.
-
-    This function shows a black screen and prints the artist, title and
-    performer of the entry for a duration.
-
-    This is done by creating a black png file, and showing subtitles in the
-    middle of the screen.... don't ask, it works
-
-    :param entry: The entry to preview
-    :type entry: :py:class:`Entry`
-    :rtype: None
-    """
-    background = Image.new("RGB", (1280, 720))
-    subtitle: str = f"""1
+        :param entry: The entry to preview
+        :type entry: :py:class:`Entry`
+        :rtype: None
+        """
+        background = Image.new("RGB", (1280, 720))
+        subtitle: str = f"""1
 00:00:00,00 --> 00:05:00,00
 {entry.artist} - {entry.title}
 {entry.performer}"""
-    with tempfile.NamedTemporaryFile() as tmpfile:
-        background.save(tmpfile, "png")
-        process = await asyncio.create_subprocess_exec(
-            "mpv",
-            tmpfile.name,
-            f"--image-display-duration={state.config['preview_duration']}",
-            "--sub-pos=50",
-            "--sub-file=-",
-            "--fullscreen",
-            state.config["mpv_options"],
-            stdin=asyncio.subprocess.PIPE,
-            stdout=asyncio.subprocess.PIPE,
-            stderr=asyncio.subprocess.PIPE,
+        with tempfile.NamedTemporaryFile() as tmpfile:
+            background.save(tmpfile, "png")
+            process = await asyncio.create_subprocess_exec(
+                "mpv",
+                tmpfile.name,
+                f"--image-display-duration={self.state.config['preview_duration']}",
+                "--sub-pos=50",
+                "--sub-file=-",
+                "--fullscreen",
+                self.state.config["mpv_options"],
+                stdin=asyncio.subprocess.PIPE,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            await process.communicate(subtitle.encode())
+
+    async def handle_play(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "play" message.
+
+        Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
+        :py:attr:`State.preview_duration` is set, it shows a small preview before
+        that.
+
+        When the playback is done, the next song is requested from the server with
+        a "pop-then-get-next" message. This is handled by the
+        :py:func:`syng.server.handle_pop_then_get_next` function on the server.
+
+        If the entry is marked as skipped, emit a "get-first"  message instead,
+        because the server already handled the removal of the first entry.
+
+        :param data: A dictionary encoding the entry
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        entry: Entry = Entry(**data)
+        print(
+            f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
+            f"({entry.source}) for {entry.performer}"
         )
-        await process.communicate(subtitle.encode())
-
-
-@sio.on("play")
-async def handle_play(data: dict[str, Any]) -> None:
-    """
-    Handle the "play" message.
-
-    Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
-    :py:attr:`State.preview_duration` is set, it shows a small preview before
-    that.
-
-    When the playback is done, the next song is requested from the server with
-    a "pop-then-get-next" message. This is handled by the
-    :py:func:`syng.server.handle_pop_then_get_next` function on the server.
-
-    If the entry is marked as skipped, emit a "get-first"  message instead,
-    because the server already handled the removal of the first entry.
-
-    :param data: A dictionary encoding the entry
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    entry: Entry = Entry(**data)
-    print(
-        f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
-        f"({entry.source}) for {entry.performer}"
-    )
-    try:
-        state.current_source = sources[entry.source]
-        if state.config["preview_duration"] > 0:
-            await preview(entry)
-        await sources[entry.source].play(entry, state.config["mpv_options"])
-    except Exception:  # pylint: disable=broad-except
-        print_exc()
-    state.current_source = None
-    if entry.skip:
-        await sio.emit("get-first")
-    else:
-        await sio.emit("pop-then-get-next")
-
-
-@sio.on("search")
-async def handle_search(data: dict[str, Any]) -> None:
-    """
-    Handle the "search" message.
-
-    This handles client side search requests. It sends a search request to all
-    configured :py:class:`syng.sources.source.Source` and collects the results.
-
-    The results are then send back to the server in a "search-results" message,
-    including the `sid` of the corresponding webclient.
-
-    :param data: A dictionary with the `query` and `sid` entry.
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    logger.info(f"Searching for: {data['query']}")
-    query = data["query"]
-    sid = data["sid"]
-    results_list = await asyncio.gather(*[source.search(query) for source in sources.values()])
-
-    results = [
-        search_result.to_dict() for source_result in results_list for search_result in source_result
-    ]
-
-    await sio.emit("search-results", {"results": results, "sid": sid})
-
-
-@sio.on("client-registered")
-async def handle_client_registered(data: dict[str, Any]) -> None:
-    """
-    Handle the "client-registered" message.
-
-    If the registration was successfull (`data["success"]` == `True`), store
-    the room code in the global :py:class:`State` and print out a link to join
-    the webclient.
-
-    Start listing all configured :py:class:`syng.sources.source.Source` to the
-    server via a "sources" message. This message will be handled by the
-    :py:func:`syng.server.handle_sources` function and may request additional
-    configuration for each source.
-
-    If there is no song playing, start requesting the first song of the queue
-    with a "get-first" message. This will be handled on the server by the
-    :py:func:`syng.server.handle_get_first` function.
-
-    :param data: A dictionary containing a `success` and a `room` entry.
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    if data["success"]:
-        logger.info("Registered")
-
-        # this is borked on windows
-        if os.name != "nt":
-            print(f"Join here: {state.config['server']}/{data['room']}")
-            qr = QRCode(box_size=20, border=2)
-            qr.add_data(f"{state.config['server']}/{data['room']}")
-            qr.make()
-            qr.print_ascii()
-        state.config["room"] = data["room"]
-        await sio.emit("sources", {"sources": list(sources.keys())})
-        if state.current_source is None:  # A possible race condition can occur here
-            await sio.emit("get-first")
-    else:
-        logger.warning("Registration failed")
-        await sio.disconnect()
-
-
-@sio.on("request-config")
-async def handle_request_config(data: dict[str, Any]) -> None:
-    """
-    Handle the "request-config" message.
-
-    Sends the specific server side configuration for a given
-    :py:class:`syng.sources.source.Source`.
-
-    A Source can decide, that the config will be split up in multiple Parts.
-    If this is the case, multiple "config-chunk" messages will be send with a
-    running enumerator. Otherwise a single "config" message will be send.
-
-    After the configuration is send, the source is asked to update its
-    configuration. This can also be split up in multiple parts.
-
-    :param data: A dictionary with the entry `source` and a string, that
-        corresponds to the name of a source.
-    :type data: dict[str, Any]
-    :rtype: None
-    """
-    if data["source"] in sources:
-        config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config()
-        if isinstance(config, list):
-            num_chunks: int = len(config)
-            for current, chunk in enumerate(config):
-                await sio.emit(
-                    "config-chunk",
-                    {
-                        "source": data["source"],
-                        "config": chunk,
-                        "number": current,
-                        "total": num_chunks,
-                    },
-                )
+        try:
+            self.state.current_source = self.sources[entry.source]
+            if self.state.config["preview_duration"] > 0:
+                await self.preview(entry)
+            await self.sources[entry.source].play(entry, self.state.config["mpv_options"])
+        except Exception:  # pylint: disable=broad-except
+            print_exc()
+        self.state.current_source = None
+        if entry.skip:
+            await self.sio.emit("get-first")
         else:
-            await sio.emit("config", {"source": data["source"], "config": config})
+            await self.sio.emit("pop-then-get-next")
 
-        updated_config = await sources[data["source"]].update_config()
-        if isinstance(updated_config, list):
-            num_chunks = len(updated_config)
-            for current, chunk in enumerate(updated_config):
-                await sio.emit(
-                    "config-chunk",
-                    {
-                        "source": data["source"],
-                        "config": chunk,
-                        "number": current,
-                        "total": num_chunks,
-                    },
-                )
-        elif updated_config is not None:
-            await sio.emit("config", {"source": data["source"], "config": updated_config})
+    async def handle_search(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "search" message.
 
+        This handles client side search requests. It sends a search request to all
+        configured :py:class:`syng.sources.source.Source` and collects the results.
 
-def signal_handler() -> None:
-    """
-    Signal handler for the client.
+        The results are then send back to the server in a "search-results" message,
+        including the `sid` of the corresponding webclient.
 
-    This function is called when the client receives a signal to terminate. It
-    will disconnect from the server and kill the current player.
-
-    :rtype: None
-    """
-    engineio.async_client.async_signal_handler()
-    if state.current_source is not None:
-        if state.current_source.player is not None:
-            state.current_source.player.kill()
-
-
-async def start_client(config: dict[str, Any]) -> None:
-    """
-    Initialize the client and connect to the server.
-
-    :param config: Config options for the client
-    :type config: dict[str, Any]
-    :rtype: None
-    """
-
-    sources.update(configure_sources(config["sources"]))
-
-    if "config" in config:
-        last_song = (
-            datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
-            if "last_song" in config["config"] and config["config"]["last_song"]
-            else None
+        :param data: A dictionary with the `query` and `sid` entry.
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        logger.info(f"Searching for: {data['query']}")
+        query = data["query"]
+        sid = data["sid"]
+        results_list = await asyncio.gather(
+            *[source.search(query) for source in self.sources.values()]
         )
-        state.config |= config["config"] | {"last_song": last_song}
 
-    if not ("secret" in state.config and state.config["secret"]):
-        state.config["secret"] = "".join(
-            secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
-        )
-        print(f"Generated secret: {state.config['secret']}")
+        results = [
+            search_result.to_dict()
+            for source_result in results_list
+            for search_result in source_result
+        ]
 
-    if not ("key" in state.config and state.config["key"]):
-        state.config["key"] = ""
+        await self.sio.emit("search-results", {"results": results, "sid": sid})
 
-    try:
-        await sio.connect(state.config["server"])
-    except ConnectionError:
-        logger.error("Could not connect to server")
-        return
+    async def handle_client_registered(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "client-registered" message.
 
-    # this is not supported under windows
-    if os.name != "nt":
-        asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler)
-        asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler)
+        If the registration was successfull (`data["success"]` == `True`), store
+        the room code in the global :py:class:`State` and print out a link to join
+        the webclient.
 
-    try:
-        await sio.wait()
-    except asyncio.CancelledError:
-        pass
-    finally:
-        if state.current_source is not None:
-            if state.current_source.player is not None:
-                state.current_source.player.kill()
+        Start listing all configured :py:class:`syng.sources.source.Source` to the
+        server via a "sources" message. This message will be handled by the
+        :py:func:`syng.server.handle_sources` function and may request additional
+        configuration for each source.
+
+        If there is no song playing, start requesting the first song of the queue
+        with a "get-first" message. This will be handled on the server by the
+        :py:func:`syng.server.handle_get_first` function.
+
+        :param data: A dictionary containing a `success` and a `room` entry.
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        if data["success"]:
+            logger.info("Registered")
+
+            # this is borked on windows
+            if os.name != "nt":
+                print(f"Join here: {self.state.config['server']}/{data['room']}")
+                qr = QRCode(box_size=20, border=2)
+                qr.add_data(f"{self.state.config['server']}/{data['room']}")
+                qr.make()
+                qr.print_ascii()
+            self.state.config["room"] = data["room"]
+            await self.sio.emit("sources", {"sources": list(self.sources.keys())})
+            if self.state.current_source is None:  # A possible race condition can occur here
+                await self.sio.emit("get-first")
+        else:
+            logger.warning("Registration failed")
+            await self.sio.disconnect()
+
+    async def handle_request_config(self, data: dict[str, Any]) -> None:
+        """
+        Handle the "request-config" message.
+
+        Sends the specific server side configuration for a given
+        :py:class:`syng.sources.source.Source`.
+
+        A Source can decide, that the config will be split up in multiple Parts.
+        If this is the case, multiple "config-chunk" messages will be send with a
+        running enumerator. Otherwise a single "config" message will be send.
+
+        After the configuration is send, the source is asked to update its
+        configuration. This can also be split up in multiple parts.
+
+        :param data: A dictionary with the entry `source` and a string, that
+            corresponds to the name of a source.
+        :type data: dict[str, Any]
+        :rtype: None
+        """
+        if data["source"] in self.sources:
+            config: dict[str, Any] | list[dict[str, Any]] = await self.sources[
+                data["source"]
+            ].get_config()
+            if isinstance(config, list):
+                num_chunks: int = len(config)
+                for current, chunk in enumerate(config):
+                    await self.sio.emit(
+                        "config-chunk",
+                        {
+                            "source": data["source"],
+                            "config": chunk,
+                            "number": current,
+                            "total": num_chunks,
+                        },
+                    )
+            else:
+                await self.sio.emit("config", {"source": data["source"], "config": config})
+
+            updated_config = await self.sources[data["source"]].update_config()
+            if isinstance(updated_config, list):
+                num_chunks = len(updated_config)
+                for current, chunk in enumerate(updated_config):
+                    await self.sio.emit(
+                        "config-chunk",
+                        {
+                            "source": data["source"],
+                            "config": chunk,
+                            "number": current,
+                            "total": num_chunks,
+                        },
+                    )
+            elif updated_config is not None:
+                await self.sio.emit("config", {"source": data["source"], "config": updated_config})
+
+    def signal_handler(self) -> None:
+        """
+        Signal handler for the client.
+
+        This function is called when the client receives a signal to terminate. It
+        will disconnect from the server and kill the current player.
+
+        :rtype: None
+        """
+        engineio.async_client.async_signal_handler()
+        if self.state.current_source is not None:
+            if self.state.current_source.player is not None:
+                self.state.current_source.player.kill()
+
+    async def start_client(self, config: dict[str, Any]) -> None:
+        """
+        Initialize the client and connect to the server.
+
+        :param config: Config options for the client
+        :type config: dict[str, Any]
+        :rtype: None
+        """
+
+        self.sources.update(configure_sources(config["sources"]))
+
+        if "config" in config:
+            last_song = (
+                datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
+                if "last_song" in config["config"] and config["config"]["last_song"]
+                else None
+            )
+            self.state.config |= config["config"] | {"last_song": last_song}
+
+        if not ("secret" in self.state.config and self.state.config["secret"]):
+            self.state.config["secret"] = "".join(
+                secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
+            )
+            print(f"Generated secret: {self.state.config['secret']}")
+
+        if not ("key" in self.state.config and self.state.config["key"]):
+            self.state.config["key"] = ""
+
+        try:
+            await self.sio.connect(self.state.config["server"])
+        except ConnectionError:
+            logger.error("Could not connect to server")
+            return
+
+        # this is not supported under windows
+        if os.name != "nt":
+            asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.signal_handler)
+            asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.signal_handler)
+
+        try:
+            await self.sio.wait()
+        except asyncio.CancelledError:
+            pass
+        finally:
+            if self.state.current_source is not None:
+                if self.state.current_source.player is not None:
+                    self.state.current_source.player.kill()
 
 
 def create_async_and_start_client(
@@ -507,7 +505,9 @@ def create_async_and_start_client(
     if queue is not None:
         logger.addHandler(QueueHandler(queue))
 
-    asyncio.run(start_client(config))
+    client = Client(config)
+
+    asyncio.run(client.start_client(config))
 
 
 def run_client(args: Namespace) -> None:
diff --git a/typings/socketio/__init__.pyi b/typings/socketio/__init__.pyi
index ef78900..37bece2 100644
--- a/typings/socketio/__init__.pyi
+++ b/typings/socketio/__init__.pyi
@@ -34,7 +34,9 @@ class AsyncServer:
 
 class AsyncClient:
     def __init__(self, json: Any = None): ...
-    def on(self, event: str) -> Callable[[ClientHandler], ClientHandler]: ...
+    def on(
+        self, event: str, handler: Optional[Callable[..., Any]] = None
+    ) -> Callable[[ClientHandler], ClientHandler]: ...
     async def wait(self) -> None: ...
     async def connect(self, server: str) -> None: ...
     async def disconnect(self) -> None: ...