wrkmon 1.0.0__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 +568 -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 +150 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +115 -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.0.dist-info/METADATA +193 -0
- wrkmon-1.0.0.dist-info/RECORD +41 -0
- wrkmon-1.0.0.dist-info/WHEEL +5 -0
- wrkmon-1.0.0.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- wrkmon-1.0.0.dist-info/top_level.txt +1 -0
wrkmon/core/player.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Audio player using mpv with IPC for proper control."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from wrkmon.utils.config import get_config
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("wrkmon.player")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AudioPlayer:
|
|
18
|
+
"""mpv audio player with IPC for pause/resume/seek."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._config = get_config()
|
|
22
|
+
self._process: Optional[subprocess.Popen] = None
|
|
23
|
+
self._current_url: Optional[str] = None
|
|
24
|
+
self._volume = 80
|
|
25
|
+
self._paused = False
|
|
26
|
+
self._position = 0.0
|
|
27
|
+
self._duration = 0.0
|
|
28
|
+
self._pipe_path = r"\\.\pipe\wrkmon_mpv" if sys.platform == "win32" else "/tmp/wrkmon_mpv.sock"
|
|
29
|
+
self._pipe = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_connected(self) -> bool:
|
|
33
|
+
return self._process is not None and self._process.poll() is None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_playing(self) -> bool:
|
|
37
|
+
return self.is_connected and not self._paused
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def current_position(self) -> float:
|
|
41
|
+
return self._position
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def duration(self) -> float:
|
|
45
|
+
return self._duration
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def volume(self) -> int:
|
|
49
|
+
return self._volume
|
|
50
|
+
|
|
51
|
+
async def start(self) -> bool:
|
|
52
|
+
"""Initialize player (no-op, starts on play)."""
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def _send_command(self, command: list) -> Optional[dict]:
|
|
56
|
+
"""Send command to mpv via IPC and get response."""
|
|
57
|
+
if not self.is_connected:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
if sys.platform == "win32":
|
|
62
|
+
import win32file
|
|
63
|
+
import win32pipe
|
|
64
|
+
|
|
65
|
+
# Open pipe
|
|
66
|
+
handle = win32file.CreateFile(
|
|
67
|
+
self._pipe_path,
|
|
68
|
+
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
69
|
+
0, None,
|
|
70
|
+
win32file.OPEN_EXISTING,
|
|
71
|
+
0, None
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Send command
|
|
75
|
+
cmd = json.dumps({"command": command}) + "\n"
|
|
76
|
+
win32file.WriteFile(handle, cmd.encode())
|
|
77
|
+
|
|
78
|
+
# Read response
|
|
79
|
+
result, data = win32file.ReadFile(handle, 4096)
|
|
80
|
+
win32file.CloseHandle(handle)
|
|
81
|
+
|
|
82
|
+
if data:
|
|
83
|
+
return json.loads(data.decode().strip())
|
|
84
|
+
else:
|
|
85
|
+
# Unix socket
|
|
86
|
+
import socket
|
|
87
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
88
|
+
sock.connect(self._pipe_path)
|
|
89
|
+
sock.settimeout(1.0)
|
|
90
|
+
|
|
91
|
+
cmd = json.dumps({"command": command}) + "\n"
|
|
92
|
+
sock.send(cmd.encode())
|
|
93
|
+
|
|
94
|
+
data = sock.recv(4096)
|
|
95
|
+
sock.close()
|
|
96
|
+
|
|
97
|
+
if data:
|
|
98
|
+
return json.loads(data.decode().strip())
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug(f"IPC command failed: {e}")
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
async def _send_command_async(self, command: list) -> Optional[dict]:
|
|
106
|
+
"""Async wrapper for send_command."""
|
|
107
|
+
return await asyncio.to_thread(self._send_command, command)
|
|
108
|
+
|
|
109
|
+
async def play(self, url: str) -> bool:
|
|
110
|
+
"""Play audio from URL - stealth mode with IPC."""
|
|
111
|
+
logger.info(f"=== player.play() called ===")
|
|
112
|
+
logger.info(f" URL: {url[:100]}...")
|
|
113
|
+
|
|
114
|
+
# Kill any existing playback
|
|
115
|
+
await self.stop()
|
|
116
|
+
|
|
117
|
+
mpv_path = self._config.mpv_path
|
|
118
|
+
logger.info(f" mpv_path: {mpv_path}")
|
|
119
|
+
|
|
120
|
+
# Check if mpv exists
|
|
121
|
+
mpv_exists = os.path.exists(mpv_path) if mpv_path != "mpv" else True
|
|
122
|
+
logger.info(f" mpv exists: {mpv_exists}")
|
|
123
|
+
|
|
124
|
+
# Args with IPC server for control
|
|
125
|
+
args = [
|
|
126
|
+
mpv_path,
|
|
127
|
+
"--no-video",
|
|
128
|
+
"--no-terminal",
|
|
129
|
+
"--really-quiet",
|
|
130
|
+
"--no-osc",
|
|
131
|
+
"--no-osd-bar",
|
|
132
|
+
"--force-window=no",
|
|
133
|
+
"--audio-display=no",
|
|
134
|
+
f"--volume={self._volume}",
|
|
135
|
+
f"--input-ipc-server={self._pipe_path}",
|
|
136
|
+
url,
|
|
137
|
+
]
|
|
138
|
+
logger.info(f" IPC pipe: {self._pipe_path}")
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
if sys.platform == "win32":
|
|
142
|
+
logger.info(" Creating Windows subprocess...")
|
|
143
|
+
self._process = subprocess.Popen(
|
|
144
|
+
args,
|
|
145
|
+
stdin=subprocess.DEVNULL,
|
|
146
|
+
stdout=subprocess.DEVNULL,
|
|
147
|
+
stderr=subprocess.DEVNULL,
|
|
148
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
logger.info(" Creating Unix subprocess...")
|
|
152
|
+
self._process = subprocess.Popen(
|
|
153
|
+
args,
|
|
154
|
+
stdin=subprocess.DEVNULL,
|
|
155
|
+
stdout=subprocess.DEVNULL,
|
|
156
|
+
stderr=subprocess.DEVNULL,
|
|
157
|
+
start_new_session=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info(f" Process created, PID: {self._process.pid}")
|
|
161
|
+
self._current_url = url
|
|
162
|
+
self._paused = False
|
|
163
|
+
|
|
164
|
+
# Wait for mpv to start and create pipe
|
|
165
|
+
await asyncio.sleep(1.0)
|
|
166
|
+
|
|
167
|
+
poll_result = self._process.poll()
|
|
168
|
+
if poll_result is None:
|
|
169
|
+
logger.info(" SUCCESS - mpv is running!")
|
|
170
|
+
# Try to get duration
|
|
171
|
+
await self._update_properties()
|
|
172
|
+
return True
|
|
173
|
+
else:
|
|
174
|
+
logger.error(f" FAILED - mpv exited with code: {poll_result}")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.exception(f" EXCEPTION: {e}")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
async def _update_properties(self) -> None:
|
|
182
|
+
"""Update position and duration from mpv."""
|
|
183
|
+
try:
|
|
184
|
+
# Get time position
|
|
185
|
+
result = await self._send_command_async(["get_property", "time-pos"])
|
|
186
|
+
if result and "data" in result:
|
|
187
|
+
self._position = float(result["data"] or 0)
|
|
188
|
+
|
|
189
|
+
# Get duration
|
|
190
|
+
result = await self._send_command_async(["get_property", "duration"])
|
|
191
|
+
if result and "data" in result:
|
|
192
|
+
self._duration = float(result["data"] or 0)
|
|
193
|
+
|
|
194
|
+
# Get pause state
|
|
195
|
+
result = await self._send_command_async(["get_property", "pause"])
|
|
196
|
+
if result and "data" in result:
|
|
197
|
+
self._paused = bool(result["data"])
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.debug(f"Failed to update properties: {e}")
|
|
201
|
+
|
|
202
|
+
async def stop(self) -> None:
|
|
203
|
+
"""Stop playback."""
|
|
204
|
+
if self._process:
|
|
205
|
+
try:
|
|
206
|
+
# Try graceful quit first
|
|
207
|
+
self._send_command(["quit"])
|
|
208
|
+
await asyncio.sleep(0.2)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
self._process.terminate()
|
|
214
|
+
self._process.wait(timeout=2)
|
|
215
|
+
except Exception:
|
|
216
|
+
try:
|
|
217
|
+
self._process.kill()
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
self._process = None
|
|
221
|
+
|
|
222
|
+
self._paused = False
|
|
223
|
+
self._position = 0.0
|
|
224
|
+
self._duration = 0.0
|
|
225
|
+
|
|
226
|
+
async def pause(self) -> None:
|
|
227
|
+
"""Pause playback via IPC."""
|
|
228
|
+
if self.is_connected:
|
|
229
|
+
result = await self._send_command_async(["set_property", "pause", True])
|
|
230
|
+
if result:
|
|
231
|
+
self._paused = True
|
|
232
|
+
logger.info("Paused via IPC")
|
|
233
|
+
else:
|
|
234
|
+
# Fallback - just set flag
|
|
235
|
+
self._paused = True
|
|
236
|
+
|
|
237
|
+
async def resume(self) -> None:
|
|
238
|
+
"""Resume playback via IPC."""
|
|
239
|
+
if self.is_connected:
|
|
240
|
+
result = await self._send_command_async(["set_property", "pause", False])
|
|
241
|
+
if result:
|
|
242
|
+
self._paused = False
|
|
243
|
+
logger.info("Resumed via IPC")
|
|
244
|
+
else:
|
|
245
|
+
# Fallback - restart if IPC fails
|
|
246
|
+
if self._current_url:
|
|
247
|
+
await self.play(self._current_url)
|
|
248
|
+
self._paused = False
|
|
249
|
+
|
|
250
|
+
async def toggle_pause(self) -> None:
|
|
251
|
+
"""Toggle pause state."""
|
|
252
|
+
if self._paused:
|
|
253
|
+
await self.resume()
|
|
254
|
+
else:
|
|
255
|
+
await self.pause()
|
|
256
|
+
|
|
257
|
+
async def set_volume(self, volume: int) -> None:
|
|
258
|
+
"""Set volume."""
|
|
259
|
+
self._volume = max(0, min(100, volume))
|
|
260
|
+
if self.is_connected:
|
|
261
|
+
await self._send_command_async(["set_property", "volume", self._volume])
|
|
262
|
+
|
|
263
|
+
async def seek(self, seconds: float, relative: bool = True) -> None:
|
|
264
|
+
"""Seek in current track."""
|
|
265
|
+
if self.is_connected:
|
|
266
|
+
if relative:
|
|
267
|
+
await self._send_command_async(["seek", seconds, "relative"])
|
|
268
|
+
else:
|
|
269
|
+
await self._send_command_async(["seek", seconds, "absolute"])
|
|
270
|
+
|
|
271
|
+
async def get_position(self) -> float:
|
|
272
|
+
"""Get current playback position."""
|
|
273
|
+
await self._update_properties()
|
|
274
|
+
return self._position
|
|
275
|
+
|
|
276
|
+
async def get_duration(self) -> float:
|
|
277
|
+
"""Get current track duration."""
|
|
278
|
+
await self._update_properties()
|
|
279
|
+
return self._duration
|
|
280
|
+
|
|
281
|
+
async def shutdown(self) -> None:
|
|
282
|
+
"""Shutdown player."""
|
|
283
|
+
await self.stop()
|
|
284
|
+
|
|
285
|
+
def on_property_change(self, name: str, callback) -> None:
|
|
286
|
+
"""Register callback - not implemented yet."""
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
async def get_property(self, name: str):
|
|
290
|
+
"""Get property."""
|
|
291
|
+
if name == "volume":
|
|
292
|
+
return self._volume
|
|
293
|
+
if name == "pause":
|
|
294
|
+
return self._paused
|
|
295
|
+
if name == "time-pos":
|
|
296
|
+
await self._update_properties()
|
|
297
|
+
return self._position
|
|
298
|
+
if name == "duration":
|
|
299
|
+
await self._update_properties()
|
|
300
|
+
return self._duration
|
|
301
|
+
return None
|
wrkmon/core/queue.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Play queue management."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from wrkmon.core.youtube import SearchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class QueueItem:
|
|
12
|
+
"""An item in the play queue."""
|
|
13
|
+
|
|
14
|
+
video_id: str
|
|
15
|
+
title: str
|
|
16
|
+
channel: str
|
|
17
|
+
duration: int
|
|
18
|
+
added_at: float = 0.0
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_search_result(cls, result: SearchResult) -> "QueueItem":
|
|
22
|
+
"""Create a queue item from a search result."""
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
return cls(
|
|
26
|
+
video_id=result.video_id,
|
|
27
|
+
title=result.title,
|
|
28
|
+
channel=result.channel,
|
|
29
|
+
duration=result.duration,
|
|
30
|
+
added_at=time.time(),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def url(self) -> str:
|
|
35
|
+
"""Get the YouTube URL."""
|
|
36
|
+
return f"https://www.youtube.com/watch?v={self.video_id}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PlayQueue:
|
|
41
|
+
"""Manages the play queue."""
|
|
42
|
+
|
|
43
|
+
items: list[QueueItem] = field(default_factory=list)
|
|
44
|
+
current_index: int = -1
|
|
45
|
+
shuffle_mode: bool = False
|
|
46
|
+
repeat_mode: str = "none" # none, one, all
|
|
47
|
+
_shuffle_order: list[int] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def current(self) -> Optional[QueueItem]:
|
|
51
|
+
"""Get the current item."""
|
|
52
|
+
if 0 <= self.current_index < len(self.items):
|
|
53
|
+
if self.shuffle_mode and self._shuffle_order:
|
|
54
|
+
actual_index = self._shuffle_order[self.current_index]
|
|
55
|
+
return self.items[actual_index]
|
|
56
|
+
return self.items[self.current_index]
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_empty(self) -> bool:
|
|
61
|
+
"""Check if queue is empty."""
|
|
62
|
+
return len(self.items) == 0
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def length(self) -> int:
|
|
66
|
+
"""Get queue length."""
|
|
67
|
+
return len(self.items)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def has_next(self) -> bool:
|
|
71
|
+
"""Check if there's a next item."""
|
|
72
|
+
if self.repeat_mode == "all":
|
|
73
|
+
return len(self.items) > 0
|
|
74
|
+
if self.repeat_mode == "one":
|
|
75
|
+
return self.current is not None
|
|
76
|
+
return self.current_index < len(self.items) - 1
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def has_previous(self) -> bool:
|
|
80
|
+
"""Check if there's a previous item."""
|
|
81
|
+
if self.repeat_mode in ("all", "one"):
|
|
82
|
+
return len(self.items) > 0
|
|
83
|
+
return self.current_index > 0
|
|
84
|
+
|
|
85
|
+
def add(self, item: QueueItem) -> int:
|
|
86
|
+
"""Add an item to the queue. Returns position."""
|
|
87
|
+
self.items.append(item)
|
|
88
|
+
if self.shuffle_mode:
|
|
89
|
+
self._shuffle_order.append(len(self.items) - 1)
|
|
90
|
+
return len(self.items) - 1
|
|
91
|
+
|
|
92
|
+
def add_search_result(self, result: SearchResult) -> int:
|
|
93
|
+
"""Add a search result to the queue."""
|
|
94
|
+
return self.add(QueueItem.from_search_result(result))
|
|
95
|
+
|
|
96
|
+
def add_multiple(self, items: list[QueueItem]) -> None:
|
|
97
|
+
"""Add multiple items to the queue."""
|
|
98
|
+
for item in items:
|
|
99
|
+
self.add(item)
|
|
100
|
+
|
|
101
|
+
def remove(self, index: int) -> Optional[QueueItem]:
|
|
102
|
+
"""Remove an item by index."""
|
|
103
|
+
if 0 <= index < len(self.items):
|
|
104
|
+
item = self.items.pop(index)
|
|
105
|
+
|
|
106
|
+
# Update shuffle order
|
|
107
|
+
if self.shuffle_mode:
|
|
108
|
+
self._shuffle_order = [i for i in self._shuffle_order if i != index]
|
|
109
|
+
self._shuffle_order = [i - 1 if i > index else i for i in self._shuffle_order]
|
|
110
|
+
|
|
111
|
+
# Update current index
|
|
112
|
+
if index < self.current_index:
|
|
113
|
+
self.current_index -= 1
|
|
114
|
+
elif index == self.current_index:
|
|
115
|
+
# Current item was removed
|
|
116
|
+
if self.current_index >= len(self.items):
|
|
117
|
+
self.current_index = len(self.items) - 1
|
|
118
|
+
|
|
119
|
+
return item
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def clear(self) -> None:
|
|
123
|
+
"""Clear the queue."""
|
|
124
|
+
self.items.clear()
|
|
125
|
+
self._shuffle_order.clear()
|
|
126
|
+
self.current_index = -1
|
|
127
|
+
|
|
128
|
+
def next(self) -> Optional[QueueItem]:
|
|
129
|
+
"""Move to and return the next item."""
|
|
130
|
+
if not self.items:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
if self.repeat_mode == "one":
|
|
134
|
+
return self.current
|
|
135
|
+
|
|
136
|
+
if self.shuffle_mode:
|
|
137
|
+
if self.current_index < len(self._shuffle_order) - 1:
|
|
138
|
+
self.current_index += 1
|
|
139
|
+
elif self.repeat_mode == "all":
|
|
140
|
+
self.current_index = 0
|
|
141
|
+
else:
|
|
142
|
+
return None
|
|
143
|
+
else:
|
|
144
|
+
if self.current_index < len(self.items) - 1:
|
|
145
|
+
self.current_index += 1
|
|
146
|
+
elif self.repeat_mode == "all":
|
|
147
|
+
self.current_index = 0
|
|
148
|
+
else:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return self.current
|
|
152
|
+
|
|
153
|
+
def previous(self) -> Optional[QueueItem]:
|
|
154
|
+
"""Move to and return the previous item."""
|
|
155
|
+
if not self.items:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
if self.repeat_mode == "one":
|
|
159
|
+
return self.current
|
|
160
|
+
|
|
161
|
+
if self.current_index > 0:
|
|
162
|
+
self.current_index -= 1
|
|
163
|
+
elif self.repeat_mode == "all":
|
|
164
|
+
self.current_index = len(self.items) - 1
|
|
165
|
+
else:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
return self.current
|
|
169
|
+
|
|
170
|
+
def jump_to(self, index: int) -> Optional[QueueItem]:
|
|
171
|
+
"""Jump to a specific index."""
|
|
172
|
+
if 0 <= index < len(self.items):
|
|
173
|
+
self.current_index = index
|
|
174
|
+
return self.current
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def shuffle(self) -> None:
|
|
178
|
+
"""Enable shuffle mode and create shuffle order."""
|
|
179
|
+
self.shuffle_mode = True
|
|
180
|
+
self._create_shuffle_order()
|
|
181
|
+
|
|
182
|
+
def unshuffle(self) -> None:
|
|
183
|
+
"""Disable shuffle mode."""
|
|
184
|
+
self.shuffle_mode = False
|
|
185
|
+
# Update current index to actual position
|
|
186
|
+
if self._shuffle_order and 0 <= self.current_index < len(self._shuffle_order):
|
|
187
|
+
self.current_index = self._shuffle_order[self.current_index]
|
|
188
|
+
self._shuffle_order.clear()
|
|
189
|
+
|
|
190
|
+
def toggle_shuffle(self) -> bool:
|
|
191
|
+
"""Toggle shuffle mode. Returns new state."""
|
|
192
|
+
if self.shuffle_mode:
|
|
193
|
+
self.unshuffle()
|
|
194
|
+
else:
|
|
195
|
+
self.shuffle()
|
|
196
|
+
return self.shuffle_mode
|
|
197
|
+
|
|
198
|
+
def _create_shuffle_order(self) -> None:
|
|
199
|
+
"""Create a new shuffle order."""
|
|
200
|
+
self._shuffle_order = list(range(len(self.items)))
|
|
201
|
+
random.shuffle(self._shuffle_order)
|
|
202
|
+
|
|
203
|
+
# Move current item to front if playing
|
|
204
|
+
if self.current_index >= 0:
|
|
205
|
+
current_actual = self.current_index
|
|
206
|
+
if current_actual in self._shuffle_order:
|
|
207
|
+
self._shuffle_order.remove(current_actual)
|
|
208
|
+
self._shuffle_order.insert(0, current_actual)
|
|
209
|
+
self.current_index = 0
|
|
210
|
+
|
|
211
|
+
def set_repeat(self, mode: str) -> None:
|
|
212
|
+
"""Set repeat mode: none, one, all."""
|
|
213
|
+
if mode in ("none", "one", "all"):
|
|
214
|
+
self.repeat_mode = mode
|
|
215
|
+
|
|
216
|
+
def cycle_repeat(self) -> str:
|
|
217
|
+
"""Cycle through repeat modes. Returns new mode."""
|
|
218
|
+
modes = ["none", "one", "all"]
|
|
219
|
+
current_idx = modes.index(self.repeat_mode)
|
|
220
|
+
self.repeat_mode = modes[(current_idx + 1) % len(modes)]
|
|
221
|
+
return self.repeat_mode
|
|
222
|
+
|
|
223
|
+
def move(self, from_index: int, to_index: int) -> bool:
|
|
224
|
+
"""Move an item from one position to another."""
|
|
225
|
+
if not (0 <= from_index < len(self.items) and 0 <= to_index < len(self.items)):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
item = self.items.pop(from_index)
|
|
229
|
+
self.items.insert(to_index, item)
|
|
230
|
+
|
|
231
|
+
# Update current index
|
|
232
|
+
if self.current_index == from_index:
|
|
233
|
+
self.current_index = to_index
|
|
234
|
+
elif from_index < self.current_index <= to_index:
|
|
235
|
+
self.current_index -= 1
|
|
236
|
+
elif to_index <= self.current_index < from_index:
|
|
237
|
+
self.current_index += 1
|
|
238
|
+
|
|
239
|
+
# Recreate shuffle order if needed
|
|
240
|
+
if self.shuffle_mode:
|
|
241
|
+
self._create_shuffle_order()
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def get_upcoming(self, count: int = 5) -> list[QueueItem]:
|
|
246
|
+
"""Get upcoming items in queue."""
|
|
247
|
+
result = []
|
|
248
|
+
start = self.current_index + 1
|
|
249
|
+
|
|
250
|
+
if self.shuffle_mode and self._shuffle_order:
|
|
251
|
+
for i in range(start, min(start + count, len(self._shuffle_order))):
|
|
252
|
+
actual_idx = self._shuffle_order[i]
|
|
253
|
+
result.append(self.items[actual_idx])
|
|
254
|
+
else:
|
|
255
|
+
for i in range(start, min(start + count, len(self.items))):
|
|
256
|
+
result.append(self.items[i])
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def to_list(self) -> list[QueueItem]:
|
|
261
|
+
"""Get all items in current play order."""
|
|
262
|
+
if self.shuffle_mode and self._shuffle_order:
|
|
263
|
+
return [self.items[i] for i in self._shuffle_order]
|
|
264
|
+
return list(self.items)
|