From 0ca224fe509ec5cd0fc8aacd39709988f007baab Mon Sep 17 00:00:00 2001
From: Pavel Kirilin <win10@list.ru>
Date: Sun, 12 May 2024 11:59:00 +0200
Subject: [PATCH] Udded player progress, updated UI.

Signed-off-by: Pavel Kirilin <win10@list.ru>
---
 anime/__main__.py                           |  13 +-
 anime/deps.py                               |   6 +
 anime/dtos.py                               |  17 ++-
 anime/lifespan.py                           |  15 +++
 anime/router/__init__.py                    |   7 ++
 anime/router/commands.py                    |  46 +++++++
 anime/router/player.py                      |  28 +++++
 anime/routes.py                             | 102 ---------------
 anime/services/__init__.py                  |   5 +
 anime/services/dbus.py                      | 131 ++++++++++++++++++++
 frontend/index.html                         |   9 +-
 frontend/package.json                       |   2 +
 frontend/pnpm-lock.yaml                     |  84 ++++++++++++-
 frontend/src/assets/base.css                |  15 +--
 frontend/src/components/PlayerComponent.vue | 125 ++++++++++++++++---
 frontend/src/main.js                        |   3 +
 frontend/src/stores/backendState.js         |  17 +--
 frontend/src/stores/playersState.js         |  67 ++++++++++
 frontend/src/utils/index.js                 |   1 -
 frontend/src/views/HomeView.vue             |  53 ++++++--
 frontend/vite.config.js                     |   3 +-
 poetry.lock                                 |  16 ++-
 pyproject.toml                              |   1 +
 23 files changed, 597 insertions(+), 169 deletions(-)
 create mode 100644 anime/deps.py
 create mode 100644 anime/lifespan.py
 create mode 100644 anime/router/__init__.py
 create mode 100644 anime/router/commands.py
 create mode 100644 anime/router/player.py
 delete mode 100644 anime/routes.py
 create mode 100644 anime/services/__init__.py
 create mode 100644 anime/services/dbus.py
 create mode 100644 frontend/src/stores/playersState.js

diff --git a/anime/__main__.py b/anime/__main__.py
index 586cdb0..869ae89 100644
--- a/anime/__main__.py
+++ b/anime/__main__.py
@@ -1,13 +1,15 @@
+from functools import partial
 from pathlib import Path
 from typing import Optional
 
+import dbus
 import uvicorn
 from fastapi import FastAPI
 from fastapi.responses import JSONResponse
 from fastapi.staticfiles import StaticFiles
 from typer import Argument, Typer
-
-from anime.routes import router
+from anime.router import router
+from anime import lifespan
 
 CURRENT_DIR = Path(__file__).parent
 STATIC_DIR = CURRENT_DIR / "static"
@@ -35,6 +37,10 @@ def run_app(
         description="API for RCE",
         openapi_url="/api/openapi.json",
         docs_url="/api/docs",
+        lifespan=partial(
+            lifespan.lifespan,
+            anime_dir=anime_dir,
+        ),
     )
     app.include_router(router, prefix="/api")
     app.mount(
@@ -46,9 +52,6 @@ def run_app(
         ),
     )
     app.add_exception_handler(Exception, default_exception_handler)
-    app.state.anime_dir = anime_dir
-    app.state.pid = None
-
     uvicorn.run(app, host=host, port=port, workers=1)
 
 
diff --git a/anime/deps.py b/anime/deps.py
new file mode 100644
index 0000000..f7c101e
--- /dev/null
+++ b/anime/deps.py
@@ -0,0 +1,6 @@
+from typing import Annotated
+from fastapi import Depends
+from anime import services
+
+
+GetDbusService = Annotated[services.DbusService, Depends()]
diff --git a/anime/dtos.py b/anime/dtos.py
index 37381a6..0463556 100644
--- a/anime/dtos.py
+++ b/anime/dtos.py
@@ -1,10 +1,25 @@
 from pydantic import BaseModel
 
 
-class PlayerOffsetRequest(BaseModel):
+class PlayerRequest(BaseModel):
+    player: str | None = None
+
+
+class PlayerOffsetRequest(PlayerRequest):
     offset: int
     forward: bool
 
 
 class KillRequest(BaseModel):
     names: list[str]
