wrkmon 1.0.0__tar.gz → 1.0.1__tar.gz

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.
Files changed (49) hide show
  1. {wrkmon-1.0.0/wrkmon.egg-info → wrkmon-1.0.1}/PKG-INFO +40 -67
  2. {wrkmon-1.0.0 → wrkmon-1.0.1}/README.md +37 -64
  3. {wrkmon-1.0.0 → wrkmon-1.0.1}/pyproject.toml +2 -2
  4. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/app.py +26 -2
  5. wrkmon-1.0.1/wrkmon/ui/views/search.py +258 -0
  6. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/widgets/player_bar.py +14 -0
  7. {wrkmon-1.0.0 → wrkmon-1.0.1/wrkmon.egg-info}/PKG-INFO +40 -67
  8. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon.egg-info/SOURCES.txt +1 -1
  9. wrkmon-1.0.0/wrkmon/ui/views/search.py +0 -150
  10. /wrkmon-1.0.0/LICENSE.txt → /wrkmon-1.0.1/LICENSE +0 -0
  11. {wrkmon-1.0.0 → wrkmon-1.0.1}/setup.cfg +0 -0
  12. {wrkmon-1.0.0 → wrkmon-1.0.1}/tests/test_core.py +0 -0
  13. {wrkmon-1.0.0 → wrkmon-1.0.1}/tests/test_utils.py +0 -0
  14. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/__init__.py +0 -0
  15. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/__main__.py +0 -0
  16. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/cli.py +0 -0
  17. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/core/__init__.py +0 -0
  18. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/core/cache.py +0 -0
  19. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/core/player.py +0 -0
  20. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/core/queue.py +0 -0
  21. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/core/youtube.py +0 -0
  22. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/data/__init__.py +0 -0
  23. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/data/database.py +0 -0
  24. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/data/migrations.py +0 -0
  25. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/data/models.py +0 -0
  26. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/__init__.py +0 -0
  27. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/components.py +0 -0
  28. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/messages.py +0 -0
  29. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/screens/__init__.py +0 -0
  30. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/screens/history.py +0 -0
  31. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/screens/player.py +0 -0
  32. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/screens/playlist.py +0 -0
  33. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/screens/search.py +0 -0
  34. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/theme.py +0 -0
  35. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/views/__init__.py +0 -0
  36. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/views/history.py +0 -0
  37. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/views/playlists.py +0 -0
  38. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/views/queue.py +0 -0
  39. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/widgets/__init__.py +0 -0
  40. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/widgets/header.py +0 -0
  41. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/ui/widgets/result_item.py +0 -0
  42. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/utils/__init__.py +0 -0
  43. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/utils/config.py +0 -0
  44. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/utils/mpv_installer.py +0 -0
  45. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon/utils/stealth.py +0 -0
  46. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon.egg-info/dependency_links.txt +0 -0
  47. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon.egg-info/entry_points.txt +0 -0
  48. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon.egg-info/requires.txt +0 -0
  49. {wrkmon-1.0.0 → wrkmon-1.0.1}/wrkmon.egg-info/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wrkmon
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Stealth TUI YouTube audio player - stream music while looking productive
5
- Author-email: Umar Khan Yousafzai <umar@example.com>
5
+ Author-email: Umar Khan Yousafzai <umerfarooqkhan325@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube
8
8
  Project-URL: Documentation, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube#readme
@@ -24,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.12
24
24
  Classifier: Topic :: Multimedia :: Sound/Audio :: Players
25
25
  Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
- License-File: LICENSE.txt
27
+ License-File: LICENSE
28
28
  Requires-Dist: textual>=0.50.0
29
29
  Requires-Dist: typer>=0.9.0
30
30
  Requires-Dist: yt-dlp>=2024.0.0
@@ -35,29 +35,40 @@ Requires-Dist: pytest>=8.0.0; extra == "dev"
35
35
  Requires-Dist: ruff>=0.3.0; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
- # wrkmon 🎵
38
+ # wrkmon
39
39
 
40
- **Stealth TUI YouTube Audio Player** - Stream music while looking productive!
40
+ **Terminal-based YouTube Music Player** - Listen to music right from your terminal!
41
41
 
42
- A terminal-based YouTube audio player that runs completely hidden in the background. No visible windows, no distractions - just music.
42
+ A beautiful TUI (Terminal User Interface) for streaming YouTube audio. No browser needed, just your terminal.
43
43
 
44
44
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
45
45
  ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
