wrkmon 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wrkmon/__init__.py +4 -0
- wrkmon/__main__.py +6 -0
- wrkmon/app.py +592 -0
- wrkmon/cli.py +289 -0
- wrkmon/core/__init__.py +8 -0
- wrkmon/core/cache.py +208 -0
- wrkmon/core/player.py +301 -0
- wrkmon/core/queue.py +264 -0
- wrkmon/core/youtube.py +178 -0
- wrkmon/data/__init__.py +6 -0
- wrkmon/data/database.py +426 -0
- wrkmon/data/migrations.py +134 -0
- wrkmon/data/models.py +144 -0
- wrkmon/ui/__init__.py +5 -0
- wrkmon/ui/components.py +211 -0
- wrkmon/ui/messages.py +89 -0
- wrkmon/ui/screens/__init__.py +8 -0
- wrkmon/ui/screens/history.py +142 -0
- wrkmon/ui/screens/player.py +222 -0
- wrkmon/ui/screens/playlist.py +278 -0
- wrkmon/ui/screens/search.py +165 -0
- wrkmon/ui/theme.py +326 -0
- wrkmon/ui/views/__init__.py +8 -0
- wrkmon/ui/views/history.py +138 -0
- wrkmon/ui/views/playlists.py +259 -0
- wrkmon/ui/views/queue.py +191 -0
- wrkmon/ui/views/search.py +258 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +129 -0
- wrkmon/ui/widgets/result_item.py +98 -0
- wrkmon/utils/__init__.py +6 -0
- wrkmon/utils/config.py +172 -0
- wrkmon/utils/mpv_installer.py +190 -0
- wrkmon/utils/stealth.py +124 -0
- wrkmon-1.0.1.dist-info/METADATA +166 -0
- wrkmon-1.0.1.dist-info/RECORD +41 -0
- wrkmon-1.0.1.dist-info/WHEEL +5 -0
- wrkmon-1.0.1.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.1.dist-info/licenses/LICENSE +21 -0
- wrkmon-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Database migrations for wrkmon."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
# List of migrations in order
|
|
7
|
+
# Each migration is a tuple of (version, description, up_sql)
|
|
8
|
+
MIGRATIONS: list[tuple[int, str, str]] = [
|
|
9
|
+
(
|
|
10
|
+
1,
|
|
11
|
+
"Initial schema",
|
|
12
|
+
"""
|
|
13
|
+
-- Tracks table
|
|
14
|
+
CREATE TABLE IF NOT EXISTS tracks (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
video_id TEXT UNIQUE NOT NULL,
|
|
17
|
+
title TEXT NOT NULL,
|
|
18
|
+
channel TEXT NOT NULL,
|
|
19
|
+
duration INTEGER NOT NULL,
|
|
20
|
+
thumbnail_url TEXT,
|
|
21
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_tracks_video_id ON tracks(video_id);
|
|
24
|
+
|
|
25
|
+
-- Playlists table
|
|
26
|
+
CREATE TABLE IF NOT EXISTS playlists (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
name TEXT UNIQUE NOT NULL,
|
|
29
|
+
description TEXT DEFAULT '',
|
|
30
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
31
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- Playlist tracks junction table
|
|
35
|
+
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
playlist_id INTEGER NOT NULL,
|
|
38
|
+
track_id INTEGER NOT NULL,
|
|
39
|
+
position INTEGER NOT NULL,
|
|
40
|
+
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
41
|
+
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
|
42
|
+
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE,
|
|
43
|
+
UNIQUE(playlist_id, track_id)
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
|
|
46
|
+
|
|
47
|
+
-- History table
|
|
48
|
+
CREATE TABLE IF NOT EXISTS history (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
track_id INTEGER NOT NULL,
|
|
51
|
+
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
play_count INTEGER DEFAULT 1,
|
|
53
|
+
last_position INTEGER DEFAULT 0,
|
|
54
|
+
completed INTEGER DEFAULT 0,
|
|
55
|
+
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_history_played_at ON history(played_at DESC);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
|
|
59
|
+
|
|
60
|
+
-- Schema version table
|
|
61
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
62
|
+
version INTEGER PRIMARY KEY,
|
|
63
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
64
|
+
);
|
|
65
|
+
""",
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MigrationManager:
|
|
71
|
+
"""Manages database migrations."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
74
|
+
self._conn = conn
|
|
75
|
+
self._ensure_version_table()
|
|
76
|
+
|
|
77
|
+
def _ensure_version_table(self) -> None:
|
|
78
|
+
"""Ensure schema_version table exists."""
|
|
79
|
+
self._conn.execute("""
|
|
80
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
81
|
+
version INTEGER PRIMARY KEY,
|
|
82
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
83
|
+
)
|
|
84
|
+
""")
|
|
85
|
+
self._conn.commit()
|
|
86
|
+
|
|
87
|
+
def get_current_version(self) -> int:
|
|
88
|
+
"""Get the current schema version."""
|
|
89
|
+
cursor = self._conn.execute(
|
|
90
|
+
"SELECT MAX(version) FROM schema_version"
|
|
91
|
+
)
|
|
92
|
+
result = cursor.fetchone()[0]
|
|
93
|
+
return result if result is not None else 0
|
|
94
|
+
|
|
95
|
+
def get_pending_migrations(self) -> list[tuple[int, str, str]]:
|
|
96
|
+
"""Get list of pending migrations."""
|
|
97
|
+
current = self.get_current_version()
|
|
98
|
+
return [m for m in MIGRATIONS if m[0] > current]
|
|
99
|
+
|
|
100
|
+
def apply_migration(self, version: int, description: str, sql: str) -> None:
|
|
101
|
+
"""Apply a single migration."""
|
|
102
|
+
# Execute migration SQL
|
|
103
|
+
self._conn.executescript(sql)
|
|
104
|
+
|
|
105
|
+
# Record the migration
|
|
106
|
+
self._conn.execute(
|
|
107
|
+
"INSERT INTO schema_version (version) VALUES (?)",
|
|
108
|
+
(version,),
|
|
109
|
+
)
|
|
110
|
+
self._conn.commit()
|
|
111
|
+
|
|
112
|
+
def migrate(self, target_version: int | None = None) -> list[int]:
|
|
113
|
+
"""Run pending migrations up to target version. Returns applied versions."""
|
|
114
|
+
applied = []
|
|
115
|
+
pending = self.get_pending_migrations()
|
|
116
|
+
|
|
117
|
+
for version, description, sql in pending:
|
|
118
|
+
if target_version is not None and version > target_version:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
self.apply_migration(version, description, sql)
|
|
122
|
+
applied.append(version)
|
|
123
|
+
|
|
124
|
+
return applied
|
|
125
|
+
|
|
126
|
+
def needs_migration(self) -> bool:
|
|
127
|
+
"""Check if there are pending migrations."""
|
|
128
|
+
return len(self.get_pending_migrations()) > 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_migrations(conn: sqlite3.Connection) -> list[int]:
|
|
132
|
+
"""Convenience function to run all pending migrations."""
|
|
133
|
+
manager = MigrationManager(conn)
|
|
134
|
+
return manager.migrate()
|
wrkmon/data/models.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Data models for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Track:
|
|
10
|
+
"""Represents a track/video."""
|
|
11
|
+
|
|
12
|
+
video_id: str
|
|
13
|
+
title: str
|
|
14
|
+
channel: str
|
|
15
|
+
duration: int # seconds
|
|
16
|
+
thumbnail_url: Optional[str] = None
|
|
17
|
+
id: Optional[int] = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def url(self) -> str:
|
|
21
|
+
"""Get YouTube URL."""
|
|
22
|
+
return f"https://www.youtube.com/watch?v={self.video_id}"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def duration_str(self) -> str:
|
|
26
|
+
"""Get formatted duration."""
|
|
27
|
+
mins, secs = divmod(self.duration, 60)
|
|
28
|
+
hours, mins = divmod(mins, 60)
|
|
29
|
+
if hours > 0:
|
|
30
|
+
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
31
|
+
return f"{mins}:{secs:02d}"
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
"""Convert to dictionary."""
|
|
35
|
+
return {
|
|
36
|
+
"id": self.id,
|
|
37
|
+
"video_id": self.video_id,
|
|
38
|
+
"title": self.title,
|
|
39
|
+
"channel": self.channel,
|
|
40
|
+
"duration": self.duration,
|
|
41
|
+
"thumbnail_url": self.thumbnail_url,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict) -> "Track":
|
|
46
|
+
"""Create from dictionary."""
|
|
47
|
+
return cls(
|
|
48
|
+
id=data.get("id"),
|
|
49
|
+
video_id=data["video_id"],
|
|
50
|
+
title=data["title"],
|
|
51
|
+
channel=data["channel"],
|
|
52
|
+
duration=data["duration"],
|
|
53
|
+
thumbnail_url=data.get("thumbnail_url"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Playlist:
|
|
59
|
+
"""Represents a playlist."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
description: str = ""
|
|
63
|
+
tracks: list[Track] = field(default_factory=list)
|
|
64
|
+
created_at: Optional[datetime] = None
|
|
65
|
+
updated_at: Optional[datetime] = None
|
|
66
|
+
id: Optional[int] = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def track_count(self) -> int:
|
|
70
|
+
"""Get number of tracks."""
|
|
71
|
+
return len(self.tracks)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def total_duration(self) -> int:
|
|
75
|
+
"""Get total duration in seconds."""
|
|
76
|
+
return sum(t.duration for t in self.tracks)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def total_duration_str(self) -> str:
|
|
80
|
+
"""Get formatted total duration."""
|
|
81
|
+
total = self.total_duration
|
|
82
|
+
hours, remainder = divmod(total, 3600)
|
|
83
|
+
mins, secs = divmod(remainder, 60)
|
|
84
|
+
if hours > 0:
|
|
85
|
+
return f"{hours}h {mins}m"
|
|
86
|
+
return f"{mins}m {secs}s"
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
"""Convert to dictionary."""
|
|
90
|
+
return {
|
|
91
|
+
"id": self.id,
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"description": self.description,
|
|
94
|
+
"tracks": [t.to_dict() for t in self.tracks],
|
|
95
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
96
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, data: dict) -> "Playlist":
|
|
101
|
+
"""Create from dictionary."""
|
|
102
|
+
return cls(
|
|
103
|
+
id=data.get("id"),
|
|
104
|
+
name=data["name"],
|
|
105
|
+
description=data.get("description", ""),
|
|
106
|
+
tracks=[Track.from_dict(t) for t in data.get("tracks", [])],
|
|
107
|
+
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
|
|
108
|
+
updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class HistoryEntry:
|
|
114
|
+
"""Represents a play history entry."""
|
|
115
|
+
|
|
116
|
+
track: Track
|
|
117
|
+
played_at: datetime
|
|
118
|
+
play_count: int = 1
|
|
119
|
+
last_position: int = 0 # seconds, for resume
|
|
120
|
+
completed: bool = False
|
|
121
|
+
id: Optional[int] = None
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict:
|
|
124
|
+
"""Convert to dictionary."""
|
|
125
|
+
return {
|
|
126
|
+
"id": self.id,
|
|
127
|
+
"track": self.track.to_dict(),
|
|
128
|
+
"played_at": self.played_at.isoformat(),
|
|
129
|
+
"play_count": self.play_count,
|
|
130
|
+
"last_position": self.last_position,
|
|
131
|
+
"completed": self.completed,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_dict(cls, data: dict) -> "HistoryEntry":
|
|
136
|
+
"""Create from dictionary."""
|
|
137
|
+
return cls(
|
|
138
|
+
id=data.get("id"),
|
|
139
|
+
track=Track.from_dict(data["track"]),
|
|
140
|
+
played_at=datetime.fromisoformat(data["played_at"]),
|
|
141
|
+
play_count=data.get("play_count", 1),
|
|
142
|
+
last_position=data.get("last_position", 0),
|
|
143
|
+
completed=data.get("completed", False),
|
|
144
|
+
)
|
wrkmon/ui/__init__.py
ADDED
wrkmon/ui/components.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Reusable TUI components for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.widgets import Static, Input, ProgressBar, Label
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
|
|
8
|
+
from wrkmon.utils.stealth import get_stealth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HeaderBar(Static):
|
|
12
|
+
"""Header bar showing app name and fake system stats."""
|
|
13
|
+
|
|
14
|
+
cpu = reactive(23)
|
|
15
|
+
mem = reactive(45)
|
|
16
|
+
|
|
17
|
+
def __init__(self, **kwargs):
|
|
18
|
+
super().__init__(**kwargs)
|
|
19
|
+
self._stealth = get_stealth()
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
with Horizontal(id="header"):
|
|
23
|
+
yield Static("WRKMON v1.0.0", id="header-title")
|
|
24
|
+
yield Static(self._get_stats_text(), id="header-stats")
|
|
25
|
+
|
|
26
|
+
def _get_stats_text(self) -> str:
|
|
27
|
+
return f"[CPU: {self.cpu}%] [MEM: {self.mem}%]"
|
|
28
|
+
|
|
29
|
+
def update_stats(self) -> None:
|
|
30
|
+
"""Update fake system stats."""
|
|
31
|
+
self.cpu = self._stealth.get_fake_cpu()
|
|
32
|
+
self.mem = self._stealth.get_fake_memory()
|
|
33
|
+
stats = self.query_one("#header-stats", Static)
|
|
34
|
+
stats.update(self._get_stats_text())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SearchBar(Static):
|
|
38
|
+
"""Search input bar."""
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
with Horizontal(id="search-container"):
|
|
42
|
+
yield Static("> Search: ", classes="search-label")
|
|
43
|
+
yield Input(placeholder="Enter search query...", id="search-input")
|
|
44
|
+
|
|
45
|
+
def focus_input(self) -> None:
|
|
46
|
+
"""Focus the search input."""
|
|
47
|
+
self.query_one("#search-input", Input).focus()
|
|
48
|
+
|
|
49
|
+
def clear_input(self) -> None:
|
|
50
|
+
"""Clear the search input."""
|
|
51
|
+
self.query_one("#search-input", Input).value = ""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def value(self) -> str:
|
|
55
|
+
"""Get current search value."""
|
|
56
|
+
return self.query_one("#search-input", Input).value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ResultsHeader(Static):
|
|
60
|
+
"""Header row for results list (styled like ps/top output)."""
|
|
61
|
+
|
|
62
|
+
def compose(self) -> ComposeResult:
|
|
63
|
+
with Horizontal(id="results-header"):
|
|
64
|
+
yield Static("#", classes="result-index")
|
|
65
|
+
yield Static("Process Name", classes="result-title")
|
|
66
|
+
yield Static("PID", classes="result-pid")
|
|
67
|
+
yield Static("Status", classes="result-status")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ResultItem(Static):
|
|
71
|
+
"""A single result item styled like a process entry."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
index: int,
|
|
76
|
+
title: str,
|
|
77
|
+
video_id: str,
|
|
78
|
+
status: str = "READY",
|
|
79
|
+
**kwargs,
|
|
80
|
+
):
|
|
81
|
+
super().__init__(**kwargs)
|
|
82
|
+
self.index = index
|
|
83
|
+
self.title = title
|
|
84
|
+
self.video_id = video_id
|
|
85
|
+
self.status = status
|
|
86
|
+
self._stealth = get_stealth()
|
|
87
|
+
|
|
88
|
+
def compose(self) -> ComposeResult:
|
|
89
|
+
fake_pid = self._stealth.get_fake_pid()
|
|
90
|
+
process_name = self._stealth.get_fake_process_name(self.title)
|
|
91
|
+
|
|
92
|
+
with Horizontal(classes="result-item"):
|
|
93
|
+
yield Static(f"{self.index}", classes="result-index")
|
|
94
|
+
yield Static(process_name[:40], classes="result-title")
|
|
95
|
+
yield Static(str(fake_pid), classes="result-pid")
|
|
96
|
+
status_class = f"status-{self.status.lower()}"
|
|
97
|
+
yield Static(self.status, classes=f"result-status {status_class}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PlayerBar(Static):
|
|
101
|
+
"""Bottom player bar showing current track and controls."""
|
|
102
|
+
|
|
103
|
+
title = reactive("No process running")
|
|
104
|
+
status = reactive("STOPPED")
|
|
105
|
+
position = reactive(0.0)
|
|
106
|
+
duration = reactive(0.0)
|
|
107
|
+
volume = reactive(80)
|
|
108
|
+
|
|
109
|
+
def __init__(self, **kwargs):
|
|
110
|
+
super().__init__(**kwargs)
|
|
111
|
+
self._stealth = get_stealth()
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
with Vertical(id="player-bar"):
|
|
115
|
+
# Now playing line
|
|
116
|
+
with Horizontal(id="now-playing"):
|
|
117
|
+
yield Static("NOW:", id="now-playing-label")
|
|
118
|
+
yield Static(self.title, id="now-playing-title")
|
|
119
|
+
yield Static(self._get_status_icon(), id="now-playing-status")
|
|
120
|
+
|
|
121
|
+
# Progress bar
|
|
122
|
+
with Horizontal(id="progress-container"):
|
|
123
|
+
yield ProgressBar(total=100, show_percentage=False, id="progress-bar")
|
|
124
|
+
yield Static(self._get_time_text(), id="progress-time")
|
|
125
|
+
|
|
126
|
+
# Volume bar
|
|
127
|
+
with Horizontal(id="volume-container"):
|
|
128
|
+
yield Static("VOL:", id="volume-label")
|
|
129
|
+
yield ProgressBar(total=100, show_percentage=False, id="volume-bar")
|
|
130
|
+
yield Static(f"{self.volume}%", id="volume-value")
|
|
131
|
+
|
|
132
|
+
def _get_status_icon(self) -> str:
|
|
133
|
+
icons = {
|
|
134
|
+
"playing": "▶",
|
|
135
|
+
"paused": "⏸",
|
|
136
|
+
"stopped": "⏹",
|
|
137
|
+
"buffering": "⟳",
|
|
138
|
+
}
|
|
139
|
+
return icons.get(self.status.lower(), "⏹")
|
|
140
|
+
|
|
141
|
+
def _get_time_text(self) -> str:
|
|
142
|
+
pos = self._stealth.format_duration(self.position)
|
|
143
|
+
dur = self._stealth.format_duration(self.duration)
|
|
144
|
+
return f"{pos} / {dur}"
|
|
145
|
+
|
|
146
|
+
def update_now_playing(self, title: str, status: str) -> None:
|
|
147
|
+
"""Update the now playing display."""
|
|
148
|
+
self.title = title
|
|
149
|
+
self.status = status
|
|
150
|
+
process_name = self._stealth.get_fake_process_name(title)
|
|
151
|
+
self.query_one("#now-playing-title", Static).update(f"{process_name}")
|
|
152
|
+
self.query_one("#now-playing-status", Static).update(self._get_status_icon())
|
|
153
|
+
|
|
154
|
+
def update_progress(self, position: float, duration: float) -> None:
|
|
155
|
+
"""Update the progress bar."""
|
|
156
|
+
self.position = position
|
|
157
|
+
self.duration = duration
|
|
158
|
+
|
|
159
|
+
progress_bar = self.query_one("#progress-bar", ProgressBar)
|
|
160
|
+
if duration > 0:
|
|
161
|
+
progress_bar.update(progress=(position / duration) * 100)
|
|
162
|
+
else:
|
|
163
|
+
progress_bar.update(progress=0)
|
|
164
|
+
|
|
165
|
+
self.query_one("#progress-time", Static).update(self._get_time_text())
|
|
166
|
+
|
|
167
|
+
def update_volume(self, volume: int) -> None:
|
|
168
|
+
"""Update the volume display."""
|
|
169
|
+
self.volume = volume
|
|
170
|
+
self.query_one("#volume-bar", ProgressBar).update(progress=volume)
|
|
171
|
+
self.query_one("#volume-value", Static).update(f"{volume}%")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class FooterBar(Static):
|
|
175
|
+
"""Footer showing keyboard shortcuts."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, hints: list[tuple[str, str]] = None, **kwargs):
|
|
178
|
+
super().__init__(**kwargs)
|
|
179
|
+
self.hints = hints or [
|
|
180
|
+
("/", "Search"),
|
|
181
|
+
("Space", "Play/Pause"),
|
|
182
|
+
("+/-", "Volume"),
|
|
183
|
+
("n/p", "Next/Prev"),
|
|
184
|
+
("q", "Queue"),
|
|
185
|
+
("h", "History"),
|
|
186
|
+
("Esc", "Back"),
|
|
187
|
+
("Ctrl+C", "Quit"),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
def compose(self) -> ComposeResult:
|
|
191
|
+
with Horizontal(id="footer"):
|
|
192
|
+
for key, action in self.hints:
|
|
193
|
+
yield Static(f"[{key}] {action}", classes="key-hint")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class LoadingIndicator(Static):
|
|
197
|
+
"""Loading indicator."""
|
|
198
|
+
|
|
199
|
+
def compose(self) -> ComposeResult:
|
|
200
|
+
yield Static("Loading...", classes="loading")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class EmptyState(Static):
|
|
204
|
+
"""Empty state message."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, message: str = "No data", **kwargs):
|
|
207
|
+
super().__init__(**kwargs)
|
|
208
|
+
self.message = message
|
|
209
|
+
|
|
210
|
+
def compose(self) -> ComposeResult:
|
|
211
|
+
yield Static(self.message, classes="empty-state")
|
wrkmon/ui/messages.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Custom Textual messages for wrkmon component communication."""
|
|
2
|
+
|
|
3
|
+
from textual.message import Message
|
|
4
|
+
|
|
5
|
+
from wrkmon.core.youtube import SearchResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrackSelected(Message):
|
|
9
|
+
"""Emitted when a track is selected for playback."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, result: SearchResult) -> None:
|
|
12
|
+
self.result = result
|
|
13
|
+
super().__init__()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TrackQueued(Message):
|
|
17
|
+
"""Emitted when a track is added to queue."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, result: SearchResult) -> None:
|
|
20
|
+
self.result = result
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlaybackStateChanged(Message):
|
|
25
|
+
"""Emitted when playback state changes."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
is_playing: bool,
|
|
30
|
+
position: float = 0.0,
|
|
31
|
+
duration: float = 0.0,
|
|
32
|
+
title: str = "",
|
|
33
|
+
) -> None:
|
|
34
|
+
self.is_playing = is_playing
|
|
35
|
+
self.position = position
|
|
36
|
+
self.duration = duration
|
|
37
|
+
self.title = title
|
|
38
|
+
super().__init__()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VolumeChanged(Message):
|
|
42
|
+
"""Emitted when volume changes."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, volume: int) -> None:
|
|
45
|
+
self.volume = volume
|
|
46
|
+
super().__init__()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ViewChanged(Message):
|
|
50
|
+
"""Emitted when the active view should change."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, view_name: str) -> None:
|
|
53
|
+
self.view_name = view_name
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SearchStarted(Message):
|
|
58
|
+
"""Emitted when a search begins."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, query: str) -> None:
|
|
61
|
+
self.query = query
|
|
62
|
+
super().__init__()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SearchCompleted(Message):
|
|
66
|
+
"""Emitted when search results are ready."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, results: list[SearchResult], query: str) -> None:
|
|
69
|
+
self.results = results
|
|
70
|
+
self.query = query
|
|
71
|
+
super().__init__()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class QueueUpdated(Message):
|
|
75
|
+
"""Emitted when the queue changes."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, queue_length: int, current_index: int) -> None:
|
|
78
|
+
self.queue_length = queue_length
|
|
79
|
+
self.current_index = current_index
|
|
80
|
+
super().__init__()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StatusMessage(Message):
|
|
84
|
+
"""Emitted to show a status message to the user."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: str, level: str = "info") -> None:
|
|
87
|
+
self.message = message
|
|
88
|
+
self.level = level # "info", "success", "warning", "error"
|
|
89
|
+
super().__init__()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""TUI screens for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from wrkmon.ui.screens.search import SearchScreen
|
|
4
|
+
from wrkmon.ui.screens.player import PlayerScreen
|
|
5
|
+
from wrkmon.ui.screens.playlist import PlaylistScreen
|
|
6
|
+
from wrkmon.ui.screens.history import HistoryScreen
|
|
7
|
+
|
|
8
|
+
__all__ = ["SearchScreen", "PlayerScreen", "PlaylistScreen", "HistoryScreen"]
|