+
+
+class PlayerInfo(BaseModel):
+    name: str
+    playing: bool
+    can_seek: bool
+    artists: list[str]
+
+    progress: int | None = None
+    title: str | None = None
+    album: str | None = None
diff --git a/anime/lifespan.py b/anime/lifespan.py
new file mode 100644
index 0000000..cad102b
--- /dev/null
+++ b/anime/lifespan.py
@@ -0,0 +1,15 @@
+from contextlib import asynccontextmanager
+from pathlib import Path
+import dbus
+from fastapi import FastAPI
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI, anime_dir: Path):
+    app.state.anime_dir = anime_dir
+    app.state.pid = None
+    app.state.dbus_session = dbus.SessionBus()
+
+    yield
+
+    app.state.dbus_session.close()
diff --git a/anime/router/__init__.py b/anime/router/__init__.py
new file mode 100644
index 0000000..ca1de6e
--- /dev/null
+++ b/anime/router/__init__.py
@@ -0,0 +1,7 @@
+from fastapi import APIRouter
+from . import player, commands
+
+router = APIRouter()
+
+router.include_router(player.router, prefix="/player")
+router.include_router(commands.router, prefix="/commands")
diff --git a/anime/router/commands.py b/anime/router/commands.py
new file mode 100644
index 0000000..9028b59
--- /dev/null
+++ b/anime/router/commands.py
@@ -0,0 +1,46 @@
+import os
+import shutil
+import subprocess
+
+from fastapi import APIRouter, HTTPException, Request
+
+from anime import dtos
+
+router = APIRouter()
+
+
+def is_pid_alive(pid: int) -> bool:
+    if pid:
+        try:
+            os.kill(pid, 0)
+        except OSError:
+            return False
+        else:
+            return True
+
+
+@router.get("/can-start-watching")
+def can_start_watching(request: Request) -> None:
+    if not request.app.state.anime_dir:
+        raise HTTPException(status_code=400, detail="Anime directory is not set")
+
+
+@router.post("/kill")
+def kill(input_dto: dtos.KillRequest) -> None:
+    for name in input_dto.names:
+        os.system(f"killall {name}")
+
+
+@router.post("/start-watching")
+def start_watching(request: Request) -> None:
+    anime_dir = request.app.state.anime_dir
+    if not anime_dir:
+        raise HTTPException(status_code=400, detail="Anime directory is not set")
+    awatch = shutil.which("awatch")
+    if awatch is None:
+        raise Exception("awatch command is not available")
+    ret = subprocess.Popen(
+        [awatch],
+        cwd=anime_dir,
+    )
+    request.app.state.pid = ret.pid
diff --git a/anime/router/player.py b/anime/router/player.py
new file mode 100644
index 0000000..8d6002c
--- /dev/null
+++ b/anime/router/player.py
@@ -0,0 +1,28 @@
+from fastapi import APIRouter
+
+from anime import dtos
+from anime.deps import GetDbusService
+
+router = APIRouter()
+
+
+@router.post("/offset")
+async def offset(
+    req: dtos.PlayerOffsetRequest,
+    dbus_service: GetDbusService,
+) -> None:
+    direction = 1 if req.forward else -1
+    dbus_service.seek(abs(req.offset) * direction, req.player)
+
+
+@router.post("/play-pause")
+async def play_pause(
+    input_dto: dtos.PlayerRequest,
+    dbus_service: GetDbusService,
+) -> None:
+    dbus_service.play_pause(input_dto.player)
+
+
+@router.get("/list")
+def get_players(dbus_service: GetDbusService) -> list[dtos.PlayerInfo]:
+    return dbus_service.list_players()
diff --git a/anime/routes.py b/anime/routes.py
deleted file mode 100644
index 51d0f2d..0000000
--- a/anime/routes.py
+++ /dev/null
@@ -1,102 +0,0 @@
-import os
-import shutil
-import subprocess
-from pathlib import Path
-
-from fastapi import APIRouter, HTTPException, Request
-
-from anime.dtos import KillRequest, PlayerOffsetRequest
-
-CWD = Path.cwd()
-router = APIRouter()
-
-
-def is_pid_alive(pid: int) -> bool:
-    if pid:
-        try:
-            os.kill(pid, 0)
-        except OSError:
-            return False
-        else:
-            return True
-
-
-@router.get("/can-start-watching")
-def can_start_watching(request: Request) -> None:
-    if not request.app.state.anime_dir:
-        raise HTTPException(status_code=400, detail="Anime directory is not set")
-
-
-@router.post("/kill")
-def kill(input_dto: KillRequest) -> None:
-    for name in input_dto.names:
-        os.system(f"killall {name}")
-
-
-@router.post("/start-watching")
-def start_watching(request: Request) -> None:
-    anime_dir = request.app.state.anime_dir
-    if not anime_dir:
-        raise HTTPException(status_code=400, detail="Anime directory is not set")
-    if request.app.state.pid and is_pid_alive(request.app.state.pid):
-        raise HTTPException(
-            status_code=400,
-            detail="Awatch is already running",
-        )
-    awatch = shutil.which("awatch")
-    if awatch is None:
-        raise Exception("awatch command is not available")
-    ret = subprocess.Popen(
-        [awatch],
-        cwd=anime_dir,
-    )
-    request.app.state.pid = ret.pid
-
-
-@router.post("/player/offset")
-async def offset(req: PlayerOffsetRequest) -> None:
-    direction = "+" if req.forward else "-"
-    playerctl = shutil.which("playerctl")
-    if playerctl is None:
-        raise HTTPException(
-            status_code=500,
-            detail="playerctl command is not available",
-        )
-
-    subprocess.run(
-        [playerctl, "position", f"{req.offset}{direction}"],
-        check=False,
-    )
-
-
-@router.post("/player/play-pause")
-async def play_pause() -> None:
-    playerctl = shutil.which("playerctl")
-    if playerctl is None:
-        raise HTTPException(
-            status_code=500,
-            detail="playerctl command is not available",
-        )
-
-    subprocess.run([playerctl, "play-pause"], check=False)
-
-
-@router.get("/player/playing")
-def player_state(request: Request):
-    playerctl = shutil.which("playerctl")
-    if playerctl is None:
-        raise HTTPException(
-            status_code=500,
-            detail="playerctl command is not available",
-        )
-
-    status_cmd = subprocess.Popen([playerctl, "status"], stdout=subprocess.PIPE)
-    status_cmd.wait()
-    current_state = status_cmd.stdout.read().decode("utf-8").strip().lower()
-    if (
-        request.app.state.pid
-        and is_pid_alive(request.app.state.pid)
-        and current_state == "playing"
-    ):
-        return {"playing": True}
-    return {"playing": False}
diff --git a/anime/services/__init__.py b/anime/services/__init__.py
new file mode 100644
index 0000000..8ff7689
--- /dev/null
+++ b/anime/services/__init__.py
@@ -0,0 +1,5 @@
+from .dbus import DbusService
+
+__all__ = [
+    "DbusService",
+]
diff --git a/anime/services/dbus.py b/anime/services/dbus.py
new file mode 100644
index 0000000..bae227c
--- /dev/null
+++ b/anime/services/dbus.py
@@ -0,0 +1,131 @@
+from typing import Annotated
+import dbus.service
+from fastapi import Depends, HTTPException, Request
+import dbus
+
+from anime import dtos
+
+
+def _get_dbus_session(request: Request) -> dbus.SessionBus:
+    return request.app.state.dbus_session
+
+
+class DbusService:
+    def __init__(
+        self,
+        dbus_session: Annotated[dbus.SessionBus, Depends(_get_dbus_session)],
+    ) -> None:
+        self.session = dbus_session
+
+    def _get_player(self, name: str) -> dbus.service.Object:
+        if name:
+            try:
+                return self.session.get_object(
+                    f"org.mpris.MediaPlayer2.{name}",
+                    "/org/mpris/MediaPlayer2",
+                )
+            except dbus.exceptions.DBusException as exc:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Player {name} is not available",
+                ) from exc
+
+        for name in self.session.list_names():
+            if not name.startswith("org.mpris.MediaPlayer2."):
+                continue
+            return self.session.get_object(name, "/org/mpris/MediaPlayer2")
+        raise HTTPException(status_code=400, detail="No player is available")
+
+    def _resolve_name(self, name: str | None = None) -> str:
+        if name is None:
+            all_players = [
+                name
+                for name in self.session.list_names()
+                if name.startswith("org.mpris.MediaPlayer2.")
+            ]
+            if not all_players:
+                raise HTTPException(
+                    status_code=400,
+                    detail="No player is available",
+                )
+            return all_players[0].removeprefix("org.mpris.MediaPlayer2.")
+        if name:
+            return name
+
+    def list_players(self) -> list[dtos.PlayerInfo]:
+        state = []
+        for name in self.session.list_names():
+            if not name.startswith("org.mpris.MediaPlayer2."):
+                continue
+            player = self.session.get_object(name, "/org/mpris/MediaPlayer2")
+            status = player.Get(
+                "org.mpris.MediaPlayer2.Player",
+                "PlaybackStatus",
+                dbus_interface="org.freedesktop.DBus.Properties",
+            )
+            metadata = player.Get(
+                "org.mpris.MediaPlayer2.Player",
+                "Metadata",
+                dbus_interface="org.freedesktop.DBus.Properties",
+            )
+            can_seek = player.Get(
+                "org.mpris.MediaPlayer2.Player",
+                "CanSeek",
+                dbus_interface="org.freedesktop.DBus.Properties",
+            )
+            track_len = metadata.get("mpris:length")
+            progress = None
+            try:
+                position = player.Get(
+                    "org.mpris.MediaPlayer2.Player",
+                    "Position",
+                    dbus_interface="org.freedesktop.DBus.Properties",
+                )
+                if position is not None and track_len is not None:
+                    progress = ((position * 100) // track_len)
+            except dbus.exceptions.DBusException:
+                pass
+            artists = metadata.get("xesam:artist")
+            album = metadata.get("xesam:album")
+            title = metadata.get("xesam:title")
+            state.append(
+                dtos.PlayerInfo(
+                    name=name.removeprefix("org.mpris.MediaPlayer2."),
+                    playing=status.lower() == "playing",
+                    can_seek=bool(can_seek),
+                    artists=[str(artist) for artist in artists] if artists else [],
+                    title=str(title) if title else None,
+                    album=str(album) if album else None,
+                    progress=progress,
+                )
+            )
+        return state
+
+    def seek(
+        self,
+        offset: int,
+        name: str | None = None,
+    ) -> None:
+        resolved_name = self._resolve_name(name)
+        player = self._get_player(resolved_name)
+        try:
+            player.Seek(
+                offset * 1_000_000,
+                dbus_interface="org.mpris.MediaPlayer2.Player",
+            )
+        except dbus.exceptions.DBusException as exc:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Player {resolved_name} is not seekable",
+            ) from exc
+
+    def play_pause(self, name: str | None = None) -> None:
+        resolved_name = self._resolve_name(name)
+        player = self._get_player(resolved_name)
+        try:
+            player.PlayPause(dbus_interface="org.mpris.MediaPlayer2.Player")
+        except dbus.exceptions.DBusException as exc:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Player {resolved_name} cannot be controlled",
+            ) from exc
diff --git a/frontend/index.html b/frontend/index.html
index 9818b67..ab35421 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,8 +6,13 @@
   <link rel="icon" href="/favicon.ico">
   <meta name="viewport" content="width=device-width,initial-scale=1">
   <title>Anime</title>