46
46
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)
47
+ ![PyPI](https://img.shields.io/pypi/v/wrkmon.svg)
47
48
 
48
49
  ## Features
49
50
 
50
- - 🔍 **YouTube Search** - Search and stream any YouTube audio
51
- - 👻 **Stealth Mode** - No visible windows, completely hidden playback
52
- - 🎨 **Beautiful TUI** - Clean terminal interface with keyboard controls
53
- - 📋 **Queue Management** - Add tracks, shuffle, repeat
54
- - 📜 **History & Playlists** - Track your listening history
55
- - ⌨️ **Keyboard Driven** - Full control without touching the mouse
56
- - 🖥️ **Cross-Platform** - Works on Windows, macOS, and Linux
51
+ - Search and stream YouTube audio
52
+ - Beautiful terminal interface
53
+ - Queue management with shuffle/repeat
54
+ - Play history and playlists
55
+ - Keyboard-driven controls
56
+ - Cross-platform (Windows, macOS, Linux)
57
57
 
58
58
  ## Installation
59
59
 
60
- ### Quick Install (Recommended)
60
+ ### pip (Recommended)
61
+
62
+ ```bash
63
+ pip install wrkmon
64
+ ```
65
+
66
+ > **Note:** You also need mpv installed:
67
+ > - Windows: `winget install mpv`
68
+ > - macOS: `brew install mpv`
69
+ > - Linux: `sudo apt install mpv`
70
+
71
+ ### Quick Install Scripts
61
72
 
62
73
  **Windows (PowerShell):**
63
74
  ```powershell
@@ -71,49 +82,24 @@ curl -sSL https://raw.githubusercontent.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtu
71
82
 
72
83
  ### Package Managers
73
84
 
74
- **Windows (Chocolatey):**
75
85
  ```powershell
86
+ # Windows (Chocolatey)
76
87
  choco install wrkmon
77
- ```
78
-
79
- **Windows (winget):**
80
- ```powershell
81
- winget install wrkmon
82
- ```
83
88
 
84
- **macOS (Homebrew):**
85
- ```bash
89
+ # macOS (Homebrew) - coming soon
86
90
  brew install wrkmon
87
- ```
88
91
 
89
- **Linux (Snap):**
90
- ```bash
92
+ # Linux (Snap) - coming soon
91
93
  sudo snap install wrkmon
92
94
  ```
93
95
 
94
- **Linux (apt):**
95
- ```bash
96
- sudo apt install wrkmon
97
- ```
98
-
99
- ### pip (All Platforms)
100
-
101
- ```bash
102
- pip install wrkmon
103
- ```
104
-
105
- > **Note:** If using pip, you need to install mpv separately:
106
- > - Windows: `winget install mpv` or `choco install mpv`
107
- > - macOS: `brew install mpv`
108
- > - Linux: `sudo apt install mpv`
109
-
110
96
  ## Usage
111
97
 
112
98
  ```bash
113
99
  wrkmon # Launch the TUI
114
- wrkmon search "q" # Quick search from terminal
115
- wrkmon play <id> # Play a specific video
116
- wrkmon history # View play history
100
+ wrkmon search "q" # Quick search
101
+ wrkmon play <id> # Play a video
102
+ wrkmon history # View history
117
103
  ```
118
104
 
119
105
  ## Keyboard Controls
@@ -132,7 +118,7 @@ wrkmon history # View play history
132
118
  | `F10` | Add to queue |
133
119
  | `/` | Focus search |
134
120
  | `Enter` | Play selected |
135
- | `a` | Add to queue (in list) |
121
+ | `a` | Add to queue |
136
122
  | `Ctrl+C` | Quit |
137
123
 
138
124
  ## Screenshots
@@ -141,38 +127,29 @@ wrkmon history # View play history
141
127
  ┌─────────────────────────────────────────────────────────┐
142
128
  │ wrkmon [Search] │
143
129
  ├─────────────────────────────────────────────────────────┤
144
- │ Search: lofi hip hop
130
+ │ Search: lofi beats
145
131
  ├─────────────────────────────────────────────────────────┤
146
- │ # Process PID Duration
147
- │ 1 node_worker_847291 8472 3:24:15
148
- │ 2 webpack_compile_process 9123 2:45:00
149
- │ 3 eslint_daemon_runner 7834 1:30:22
132
+ │ # Title Channel Duration│
133
+ │ 1 Lofi Hip Hop Radio ChilledCow 3:24:15│
134
+ │ 2 Jazz Lofi Beats Lofi Girl 2:45:00│
135
+ │ 3 Study Music Playlist Study 1:30:22│
150
136
  ├─────────────────────────────────────────────────────────┤
151
- │ ▶ Now Playing: lofi hip hop beats advancement █████░░░░░ 1:23:45 │
137
+ │ ▶ Now Playing: Lofi Beats advancement █████░░░░░ 1:23:45 │
152
138
  │ F1 Search F2 Queue F5 Play/Pause F9 Stop │
153
139
  └─────────────────────────────────────────────────────────┘
154
140
  ```
155
141
 
156
- ## Why wrkmon?
157
-
158
- Ever wanted to listen to music at work but worried about monitoring software catching you? wrkmon disguises itself as a legitimate development process while streaming your favorite tunes in the background. The TUI looks like a process monitor, and the audio plays through mpv with no visible windows.
159
-
160
142
  ## Requirements
161
143
 
162
144
  - Python 3.10+
163
- - mpv (automatically installed with package managers)
145
+ - mpv media player
164
146
 
165
147
  ## Development
166
148
 
167
149
  ```bash
168
- # Clone the repo
169
150
  git clone https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube.git
170
151
  cd Wrkmon-TUI-Youtube
171
-
172
- # Install in development mode
173
152
  pip install -e ".[dev]"
174
-
175
- # Run tests
176
153
  pytest
177
154
  ```
178
155
 
@@ -180,14 +157,10 @@ pytest
180
157
 
181
158
  MIT License - see [LICENSE](LICENSE) for details.
182
159
 
183
- ## Contributing
184
-
185
- Contributions are welcome! Please feel free to submit a Pull Request.
186
-
187
160
  ## Author
188
161
 
189
162
  **Umar Khan Yousafzai**
190
163
 
191
164
  ---
192
165
 
193
- *Made with ❤️ for productive procrastinators everywhere*
166
+ *Enjoy your music!*
@@ -1,26 +1,37 @@
1
- # wrkmon 🎵
1
+ # wrkmon
2
2
 
3
- **Stealth TUI YouTube Audio Player** - Stream music while looking productive!
3
+ **Terminal-based YouTube Music Player** - Listen to music right from your terminal!
4
4
 
5
- A terminal-based YouTube audio player that runs completely hidden in the background. No visible windows, no distractions - just music.
5
+ A beautiful TUI (Terminal User Interface) for streaming YouTube audio. No browser needed, just your terminal.
6
6
 
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
8
8
  ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
9
9
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)
10
+ ![PyPI](https://img.shields.io/pypi/v/wrkmon.svg)
10
11
 
11
12
  ## Features
12
13
 
13
- - 🔍 **YouTube Search** - Search and stream any YouTube audio
14
- - 👻 **Stealth Mode** - No visible windows, completely hidden playback
15
- - 🎨 **Beautiful TUI** - Clean terminal interface with keyboard controls
16
- - 📋 **Queue Management** - Add tracks, shuffle, repeat
17
- - 📜 **History & Playlists** - Track your listening history
18
- - ⌨️ **Keyboard Driven** - Full control without touching the mouse
19
- - 🖥️ **Cross-Platform** - Works on Windows, macOS, and Linux
14
+ - Search and stream YouTube audio
15
+ - Beautiful terminal interface
16
+ - Queue management with shuffle/repeat
17
+ - Play history and playlists
18
+ - Keyboard-driven controls
19
+ - Cross-platform (Windows, macOS, Linux)
20
20
 
21
21
  ## Installation
22
22
 
23
- ### Quick Install (Recommended)
23
+ ### pip (Recommended)
24
+
25
+ ```bash
26
+ pip install wrkmon
27
+ ```
28
+
29
+ > **Note:** You also need mpv installed:
30
+ > - Windows: `winget install mpv`
31
+ > - macOS: `brew install mpv`
32
+ > - Linux: `sudo apt install mpv`
33
+
34
+ ### Quick Install Scripts
24
35
 
25
36
  **Windows (PowerShell):**
26
37
  ```powershell
@@ -34,49 +45,24 @@ curl -sSL https://raw.githubusercontent.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtu
34
45
 
35
46
  ### Package Managers
36
47
 
37
- **Windows (Chocolatey):**
38
48
  ```powershell
49
+ # Windows (Chocolatey)
39
50
  choco install wrkmon
40
- ```
41
-
42
- **Windows (winget):**
43
- ```powershell
44
- winget install wrkmon
45
- ```
46
51
 
47
- **macOS (Homebrew):**
48
- ```bash
52
+ # macOS (Homebrew) - coming soon
49
53
  brew install wrkmon
50
- ```
51
54
 
52
- **Linux (Snap):**
53
- ```bash
55
+ # Linux (Snap) - coming soon
54
56
  sudo snap install wrkmon
55
57
  ```
56
58
 
57
- **Linux (apt):**
58
- ```bash
59
- sudo apt install wrkmon
60
- ```
61
-
62
- ### pip (All Platforms)
63
-
64
- ```bash
65
- pip install wrkmon
66
- ```
67
-
68
- > **Note:** If using pip, you need to install mpv separately:
69
- > - Windows: `winget install mpv` or `choco install mpv`
70
- > - macOS: `brew install mpv`
71
- > - Linux: `sudo apt install mpv`
72
-
73
59
  ## Usage
74
60
 
75
61
  ```bash
76
62
  wrkmon # Launch the TUI
77
- wrkmon search "q" # Quick search from terminal
78
- wrkmon play <id> # Play a specific video
79
- wrkmon history # View play history
63
+ wrkmon search "q" # Quick search
64
+ wrkmon play <id> # Play a video
65
+ wrkmon history # View history
80
66
  ```
81
67
 
82
68
  ## Keyboard Controls
@@ -95,7 +81,7 @@ wrkmon history # View play history
95
81
  | `F10` | Add to queue |
96
82
  | `/` | Focus search |
97
83
  | `Enter` | Play selected |
98
- | `a` | Add to queue (in list) |
84
+ | `a` | Add to queue |
99
85
  | `Ctrl+C` | Quit |
100
86
 
101
87
  ## Screenshots
@@ -104,38 +90,29 @@ wrkmon history # View play history
104
90
  ┌─────────────────────────────────────────────────────────┐
105
91
  │ wrkmon [Search] │
106
92
  ├─────────────────────────────────────────────────────────┤
107
- │ Search: lofi hip hop
93
+ │ Search: lofi beats
108
94
  ├─────────────────────────────────────────────────────────┤
109
- │ # Process PID Duration
110
- │ 1 node_worker_847291 8472 3:24:15
111
- │ 2 webpack_compile_process 9123 2:45:00
112
- │ 3 eslint_daemon_runner 7834 1:30:22
95
+ │ # Title Channel Duration│
96
+ │ 1 Lofi Hip Hop Radio ChilledCow 3:24:15│
97
+ │ 2 Jazz Lofi Beats Lofi Girl 2:45:00│
98
+ │ 3 Study Music Playlist Study 1:30:22│
113
99
  ├─────────────────────────────────────────────────────────┤
114
- │ ▶ Now Playing: lofi hip hop beats advancement █████░░░░░ 1:23:45 │
100
+ │ ▶ Now Playing: Lofi Beats advancement █████░░░░░ 1:23:45 │
115
101
  │ F1 Search F2 Queue F5 Play/Pause F9 Stop │
116
102
  └─────────────────────────────────────────────────────────┘
117
103
  ```
118
104
 
119
- ## Why wrkmon?
120
-
121
- Ever wanted to listen to music at work but worried about monitoring software catching you? wrkmon disguises itself as a legitimate development process while streaming your favorite tunes in the background. The TUI looks like a process monitor, and the audio plays through mpv with no visible windows.
122
-
123
105
  ## Requirements
124
106
 
125
107
  - Python 3.10+
126
- - mpv (automatically installed with package managers)
108
+ - mpv media player
127
109
 
128
110
  ## Development
129
111
 
130
112
  ```bash
131
- # Clone the repo
132
113
  git clone https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube.git
133
114
  cd Wrkmon-TUI-Youtube
134
-
135
- # Install in development mode
136
115
  pip install -e ".[dev]"
137
-
138
- # Run tests
139
116
  pytest
140
117
  ```
141
118
 
@@ -143,14 +120,10 @@ pytest
143
120
 
144
121
  MIT License - see [LICENSE](LICENSE) for details.
145
122
 
146
- ## Contributing
147
-
148
- Contributions are welcome! Please feel free to submit a Pull Request.
149
-
150
123
  ## Author
151
124
 
152
125
  **Umar Khan Yousafzai**
153
126
 
154
127
  ---
155
128
 
156
- *Made with ❤️ for productive procrastinators everywhere*
129
+ *Enjoy your music!*
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wrkmon"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "Stealth TUI YouTube audio player - stream music while looking productive"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  authors = [
13
- { name = "Umar Khan Yousafzai", email = "umar@example.com" }
13
+ { name = "Umar Khan Yousafzai", email = "umerfarooqkhan325@gmail.com" }
14
14
  ]
