Udded player progress, updated UI.

Signed-off-by: Pavel Kirilin <win10@list.ru>
This commit is contained in:
2024-05-12 11:59:00 +02:00
parent 1b9a6ee0da
commit 0ca224fe50
23 changed files with 597 additions and 169 deletions

View File

@ -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)

6
anime/deps.py Normal file
View File

@ -0,0 +1,6 @@
from typing import Annotated
from fastapi import Depends
from anime import services
GetDbusService = Annotated[services.DbusService, Depends()]

View File

@ -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

15
anime/lifespan.py Normal file
View File

@ -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()

7
anime/router/__init__.py Normal file
View File

@ -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")

46
anime/router/commands.py Normal file
View File

@ -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

28
anime/router/player.py Normal file
View File

@ -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()

View File

@ -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}

View File

@ -0,0 +1,5 @@
from .dbus import DbusService
__all__ = [
"DbusService",
]

131
anime/services/dbus.py Normal file
View File

@ -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

View File

@ -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>

View File

@ -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",

View File

@ -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==}

View File

@ -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,

View File

@ -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>
<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>

View File

@ -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')

View File

@ -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 }
});

View File

@ -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 }
});

View File

@ -1,5 +1,4 @@
import { showFailToast } from 'vant';
import { usePlayingStore } from '@/stores/playing';
async function postRequest(url, data) {
const response = await fetch(url, {

View File

@ -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>

View File

@ -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',

16
poetry.lock generated
View File

@ -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"

View File

@ -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"