-  <link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
-  <meta name="theme-color" content="#ffffff">
+  <link rel="mask-icon" href="/mask-icon.svg" color="#F0EFF4">
+  <meta name="theme-color" content="#F0EFF4">
+  <style>
+    body {
+      background-color: "#F0EFF4";
+    }
+  </style>
 </head>
 
 <body>
diff --git a/frontend/package.json b/frontend/package.json
index 3fe6973..a9e6c50 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,8 @@
     "format": "prettier --write src/"
   },
   "dependencies": {
+    "@qvant/qui-max": "^0.19.0",
+    "@vueuse/core": "^10.9.0",
     "pinia": "^2.1.7",
     "vant": "^4.8.11",
     "vue": "^3.4.21",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 4335c38..4cb6eea 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -5,6 +5,12 @@ settings:
   excludeLinksFromLockfile: false
 
 dependencies:
+  '@qvant/qui-max':
+    specifier: ^0.19.0
+    version: 0.19.0(vue@3.4.26)
+  '@vueuse/core':
+    specifier: ^10.9.0
+    version: 10.9.0(vue@3.4.26)
   pinia:
     specifier: ^2.1.7
     version: 2.1.7(vue@3.4.26)
@@ -1271,7 +1277,6 @@ packages:
     engines: {node: '>=6.9.0'}
     dependencies:
       regenerator-runtime: 0.14.1
-    dev: true
 
   /@babel/template@7.24.0:
     resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==}