15
15
  keywords = ["youtube", "audio", "player", "tui", "music", "stealth", "productivity", "terminal"]
16
16
  classifiers = [
@@ -329,12 +329,18 @@ class WrkmonApp(App):
329
329
  # ----------------------------------------
330
330
  async def _update_playback_display(self) -> None:
331
331
  """Update the player bar with current playback position."""
332
+ player_bar = self._get_player_bar()
333
+
334
+ # Always sync repeat mode to player bar
335
+ try:
336
+ player_bar.repeat_mode = self.queue.repeat_mode
337
+ except Exception:
338
+ pass
339
+
332
340
  if not self._current_track:
333
341
  return
334
342
 
335
343
  try:
336
- player_bar = self._get_player_bar()
337
-
338
344
  # Get current position and duration via IPC
339
345
  pos = await self.player.get_position()
340
346
  dur = await self.player.get_duration()
@@ -391,6 +397,12 @@ class WrkmonApp(App):
391
397
  self.query_one("#queue", QueueView).refresh_queue()
392
398
  except Exception:
393
399
  pass
400
+ # Auto-focus list when switching to search (if has results)
401
+ elif view_name == "search":
402
+ try:
403
+ self.query_one("#search", SearchView).focus_list()
404
+ except Exception:
405
+ pass
394
406
 
395
407
  async def action_toggle_pause(self) -> None:
396
408
  """Smart play/pause - starts playback if nothing playing."""
@@ -407,6 +419,18 @@ class WrkmonApp(App):
407
419
  await self.toggle_pause()
408
420
  return
409
421
 
422
+ # Nothing playing - if in search view with selected item, play it
423
+ if self._current_view == "search":
424
+ try:
425
+ search_view = self.query_one("#search", SearchView)
426
+ result = search_view._get_selected()
427
+ if result:
428
+ logger.info(f" -> Playing selected search result: {result.title}")
429
+ await self.play_track(result)
430
+ return
431
+ except Exception:
432
+ pass
433
+
410
434
  # Nothing playing - try to play from queue
411
435
  current = self.queue.current
412
436
  if current:
@@ -0,0 +1,258 @@
1
+ """Search view container for wrkmon."""
2
+
3
+ import logging
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Static, Input, ListView, ListItem, Label
7
+ from textual.binding import Binding
8
+ from textual.events import Key
9
+ from textual import on
10
+
11
+ from wrkmon.core.youtube import SearchResult
12
+ from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
13
+ from wrkmon.ui.widgets.result_item import ResultItem
14
+
15
+ logger = logging.getLogger("wrkmon.search")
16
+
17
+
18
+ class LoadMoreItem(ListItem):
19
+ """Special list item for loading more results."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self.is_load_more = True
24
+
25
+ def compose(self):
26
+ yield Label(" >>> Load More Results <<<", classes="load-more")
27
+
28
+
29
+ class SearchView(Vertical):
30
+ """Main search view - search YouTube and display results."""
31
+
32
+ BINDINGS = [
33
+ Binding("a", "queue_selected", "Add to Queue", show=True),
34
+ Binding("escape", "clear_search", "Clear", show=True),
35
+ Binding("/", "focus_search", "Search", show=True),
36
+ Binding("space", "play_selected", "Play", show=False, priority=True),
37
+ Binding("r", "toggle_repeat", "Repeat", show=True),
38
+ ]
39
+
40
+ def __init__(self, **kwargs) -> None:
41
+ super().__init__(**kwargs)
42
+ self.results: list[SearchResult] = []
43
+ self._is_searching = False
44
+ self._current_query = ""
45
+ self._load_more_offset = 0
46
+ self._batch_size = 15
47
+
48
+ def compose(self) -> ComposeResult:
49
+ yield Static("SEARCH", id="view-title")
50
+
51
+ with Vertical(id="search-container"):
52
+ yield Input(
53
+ placeholder="Search processes...",
54
+ id="search-input",
55
+ )
56
+
57
+ yield Static(
58
+ " # Process PID Duration Status",
59
+ id="list-header",
60
+ )
61
+
62
+ yield ListView(id="results-list")
63
+ yield Static("Type to search", id="status-bar")
64
+
65
+ def on_mount(self) -> None:
66
+ """Focus the search input on mount."""
67
+ self.query_one("#search-input", Input).focus()
68
+
69
+ @on(Input.Submitted, "#search-input")
70
+ async def handle_search(self, event: Input.Submitted) -> None:
71
+ """Execute search when Enter is pressed."""
72
+ query = event.value.strip()
73
+ if not query or self._is_searching:
74
+ return
75
+
76
+ self._is_searching = True
77
+ self._update_status("Searching...")
78
+ self._current_query = query
79
+ self._load_more_offset = 0
80
+
81
+ try:
82
+ # Access the app's YouTube client
83
+ youtube = self.app.youtube
84
+ self.results = await youtube.search(query, max_results=self._batch_size)
85
+ self._display_results(show_load_more=True)
86
+ except Exception as e:
87
+ self._update_status(f"Search failed: {e}")
88
+ self.post_message(StatusMessage(f"Search error: {e}", "error"))
89
+ finally:
90
+ self._is_searching = False
91
+
92
+ async def _load_more_results(self) -> None:
93
+ """Load more search results."""
94
+ if not self._current_query or self._is_searching:
95
+ return
96
+
97
+ self._is_searching = True
98
+ self._update_status("Loading more...")
99
+
100
+ try:
101
+ youtube = self.app.youtube
102
+ self._load_more_offset += self._batch_size
103
+ # Search with offset by fetching more and skipping existing
104
+ new_results = await youtube.search(
105
+ self._current_query,
106
+ max_results=self._batch_size + self._load_more_offset
107
+ )
108
+ # Get only the new results we don't have yet
109
+ if len(new_results) > len(self.results):
110
+ self.results = new_results
111
+ self._display_results(show_load_more=True)
112
+ else:
113
+ self._update_status(f"No more results | Found {len(self.results)} total")
114
+ except Exception as e:
115
+ self._update_status(f"Load more failed: {e}")
116
+ finally:
117
+ self._is_searching = False
118
+
119
+ def _display_results(self, show_load_more: bool = False) -> None:
120
+ """Display search results in the list."""
121
+ list_view = self.query_one("#results-list", ListView)
122
+ list_view.clear()
123
+
124
+ if not self.results:
125
+ self._update_status("No results found")
126
+ return
127
+
128
+ for i, result in enumerate(self.results, 1):
129
+ list_view.append(ResultItem(result, i))
130
+
131
+ # Add "Load More" option at the end
132
+ if show_load_more:
133
+ list_view.append(LoadMoreItem())
134
+
135
+ # Build status with repeat indicator
136
+ repeat_status = self._get_repeat_status()
137
+ status = f"Found {len(self.results)} | Enter/Space=Play A=Queue R=Repeat{repeat_status}"
138
+ self._update_status(status)
139
+ list_view.focus()
140
+
141
+ def _get_repeat_status(self) -> str:
142
+ """Get repeat mode status string."""
143
+ try:
144
+ mode = self.app.queue.repeat_mode
145
+ if mode == "one":
146
+ return " [REPEAT ONE]"
147
+ elif mode == "all":
148
+ return " [REPEAT ALL]"
149
+ except Exception:
150
+ pass
151
+ return ""
152
+
153
+ def _update_status(self, message: str) -> None:
154
+ """Update the status bar."""
155
+ try:
156
+ self.query_one("#status-bar", Static).update(message)
157
+ except Exception:
158
+ pass
159
+
160
+ def _get_selected(self) -> SearchResult | None:
161
+ """Get the currently selected result."""
162
+ list_view = self.query_one("#results-list", ListView)
163
+ if list_view.highlighted_child is None:
164
+ return None
165
+
166
+ item = list_view.highlighted_child
167
+ if isinstance(item, ResultItem):
168
+ return item.result
169
+ return None
170
+
171
+ def action_clear_search(self) -> None:
172
+ """Clear the search input."""
173
+ search_input = self.query_one("#search-input", Input)
174
+ if search_input.value:
175
+ search_input.value = ""
176
+ search_input.focus()
177
+
178
+ @on(ListView.Selected, "#results-list")
179
+ async def handle_result_selected(self, event: ListView.Selected) -> None:
180
+ """Handle Enter key on a result - play it or load more."""
181
+ if isinstance(event.item, LoadMoreItem):
182
+ await self._load_more_results()
183
+ elif isinstance(event.item, ResultItem):
184
+ result = event.item.result
185
+ self.post_message(TrackSelected(result))
186
+ repeat_status = self._get_repeat_status()
187
+ self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
188
+
189
+ def action_play_selected(self) -> None:
190
+ """Play the selected track (Space key)."""
191
+ list_view = self.query_one("#results-list", ListView)
192
+ if not list_view.has_focus:
193
+ return
194
+ result = self._get_selected()
195
+ if result:
196
+ self.post_message(TrackSelected(result))
197
+ repeat_status = self._get_repeat_status()
198
+ self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
199
+
200
+ def action_toggle_repeat(self) -> None:
201
+ """Cycle repeat mode (R key)."""
202
+ try:
203
+ mode = self.app.queue.cycle_repeat()
204
+ mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
205
+ repeat_status = self._get_repeat_status()
206
+ count = len(self.results) if self.results else 0
207
+ self._update_status(f"Repeat: {mode_names[mode]} | Found {count}{repeat_status}")
208
+ except Exception:
209
+ pass
210
+
211
+ def action_queue_selected(self) -> None:
212
+ """Add selected track to queue."""
213
+ logger.info("=== 'a' PRESSED: action_queue_selected ===")
214
+ result = self._get_selected()
215
+ logger.info(f" Selected result: {result}")
216
+ if result:
217
+ logger.info(f" Posting TrackQueued for: {result.title}")
218
+ self.post_message(TrackQueued(result))
219
+ repeat_status = self._get_repeat_status()
220
+ self._update_status(f"Queued: {result.title[:40]}...{repeat_status}")
221
+ else:
222
+ logger.warning(" No result selected!")
223
+
224
+ def focus_input(self) -> None:
225
+ """Focus the search input (called from parent)."""
226
+ self.query_one("#search-input", Input).focus()
227
+
228
+ def action_focus_search(self) -> None:
229
+ """Focus the search input (/ key)."""
230
+ self.focus_input()
231
+
232
+ def on_key(self, event: Key) -> None:
233
+ """Handle key events - up at top of list goes to search, down from search goes to list."""
234
+ if event.key == "up":
235
+ list_view = self.query_one("#results-list", ListView)
236
+ # If list is focused and at top (index 0), go to search input
237
+ if list_view.has_focus and list_view.index == 0:
238
+ self.focus_input()
239
+ event.prevent_default()
240
+ event.stop()
241
+ elif event.key == "down":
242
+ search_input = self.query_one("#search-input", Input)
243
+ # If search input is focused, go to list
244
+ if search_input.has_focus:
245
+ list_view = self.query_one("#results-list", ListView)
246
+ if len(list_view.children) > 0:
247
+ list_view.focus()
248
+ list_view.index = 0
249
+ event.prevent_default()
250
+ event.stop()
251
+
252
+ def focus_list(self) -> None:
253
+ """Focus the results list (called from parent)."""
254
+ list_view = self.query_one("#results-list", ListView)
255
+ if len(list_view.children) > 0:
256
+ list_view.focus()
257
+ else:
258
+ self.query_one("#search-input", Input).focus()
@@ -18,6 +18,7 @@ class PlayerBar(Static):
18
18
  duration = reactive(0.0)
19
19
  volume = reactive(80)
20
20
  status_text = reactive("") # For showing errors/buffering
21
+ repeat_mode = reactive("none") # none, one, all
21
22
 
22
23
  def __init__(self, **kwargs) -> None:
23
24
  super().__init__(**kwargs)
@@ -30,6 +31,7 @@ class PlayerBar(Static):
30
31
  yield Static("NOW", id="now-label", classes="label")
31
32
  yield Static(self._get_status_icon(), id="play-status")
32
33
  yield Static(self.title, id="track-title")
34
+ yield Static("", id="repeat-indicator")
33
35
 
34
36
  # Progress row
35
37
  with Horizontal(id="progress-row"):
@@ -93,6 +95,18 @@ class PlayerBar(Static):
93
95
  except Exception:
94
96
  pass
95
97
 
98
+ def watch_repeat_mode(self, new_mode: str) -> None:
99
+ """Update repeat indicator."""
100
+ try:
101
+ indicator = ""
102
+ if new_mode == "one":
103
+ indicator = "[R1]"
104
+ elif new_mode == "all":
105
+ indicator = "[RA]"
106
+ self.query_one("#repeat-indicator", Static).update(indicator)
107
+ except Exception:
108
+ pass
109
+
96
110
  def update_playback(
97
111
  self,
98
112
  title: str | None = None,
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wrkmon
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Stealth TUI YouTube audio player - stream music while looking productive
5
- Author-email: Umar Khan Yousafzai <umar@example.com>
5
+ Author-email: Umar Khan Yousafzai <umerfarooqkhan325@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube
8
8
  Project-URL: Documentation, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube#readme
@@ -24,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.12
24
24
  Classifier: Topic :: Multimedia :: Sound/Audio :: Players
25
25
  Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
- License-File: LICENSE.txt
27
+ License-File: LICENSE
28
28
  Requires-Dist: textual>=0.50.0
29
29
  Requires-Dist: typer>=0.9.0
30
30
  Requires-Dist: yt-dlp>=2024.0.0
@@ -35,29 +35,40 @@ Requires-Dist: pytest>=8.0.0; extra == "dev"
35
35
  Requires-Dist: ruff>=0.3.0; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
- # wrkmon 🎵
38
+ # wrkmon
39
39
 
40
- **Stealth TUI YouTube Audio Player** - Stream music while looking productive!
40
+ **Terminal-based YouTube Music Player** - Listen to music right from your terminal!
41
41
 
42
- A terminal-based YouTube audio player that runs completely hidden in the background. No visible windows, no distractions - just music.
42
+ A beautiful TUI (Terminal User Interface) for streaming YouTube audio. No browser needed, just your terminal.
43
43
 
44
44
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
45
45
  ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
46
46
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)
47
+ ![PyPI](https://img.shields.io/pypi/v/wrkmon.svg)
47
48
 
48
49
  ## Features
49
50
 
50
- - 🔍 **YouTube Search** - Search and stream any YouTube audio
51
- - 👻 **Stealth Mode** - No visible windows, completely hidden playback
52
- - 🎨 **Beautiful TUI** - Clean terminal interface with keyboard controls
53
- - 📋 **Queue Management** - Add tracks, shuffle, repeat
54
- - 📜 **History & Playlists** - Track your listening history
55
- - ⌨️ **Keyboard Driven** - Full control without touching the mouse
56
- - 🖥️ **Cross-Platform** - Works on Windows, macOS, and Linux
51
+ - Search and stream YouTube audio
52
+ - Beautiful terminal interface
53
+ - Queue management with shuffle/repeat
54
+ - Play history and playlists
55
+ - Keyboard-driven controls
56
+ - Cross-platform (Windows, macOS, Linux)
57
57
 
58
58
  ## Installation
59
59
 
60
- ### Quick Install (Recommended)
60
+ ### pip (Recommended)
61
+
62
+ ```bash
63
+ pip install wrkmon
64
+ ```
65
+
66
+ > **Note:** You also need mpv installed:
67
+ > - Windows: `winget install mpv`
68
+ > - macOS: `brew install mpv`
69
+ > - Linux: `sudo apt install mpv`
70
+
71
+ ### Quick Install Scripts
61
72
 
62
73
  **Windows (PowerShell):**
63
74
  ```powershell
@@ -71,49 +82,24 @@ curl -sSL https://raw.githubusercontent.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtu
71
82
 
72
83
  ### Package Managers
73
84
 
74
- **Windows (Chocolatey):**
75
85
  ```powershell
86
+ # Windows (Chocolatey)
76
87
  choco install wrkmon
77
- ```
78
-
79
- **Windows (winget):**
80
- ```powershell
81
- winget install wrkmon
82
- ```
83
88
 
84
- **macOS (Homebrew):**
85
- ```bash
89
+ # macOS (Homebrew) - coming soon
86
90
  brew install wrkmon
87
- ```
88
91
 
89
- **Linux (Snap):**
90
- ```bash
92
+ # Linux (Snap) - coming soon
91
93
  sudo snap install wrkmon
92
94
  ```
93
95
 
94
- **Linux (apt):**
95
- ```bash
96
- sudo apt install wrkmon
97
- ```
98
-
99
- ### pip (All Platforms)
100
-
101
- ```bash
102
- pip install wrkmon
103
- ```
104
-
105
- > **Note:** If using pip, you need to install mpv separately:
106
- > - Windows: `winget install mpv` or `choco install mpv`
107
- > - macOS: `brew install mpv`
108
- > - Linux: `sudo apt install mpv`
109
-
110
96
  ## Usage
111
97
 
112
98
  ```bash
113
99
  wrkmon # Launch the TUI
114
- wrkmon search "q" # Quick search from terminal
115
- wrkmon play <id> # Play a specific video
116
- wrkmon history # View play history
100
+ wrkmon search "q" # Quick search
101
+ wrkmon play <id> # Play a video
102
+ wrkmon history # View history
117
103
  ```
118
104
 
119
105
  ## Keyboard Controls
@@ -132,7 +118,7 @@ wrkmon history # View play history
132
118
  | `F10` | Add to queue |
133
119
  | `/` | Focus search |
134
120
  | `Enter` | Play selected |
135
- | `a` | Add to queue (in list) |
121
+ | `a` | Add to queue |
136
122
  | `Ctrl+C` | Quit |
137
123
 
138
124
  ## Screenshots
@@ -141,38 +127,29 @@ wrkmon history # View play history
141
127
  ┌─────────────────────────────────────────────────────────┐
142
128
  │ wrkmon [Search] │
143
129
  ├─────────────────────────────────────────────────────────┤
144
- │ Search: lofi hip hop
130
+ │ Search: lofi beats
145
131
  ├─────────────────────────────────────────────────────────┤
146
- │ # Process PID Duration
147
- │ 1 node_worker_847291 8472 3:24:15
148
- │ 2 webpack_compile_process 9123 2:45:00
149
- │ 3 eslint_daemon_runner 7834 1:30:22
132
+ │ # Title Channel Duration│
133
+ │ 1 Lofi Hip Hop Radio ChilledCow 3:24:15│
134
+ │ 2 Jazz Lofi Beats Lofi Girl 2:45:00│
135
+ │ 3 Study Music Playlist Study 1:30:22│
150
136
  ├─────────────────────────────────────────────────────────┤
151
- │ ▶ Now Playing: lofi hip hop beats advancement █████░░░░░ 1:23:45 │
137
+ │ ▶ Now Playing: Lofi Beats advancement █████░░░░░ 1:23:45 │
152
138
  │ F1 Search F2 Queue F5 Play/Pause F9 Stop │
153
139
  └─────────────────────────────────────────────────────────┘
154
140
  ```
155
141
 
156
- ## Why wrkmon?
157
-
158
- Ever wanted to listen to music at work but worried about monitoring software catching you? wrkmon disguises itself as a legitimate development process while streaming your favorite tunes in the background. The TUI looks like a process monitor, and the audio plays through mpv with no visible windows.
159
-
160
142
  ## Requirements
161
143
 
162
144
  - Python 3.10+
163
- - mpv (automatically installed with package managers)
145
+ - mpv media player
164
146
 
165
147
  ## Development
166
148
 
167
149
  ```bash
168
- # Clone the repo
169
150
  git clone https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube.git
170
151
  cd Wrkmon-TUI-Youtube
171
-
172
- # Install in development mode
173
152
  pip install -e ".[dev]"
174
-
175
- # Run tests
176
153
  pytest
177
154
  ```
178
155
 
@@ -180,14 +157,10 @@ pytest
180
157
 
181
158
  MIT License - see [LICENSE](LICENSE) for details.
182
159
 
183
- ## Contributing
184
-
185
- Contributions are welcome! Please feel free to submit a Pull Request.
186
-
187
160
  ## Author
188
161
 
189
162
  **Umar Khan Yousafzai**
190
163
 
191
164
  ---
192
165
 
193
- *Made with ❤️ for productive procrastinators everywhere*
166
+ *Enjoy your music!*
@@ -1,4 +1,4 @@
1
- LICENSE.txt
1
+ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  tests/test_core.py
@@ -1,150 +0,0 @@
1
- """Search view container for wrkmon."""
2
-
3
- import logging
4
- from textual.app import ComposeResult
5
- from textual.containers import Vertical
6
- from textual.widgets import Static, Input, ListView
7
- from textual.binding import Binding
8
- from textual.events import Key
9
- from textual import on
10
-
11
- from wrkmon.core.youtube import SearchResult
12
- from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
13
- from wrkmon.ui.widgets.result_item import ResultItem
14
-
15
- logger = logging.getLogger("wrkmon.search")
16
-
17
-
18
- class SearchView(Vertical):
19
- """Main search view - search YouTube and display results."""
20
-
21
- BINDINGS = [
22
- Binding("a", "queue_selected", "Add to Queue", show=True),
23
- Binding("escape", "clear_search", "Clear", show=True),
24
- Binding("/", "focus_search", "Search", show=True),
25
- ]
26
-
27
- def __init__(self, **kwargs) -> None:
28
- super().__init__(**kwargs)
29
- self.results: list[SearchResult] = []
30
- self._is_searching = False
31
-
32
- def compose(self) -> ComposeResult:
33
- yield Static("SEARCH", id="view-title")
34
-
35
- with Vertical(id="search-container"):
36
- yield Input(
37
- placeholder="Search processes...",
38
- id="search-input",
39
- )
40
-
41
- yield Static(
42
- " # Process PID Duration Status",
43
- id="list-header",
44
- )
45
-
46
- yield ListView(id="results-list")
47
- yield Static("Type to search", id="status-bar")
48
-
49
- def on_mount(self) -> None:
50
- """Focus the search input on mount."""
51
- self.query_one("#search-input", Input).focus()
52
-
53
- @on(Input.Submitted, "#search-input")
54
- async def handle_search(self, event: Input.Submitted) -> None:
55
- """Execute search when Enter is pressed."""
56
- query = event.value.strip()
57
- if not query or self._is_searching:
58
- return
59
-
60
- self._is_searching = True
61
- self._update_status("Searching...")
62
-
63
- try:
64
- # Access the app's YouTube client
65
- youtube = self.app.youtube
66
- self.results = await youtube.search(query, max_results=15)
67
- self._display_results()
68
- except Exception as e:
69
- self._update_status(f"Search failed: {e}")
70
- self.post_message(StatusMessage(f"Search error: {e}", "error"))
71
- finally:
72
- self._is_searching = False
73
-
74
- def _display_results(self) -> None:
75
- """Display search results in the list."""
76
- list_view = self.query_one("#results-list", ListView)
77
- list_view.clear()
78
-
79
- if not self.results:
80
- self._update_status("No results found")
81
- return
82
-
83
- for i, result in enumerate(self.results, 1):
84
- list_view.append(ResultItem(result, i))
85
-
86
- self._update_status(f"Found {len(self.results)} | Enter=Play A=Queue /=Search ↑↓=Nav")
87
- list_view.focus()
88
-
89
- def _update_status(self, message: str) -> None:
90
- """Update the status bar."""
91
- try:
92
- self.query_one("#status-bar", Static).update(message)
93
- except Exception:
94
- pass
95
-
96
- def _get_selected(self) -> SearchResult | None:
97
- """Get the currently selected result."""
98
- list_view = self.query_one("#results-list", ListView)
99
- if list_view.highlighted_child is None:
100
- return None
101
-
102
- item = list_view.highlighted_child
103
- if isinstance(item, ResultItem):
104
- return item.result
105
- return None
106
-
107
- def action_clear_search(self) -> None:
108
- """Clear the search input."""
109
- search_input = self.query_one("#search-input", Input)
110
- if search_input.value:
111
- search_input.value = ""
112
- search_input.focus()
113
-
114
- @on(ListView.Selected, "#results-list")
115
- def handle_result_selected(self, event: ListView.Selected) -> None:
116
- """Handle Enter key on a result - play it."""
117
- if isinstance(event.item, ResultItem):
118
- result = event.item.result
119
- self.post_message(TrackSelected(result))
120
- self._update_status(f"Playing: {result.title[:40]}...")
121
-
122
- def action_queue_selected(self) -> None:
123
- """Add selected track to queue."""
124
- logger.info("=== 'a' PRESSED: action_queue_selected ===")
125
- result = self._get_selected()
126
- logger.info(f" Selected result: {result}")
127
- if result:
128
- logger.info(f" Posting TrackQueued for: {result.title}")
129
- self.post_message(TrackQueued(result))
130
- self._update_status(f"Queued: {result.title[:40]}...")
131
- else:
132
- logger.warning(" No result selected!")
133
-
134
- def focus_input(self) -> None:
135
- """Focus the search input (called from parent)."""
136
- self.query_one("#search-input", Input).focus()
137
-
138
- def action_focus_search(self) -> None:
139
- """Focus the search input (/ key)."""
140
- self.focus_input()
141
-
142
- def on_key(self, event: Key) -> None:
143
- """Handle key events - up at top of list goes to search."""
144
- if event.key == "up":
145
- list_view = self.query_one("#results-list", ListView)
146
- # If list is focused and at top (index 0), go to search input
147
- if list_view.has_focus and list_view.index == 0:
148
- self.focus_input()
149
- event.prevent_default()
150
- event.stop()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes