diff --git a/anime/__main__.py b/anime/__main__.py index 586cdb0d33a105e79137bb801cf61ca821640fb8..869ae89c08987db145911538c4b4b15562fd2a28 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 0000000000000000000000000000000000000000..f7c101e0692f12bda6eedd2a4dd9261ab53b71f0 --- /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 37381a691020425586d8d180d838c0be1d79890c..0463556c8c8a31741ced8047ead2d58add9c73a7 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 0000000000000000000000000000000000000000..cad102b15d38a2ab3543fa65dccb970d068f39a9 --- /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 0000000000000000000000000000000000000000..ca1de6eba0a04cba4ff030a8aecafcfcf8f91382 --- /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 0000000000000000000000000000000000000000..9028b590212f6a4b0be9ddd269310d515f4a9ece --- /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 0000000000000000000000000000000000000000..8d6002c929f5571991b38ad2a948b1dd36975a17 --- /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 51d0f2d02aa027a53fdaac5b862e91fb03b56858..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..8ff7689521ccfa08f568c9d29186adbc33a125da --- /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 0000000000000000000000000000000000000000..bae227c800b4860998df02d8df6cc7fc11b080c4 --- /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 9818b67ad5eb0ff0b54ef0e29daf1963ecabf94c..ab354214d1d2b6dc3ad3ece1ac39a6fb3f8d05b9 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 3fe69733964f17c13408be4f5b5010fa0c04427a..a9e6c509364bd60ced85909e03c3f31b87e81b18 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 4335c389a5ff43003bfdced2f3ac5c6cd5d1518f..4cb6eea3ad1cc25f99d5637918d19f126f8d430d 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 8816868a41b651f318dee87c6784ebcd6e29eca1..df14e52558a95f0ff8dabe1f9bb434886cb89f77 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 fcf01dcba27d0f96834001b8da658be50a03ad04..0a5c38486ce0ee77cf3675dcf4a1a2d9400aecb1 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 c514de2a01f6c92ed284acd743adf9d812f4f2bb..0099eb1aaf442b068f8edf7795ad30f592b07c97 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 dc5af41dcda477552536007faeb9495465096694..2adf0af3fe0de5999a49c24f141cd3797ca93728 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 0000000000000000000000000000000000000000..58d305e787f3e8c0b57f369bd83ec2a19bbcf58c --- /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 33663f84389d3ac068e742aac51bbf54dd0ca049..b4e57ac3947661d90ee9eabc159b92486e38556b 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 e25cbb20efccc606021c39575093126822c69c33..bc723c528de15b85d2470aab05807826aecee212 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 191eb8228a4499cf513355ef66a44cdff65ec953..f91eb4e94e8dcf23cd4cab75c1b7bd81a0d8390b 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 b56d2294d773cb268caf657db7a4055fe34039f3..6db282f06f70049cfd0b5cb9129089042cdbec1e 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 9de668d65e20c034bbe545a7be434dcb1ed06634..e5d995ba43aefb6597c264c20413ff82b147b2be 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"