@@ -1638,6 +1643,25 @@ packages:
     resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
     dev: true
 
+  /@popperjs/core@2.11.8:
+    resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+    dev: false
+
+  /@qvant/qui-max@0.19.0(vue@3.4.26):
+    resolution: {integrity: sha512-ZSql2VXq2GLDSldo0ezicT6NpIM+VCjy8yK4sg/dDBUeEqxGyWkM5O6zdGI2v2Q2UDJkAx83wLewTXcWYIDR6w==}
+    peerDependencies:
+      vue: ^3.2.33
+    dependencies:
+      '@popperjs/core': 2.11.8
+      async-validator: 4.2.5
+      colord: 2.9.3
+      date-fns: 2.30.0
+      focus-visible: 5.2.0
+      lodash-es: 4.17.21
+      object-hash: 3.0.0
+      vue: 3.4.26
+    dev: false
+
   /@rollup/plugin-babel@5.3.1(@babel/core@7.24.5)(rollup@2.79.1):
     resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
     engines: {node: '>= 10.0.0'}
@@ -1882,6 +1906,10 @@ packages:
     resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
     dev: true
 
+  /@types/web-bluetooth@0.0.20:
+    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+    dev: false
+
   /@ungap/structured-clone@1.2.0:
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
     dev: true
@@ -2065,6 +2093,31 @@ packages:
   /@vue/shared@3.4.26:
     resolution: {integrity: sha512-Fg4zwR0GNnjzodMt3KRy2AWGMKQXByl56+4HjN87soxLNU9P5xcJkstAlIeEF3cU6UYOzmJl1tV0dVPGIljCnQ==}
 
+  /@vueuse/core@10.9.0(vue@3.4.26):
+    resolution: {integrity: sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==}
+    dependencies:
+      '@types/web-bluetooth': 0.0.20
+      '@vueuse/metadata': 10.9.0
+      '@vueuse/shared': 10.9.0(vue@3.4.26)
+      vue-demi: 0.14.7(vue@3.4.26)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
+  /@vueuse/metadata@10.9.0:
+    resolution: {integrity: sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==}
+    dev: false
+
+  /@vueuse/shared@10.9.0(vue@3.4.26):
+    resolution: {integrity: sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==}
+    dependencies:
+      vue-demi: 0.14.7(vue@3.4.26)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /acorn-jsx@5.3.2(acorn@8.11.3):
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
     peerDependencies:
@@ -2142,6 +2195,10 @@ packages:
       is-shared-array-buffer: 1.0.3
     dev: true
 
+  /async-validator@4.2.5:
+    resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
+    dev: false
+
   /async@3.2.5:
     resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==}
     dev: true
@@ -2312,6 +2369,10 @@ packages:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
     dev: true
 
+  /colord@2.9.3:
+    resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
+    dev: false
+
   /commander@2.20.3:
     resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
     dev: true
@@ -2385,6 +2446,13 @@ packages:
       is-data-view: 1.0.1
     dev: true
 
+  /date-fns@2.30.0:
+    resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
+    engines: {node: '>=0.11'}
+    dependencies:
+      '@babel/runtime': 7.24.5
+    dev: false
+
   /debug@4.3.4:
     resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
     engines: {node: '>=6.0'}
@@ -2842,6 +2910,10 @@ packages:
     resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
     dev: true
 
+  /focus-visible@5.2.0:
+    resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==}
+    dev: false
+
   /for-each@0.3.3:
     resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
     dependencies:
@@ -3375,6 +3447,10 @@ packages:
       p-locate: 5.0.0
     dev: true
 
+  /lodash-es@4.17.21:
+    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+    dev: false
+
   /lodash.debounce@4.0.8:
     resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
     dev: true
@@ -3489,6 +3565,11 @@ packages:
       boolbase: 1.0.0
     dev: true
 
+  /object-hash@3.0.0:
+    resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+    engines: {node: '>= 6'}
+    dev: false
+
   /object-inspect@1.13.1:
     resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
     dev: true
@@ -3698,7 +3779,6 @@ packages:
 
   /regenerator-runtime@0.14.1:
     resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
-    dev: true
 
   /regenerator-transform@0.15.2:
     resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==}
diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css
index 8816868..df14e52 100644
--- a/frontend/src/assets/base.css
+++ b/frontend/src/assets/base.css
@@ -1,6 +1,6 @@
 /* color palette from <https://github.com/vuejs/theme> */
 :root {
-  --vt-c-white: #ffffff;
+  --vt-c-white: #F0EFF4;
   --vt-c-white-soft: #f8f8f8;
   --vt-c-white-mute: #f2f2f2;
 
@@ -36,19 +36,6 @@
   --section-gap: 160px;
 }
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --color-background: var(--vt-c-black);
-    --color-background-soft: var(--vt-c-black-soft);
-    --color-background-mute: var(--vt-c-black-mute);
-
-    --color-border: var(--vt-c-divider-dark-2);
-    --color-border-hover: var(--vt-c-divider-dark-1);
-
-    --color-heading: var(--vt-c-text-dark-1);
-    --color-text: var(--vt-c-text-dark-2);
-  }
-}
 
 *,
 *::before,
diff --git a/frontend/src/components/PlayerComponent.vue b/frontend/src/components/PlayerComponent.vue
index fcf01dc..0a5c384 100644
--- a/frontend/src/components/PlayerComponent.vue
+++ b/frontend/src/components/PlayerComponent.vue
@@ -1,30 +1,127 @@
 <script setup>
-import { ActionBar, ActionBarButton } from 'vant';
+import { ActionBar, ActionBarButton, ActionSheet, Progress } from 'vant';
 import { postRequest } from '@/utils'
-import { useBackendStateStore } from '@/stores/backendState';
+import { onMounted, ref } from 'vue';
+import { usePlayerStateStore } from '@/stores/playersState';
+import { onLongPress } from '@vueuse/core'
+import { onUnmounted } from 'vue';
 
-const backendStore = useBackendStateStore();
+
+const playerStateStore = usePlayerStateStore()
+const playButton = ref()
+const showPlayerSelect = ref(false)
+let updater = null
 
 async function offsetRequest(offset, forward) {
     await postRequest(`/api/player/offset`, {
         offset,
         forward,
+        player: playerStateStore.playerState.current_player
     })
-    await backendStore.updatePlaying()
+    playerStateStore.refreshPlayers()
 }
 
 async function playPauseRequest() {
-    await postRequest(`/api/player/play-pause`)
-    await backendStore.updatePlaying()
+    await postRequest(`/api/player/play-pause`, {
+        player: playerStateStore.playerState.current_player
+    })
+    playerStateStore.refreshPlayers()
+}
+
+async function onPlayerSelect(player) {
+    playerStateStore.setCurrentPlayer(player.name)
+    showPlayerSelect.value = false
+}
+
+function getProgress() {
+    let player = playerStateStore.getCurrentPlayer()
+    if (player == null) {
+        return 0
+    }
+    if (player.progress == null) {
+        return 0
+    }
+    return player.progress
+}
+
+function hasProgress() {
+    let player = playerStateStore.getCurrentPlayer();
+    if (player == null) {
+        return false
+    }
+    return player.progress !== null
+}
+
+function getPlayersForChooser() {
+    let players = [];
+
+    for (let player of playerStateStore.playerState.players) {
+        let color = "#000000"
+        if (player.name === playerStateStore.playerState.current_player) {
+            color = "#b83e65"
+        }
+        let subname = [player.artists.join(", "), player.title].join(" - ")
+        let icon = player.playing ? "play" : "pause"
+        players.push({ name: player.name, color, subname, icon })
+    }
+
+    return players
+
 }
+
+onLongPress(playButton, (event) => {
+    showPlayerSelect.value = true
+}, {
+    modifiers: { prevent: true, capture: true },
+})
+
+onMounted(() => {
+    playerStateStore.refreshPlayers()
+    updater = setInterval(() => {
+        playerStateStore.refreshPlayers()
+    }, 1000)
+})
+
+onUnmounted(() => {
+    if (updater != null) {
+        clearInterval(updater)
+        updater = null
+    }
+})
 </script>
 
 <template>
-    <ActionBar>
-        <ActionBarButton icon="arrow-double-left" @click="offsetRequest(85, false)"></ActionBarButton>
-        <ActionBarButton icon="arrow-left" @click="offsetRequest(10, false)"></ActionBarButton>
-        <ActionBarButton :icon="backendStore.backendState.playing ? 'pause' : 'play'" @click="playPauseRequest()"></ActionBarButton>
-        <ActionBarButton icon="arrow" @click="offsetRequest(10, true)"></ActionBarButton>
-        <ActionBarButton icon="arrow-double-right" @click="offsetRequest(85, true)"></ActionBarButton>
-    </ActionBar>
-</template>
\ No newline at end of file
+    <div v-if="playerStateStore.playerState.players.length > 0">
+        <Progress id="playerProgress" v-if="hasProgress()" :percentage="getProgress()" :show-pivot="false" />
+        <ActionBar id="playerControls">
+            <ActionSheet v-model:show="showPlayerSelect" :actions="getPlayersForChooser()" cancel-text="Cancel"
+                description="Available players" @select="onPlayerSelect">
+            </ActionSheet>
+            <ActionBarButton icon="arrow-double-left" @click="offsetRequest(85, false)"></ActionBarButton>
+            <ActionBarButton icon="arrow-left" @click="offsetRequest(10, false)"></ActionBarButton>
+            <ActionBarButton ref="playButton" :icon="playerStateStore.playerState.is_playing ? 'pause' : 'play'"
+                @click="playPauseRequest()">
+            </ActionBarButton>
+            <ActionBarButton icon="arrow" @click="offsetRequest(10, true)"></ActionBarButton>
+            <ActionBarButton icon="arrow-double-right" @click="offsetRequest(85, true)"></ActionBarButton>
+        </ActionBar>
+    </div>
+</template>
+
+<style>
+#playerProgress {
+    position: fixed;
+    bottom: var(--van-action-bar-height);
+    left: 0;
+    width: 100%;
+    height: 8px;
+}
+
+#playerControls {
+    background-color: var(--color-background);
+}
+
+.van-action-bar-button {
+    color: var(--color-primary-darker) !important;
+}
+</style>
\ No newline at end of file
diff --git a/frontend/src/main.js b/frontend/src/main.js
index c514de2..0099eb1 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -5,12 +5,15 @@ import { createPinia } from 'pinia'
 import Vant from 'vant';
 import App from './App.vue'
 import router from './router'
+import Qui from '@qvant/qui-max';
 import 'vant/lib/index.css';
+import '@qvant/qui-max/styles';
 
 const app = createApp(App)
 
 app.use(createPinia())
 app.use(Vant)
+app.use(Qui)
 app.use(router)
 
 app.mount('#app')
diff --git a/frontend/src/stores/backendState.js b/frontend/src/stores/backendState.js
index dc5af41..2adf0af 100644
--- a/frontend/src/stores/backendState.js
+++ b/frontend/src/stores/backendState.js
@@ -2,24 +2,13 @@ import { defineStore } from "pinia";
 import { ref } from 'vue'
 
 export const useBackendStateStore = defineStore('backendState', () => {
-    const backendState = ref({
-        canWatch: false,
-        playing: false
-    })
+    const backendState = ref({canWatch: false})
 
     async function updateCanWatch() {
-        const response = await fetch("/api/can-start-watching");
+        const response = await fetch("/api/commands/can-start-watching");
         backendState.value.canWatch = response.ok;
     }
 
 
-    async function updatePlaying() {
-        const response = await fetch('/api/player/playing');
-        if (response.ok) {
-            let resp_json = await response.json()
-            backendState.value.playing = resp_json.playing
-        }
-    }
-
-    return { backendState, updateCanWatch, updatePlaying }
+    return { backendState, updateCanWatch }
 });
\ No newline at end of file
diff --git a/frontend/src/stores/playersState.js b/frontend/src/stores/playersState.js
new file mode 100644
index 0000000..58d305e
--- /dev/null
+++ b/frontend/src/stores/playersState.js
@@ -0,0 +1,67 @@
+
+import { defineStore } from "pinia";
+import { ref } from 'vue'
+
+export const usePlayerStateStore = defineStore('playerState', () => {
+    const playerState = ref({
+        players: [],
+        current_player: null,
+        is_playing: false,
+    })
+
+    function updateIsPlaying() {
+        var is_playing = false
+        if (playerState.value.current_player == null) {
+            is_playing = false
+        }
+        for (let player of playerState.value.players) {
+            if (player.name == playerState.value.current_player && player.playing) {
+                is_playing = true
+            }
+        }
+        playerState.value.is_playing = is_playing
+    }
+
+    function fixCurrentPlayer() {
+        if (playerState.value.current_player == null) {
+            return
+        }
+        let exists = false;
+        for (let player of playerState.value.players) {
+            if (player.name == playerState.value.current_player) {
+                exists = true
+            }
+        }
+        if (!exists) {
+            playerState.value.current_player = null
+        }
+    }
+
+    async function refreshPlayers() {
+        const response = await fetch("/api/player/list");
+        playerState.value.players = await response.json();
+        if (playerState.value.current_player == null && playerState.value.players.length > 0) {
+            playerState.value.current_player = playerState.value.players[0].name
+        }
+        fixCurrentPlayer()
+        updateIsPlaying()
+    }
+
+    function setCurrentPlayer(player) {
+        playerState.value.current_player = player
+        refreshPlayers()
+    }
+
+    function getCurrentPlayer() {
+        if (playerState.value.current_player == null) {
+            return null
+        }
+        for (let player of playerState.value.players) {
+            if (player.name == playerState.value.current_player) {
+                return player
+            }
+        }
+    }
+
+    return { playerState, refreshPlayers, setCurrentPlayer, getCurrentPlayer }
+});
\ No newline at end of file
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js
index 33663f8..b4e57ac 100644
--- a/frontend/src/utils/index.js
+++ b/frontend/src/utils/index.js
@@ -1,5 +1,4 @@
 import { showFailToast } from 'vant';
-import { usePlayingStore } from '@/stores/playing';
 
 async function postRequest(url, data) {
     const response = await fetch(url, {
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index e25cbb2..bc723c5 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -3,30 +3,59 @@ import PlayerComponent from '@/components/PlayerComponent.vue';
 import { Space, Button } from 'vant';
 import { postRequest } from '@/utils';
 import { useBackendStateStore } from '@/stores/backendState'
+import { onMounted } from 'vue';
+import { usePlayerStateStore } from '@/stores/playersState';
+import { QButton } from '@qvant/qui-max'
+
 
 const backendStateStore = useBackendStateStore()
+const playerStateStore = usePlayerStateStore()
 
 backendStateStore.updateCanWatch()
 
 function kill(...names) {
-  postRequest("/api/kill", { names }).then(() => {
-    setTimeout(() => backendStateStore.updatePlaying(), 500)
-  })
+  postRequest("/api/commands/kill", { names })
+  playerStateStore.refreshPlayers()
 }
 
 function startWatching() {
-  postRequest("/api/start-watching").then(() => {
-    setTimeout(() => backendStateStore.updatePlaying(), 500)
-  })
+  postRequest("/api/commands/start-watching")
+  playerStateStore.refreshPlayers()
 }
+
+onMounted(() => {
+  backendStateStore.updateCanWatch()
+})
 </script>
 
 <template>
-  <Space fill direction="vertical" :size="20">
-    <Button type="warning" round size="large" @click="kill('mpv')">Убить MPV</Button>
-    <Button type="danger" round size="large" @click="kill('awatch', 'mpv')">Убить awatch</Button>
-    <Button type="primary" round size="large" :disabled="!backendStateStore.backendState.canWatch"
-      @click="startWatching">Начать потребление анимы</Button>
-  </Space>
+  <div id="controls">
+    <QButton class="btn-ctrl" @click="kill('mpv')" theme="secondary" :full-width="true">
+      Следующая серия
+    </QButton>
+    <QButton class="btn-ctrl" @click="kill('awatch', 'mpv')" theme="secondary" :full-width="true">
+      Закончить потребление
+    </QButton>
+    <QButton class="btn-ctrl" @click="startWatching()" theme="secondary"
+      :disabled="!backendStateStore.backendState.canWatch" :full-width="true">
+      Начать потребление
+    </QButton>
+  </div>
   <PlayerComponent />
 </template>
+
+<style>
+#controls {
+  position: relative;
+  top: calc(50% - var(--van-action-bar-height));
+  margin-top: calc(-50px - var(--van-action-bar-height));
+  width: 100%;
+}
+
+.btn-ctrl {
+  margin-bottom: 10px;
+  height: 60px;
+  margin-left: 0 !important;
+  border-radius: 10px !important;
+}
+</style>
\ No newline at end of file
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 191eb82..f91eb4e 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -13,7 +13,8 @@ export default defineConfig({
         name: 'Anime',
         short_name: 'Anime',
         start_url: '/',
-        theme_color: '#000000',
+        theme_color: '#F0EFF4',
+        display: 'standalone',
         icons: [
           {
             src: '/pwa-192x192.jpg',
diff --git a/poetry.lock b/poetry.lock
index b56d229..6db282f 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -100,6 +100,20 @@ files = [
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 ]
 
+[[package]]
+name = "dbus-python"
+version = "1.3.2"
+description = "Python bindings for libdbus"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "dbus-python-1.3.2.tar.gz", hash = "sha256:ad67819308618b5069537be237f8e68ca1c7fcc95ee4a121fe6845b1418248f8"},
+]
+
+[package.extras]
+doc = ["sphinx", "sphinx_rtd_theme"]
+test = ["tap.py"]
+
 [[package]]
 name = "fastapi"
 version = "0.110.3"
@@ -823,4 +837,4 @@ files = [
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "f9e9fd7090b241c75ccfdafcf03212d386197cfc54664a7055ec40446a970232"
+content-hash = "7df0274edad5941808c3d440c2474581502824a071f087938d9b46be5e6f8097"
diff --git a/pyproject.toml b/pyproject.toml
index 9de668d..e5d995b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ python = "^3.11"
 fastapi = "^0.110.3"
 typer = "^0.12.3"
 uvicorn = { version = "^0.29.0", extras = ["standard"] }
+dbus-python = "^1.3.2"
 
 [tool.poetry.scripts]
 anime = "anime.__main__:main"
-- 
GitLab