wrkmon 1.0.1__py3-none-any.whl → 1.2.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 +1 -1
- wrkmon/app.py +297 -3
- wrkmon/cli.py +100 -0
- wrkmon/core/media_keys.py +377 -0
- wrkmon/core/queue.py +55 -0
- wrkmon/core/youtube.py +102 -0
- wrkmon/data/database.py +120 -0
- wrkmon/data/migrations.py +26 -0
- wrkmon/ui/screens/help.py +80 -0
- wrkmon/ui/theme.py +332 -75
- wrkmon/ui/views/search.py +168 -12
- wrkmon/ui/widgets/header.py +31 -1
- wrkmon/ui/widgets/player_bar.py +40 -7
- wrkmon/ui/widgets/thumbnail.py +230 -0
- wrkmon/utils/ascii_art.py +408 -0
- wrkmon/utils/config.py +85 -4
- wrkmon/utils/updater.py +311 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/METADATA +170 -166
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/RECORD +23 -18
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/entry_points.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/top_level.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/WHEEL +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""ASCII art generator for YouTube thumbnails - Enhanced with colors and blocks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("wrkmon.ascii_art")
|
|
11
|
+
|
|
12
|
+
# Character sets for different styles
|
|
13
|
+
CHARS_BLOCKS = "█▓▒░ " # Block characters (best for images)
|
|
14
|
+
CHARS_BLOCKS_DETAILED = "████▓▓▒▒░░ " # More gradients
|
|
15
|
+
CHARS_ASCII = "@%#*+=-:. " # Classic ASCII
|
|
16
|
+
CHARS_BRAILLE = "⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣀⡀ " # Braille patterns
|
|
17
|
+
|
|
18
|
+
# Color palette for Rich markup (approximate colors)
|
|
19
|
+
COLOR_PALETTE = [
|
|
20
|
+
"#000000", "#1a1a1a", "#333333", "#4d4d4d", "#666666",
|
|
21
|
+
"#808080", "#999999", "#b3b3b3", "#cccccc", "#e6e6e6", "#ffffff"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Extended color palette for better color representation
|
|
25
|
+
EXTENDED_COLORS = {
|
|
26
|
+
(0, 0, 0): "black",
|
|
27
|
+
(255, 255, 255): "white",
|
|
28
|
+
(255, 0, 0): "red",
|
|
29
|
+
(0, 255, 0): "green",
|
|
30
|
+
(0, 0, 255): "blue",
|
|
31
|
+
(255, 255, 0): "yellow",
|
|
32
|
+
(255, 0, 255): "magenta",
|
|
33
|
+
(0, 255, 255): "cyan",
|
|
34
|
+
(128, 128, 128): "grey50",
|
|
35
|
+
(192, 192, 192): "grey74",
|
|
36
|
+
(128, 0, 0): "dark_red",
|
|
37
|
+
(0, 128, 0): "dark_green",
|
|
38
|
+
(0, 0, 128): "dark_blue",
|
|
39
|
+
(128, 128, 0): "olive",
|
|
40
|
+
(128, 0, 128): "purple",
|
|
41
|
+
(0, 128, 128): "teal",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_thumbnail_url(video_id: str, quality: str = "mqdefault") -> str:
|
|
46
|
+
"""
|
|
47
|
+
Get YouTube thumbnail URL for a video.
|
|
48
|
+
|
|
49
|
+
Quality options:
|
|
50
|
+
- default: 120x90
|
|
51
|
+
- mqdefault: 320x180
|
|
52
|
+
- hqdefault: 480x360
|
|
53
|
+
- sddefault: 640x480
|
|
54
|
+
- maxresdefault: 1280x720
|
|
55
|
+
"""
|
|
56
|
+
return f"https://img.youtube.com/vi/{video_id}/{quality}.jpg"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def download_thumbnail(video_id: str, quality: str = "mqdefault") -> Optional[bytes]:
|
|
60
|
+
"""Download thumbnail image from YouTube."""
|
|
61
|
+
url = get_thumbnail_url(video_id, quality)
|
|
62
|
+
try:
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
url,
|
|
65
|
+
headers={"User-Agent": "Mozilla/5.0"}
|
|
66
|
+
)
|
|
67
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
68
|
+
return response.read()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.debug(f"Failed to download thumbnail: {e}")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def download_thumbnail_async(video_id: str, quality: str = "mqdefault") -> Optional[bytes]:
|
|
75
|
+
"""Async version of download_thumbnail."""
|
|
76
|
+
return await asyncio.to_thread(download_thumbnail, video_id, quality)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_closest_color(r: int, g: int, b: int) -> str:
|
|
80
|
+
"""Find the closest named color for Rich markup."""
|
|
81
|
+
min_dist = float('inf')
|
|
82
|
+
closest = "white"
|
|
83
|
+
|
|
84
|
+
for (cr, cg, cb), name in EXTENDED_COLORS.items():
|
|
85
|
+
dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
|
|
86
|
+
if dist < min_dist:
|
|
87
|
+
min_dist = dist
|
|
88
|
+
closest = name
|
|
89
|
+
|
|
90
|
+
return closest
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rgb_to_hex(r: int, g: int, b: int) -> str:
|
|
94
|
+
"""Convert RGB to hex color."""
|
|
95
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def image_to_ascii_blocks(
|
|
99
|
+
image_data: bytes,
|
|
100
|
+
width: int = 40,
|
|
101
|
+
colored: bool = True,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Convert image to colored block ASCII art.
|
|
105
|
+
|
|
106
|
+
Uses half-block characters (▀▄) to achieve 2x vertical resolution
|
|
107
|
+
with foreground and background colors.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
from PIL import Image
|
|
111
|
+
except ImportError:
|
|
112
|
+
logger.warning("Pillow not installed")
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
img = Image.open(io.BytesIO(image_data))
|
|
117
|
+
|
|
118
|
+
# Calculate dimensions (2 pixels per character vertically)
|
|
119
|
+
aspect_ratio = img.height / img.width
|
|
120
|
+
char_height = int(width * aspect_ratio * 0.5) # Half because we use half-blocks
|
|
121
|
+
pixel_height = char_height * 2
|
|
122
|
+
|
|
123
|
+
# Resize
|
|
124
|
+
img = img.resize((width, pixel_height))
|
|
125
|
+
img = img.convert("RGB")
|
|
126
|
+
pixels = list(img.getdata())
|
|
127
|
+
|
|
128
|
+
lines = []
|
|
129
|
+
for y in range(0, pixel_height, 2):
|
|
130
|
+
line = ""
|
|
131
|
+
for x in range(width):
|
|
132
|
+
# Top pixel
|
|
133
|
+
top_idx = y * width + x
|
|
134
|
+
# Bottom pixel
|
|
135
|
+
bot_idx = (y + 1) * width + x if (y + 1) < pixel_height else top_idx
|
|
136
|
+
|
|
137
|
+
top_r, top_g, top_b = pixels[top_idx]
|
|
138
|
+
bot_r, bot_g, bot_b = pixels[bot_idx]
|
|
139
|
+
|
|
140
|
+
if colored:
|
|
141
|
+
# Use half-block with fg (top) and bg (bottom) colors
|
|
142
|
+
top_hex = rgb_to_hex(top_r, top_g, top_b)
|
|
143
|
+
bot_hex = rgb_to_hex(bot_r, bot_g, bot_b)
|
|
144
|
+
line += f"[{top_hex} on {bot_hex}]▀[/]"
|
|
145
|
+
else:
|
|
146
|
+
# Grayscale block
|
|
147
|
+
avg = (top_r + top_g + top_b + bot_r + bot_g + bot_b) // 6
|
|
148
|
+
char_idx = int(avg / 256 * len(CHARS_BLOCKS))
|
|
149
|
+
char_idx = min(char_idx, len(CHARS_BLOCKS) - 1)
|
|
150
|
+
line += CHARS_BLOCKS[char_idx]
|
|
151
|
+
|
|
152
|
+
lines.append(line)
|
|
153
|
+
|
|
154
|
+
return "\n".join(lines)
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.debug(f"Failed to convert image: {e}")
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def image_to_ascii_simple(
|
|
162
|
+
image_data: bytes,
|
|
163
|
+
width: int = 40,
|
|
164
|
+
chars: str = CHARS_BLOCKS,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Simple grayscale ASCII art conversion.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
from PIL import Image
|
|
171
|
+
except ImportError:
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
img = Image.open(io.BytesIO(image_data))
|
|
176
|
+
|
|
177
|
+
aspect_ratio = img.height / img.width
|
|
178
|
+
height = int(width * aspect_ratio * 0.5)
|
|
179
|
+
|
|
180
|
+
img = img.resize((width, height))
|
|
181
|
+
img = img.convert("L") # Grayscale
|
|
182
|
+
|
|
183
|
+
pixels = list(img.getdata())
|
|
184
|
+
|
|
185
|
+
lines = []
|
|
186
|
+
for i in range(0, len(pixels), width):
|
|
187
|
+
line = ""
|
|
188
|
+
for pixel in pixels[i:i + width]:
|
|
189
|
+
char_idx = int((255 - pixel) / 256 * len(chars))
|
|
190
|
+
char_idx = min(char_idx, len(chars) - 1)
|
|
191
|
+
line += chars[char_idx]
|
|
192
|
+
lines.append(line)
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.debug(f"Failed to convert image: {e}")
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def image_to_ascii_colored_simple(
|
|
202
|
+
image_data: bytes,
|
|
203
|
+
width: int = 40,
|
|
204
|
+
) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Colored ASCII using full blocks with single color per character.
|
|
207
|
+
Simpler than half-blocks but still colorful.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
from PIL import Image
|
|
211
|
+
except ImportError:
|
|
212
|
+
return ""
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
img = Image.open(io.BytesIO(image_data))
|
|
216
|
+
|
|
217
|
+
aspect_ratio = img.height / img.width
|
|
218
|
+
height = int(width * aspect_ratio * 0.5)
|
|
219
|
+
|
|
220
|
+
img = img.resize((width, height))
|
|
221
|
+
img = img.convert("RGB")
|
|
222
|
+
|
|
223
|
+
pixels = list(img.getdata())
|
|
224
|
+
|
|
225
|
+
lines = []
|
|
226
|
+
for i in range(0, len(pixels), width):
|
|
227
|
+
line = ""
|
|
228
|
+
for r, g, b in pixels[i:i + width]:
|
|
229
|
+
hex_color = rgb_to_hex(r, g, b)
|
|
230
|
+
line += f"[{hex_color}]█[/]"
|
|
231
|
+
lines.append(line)
|
|
232
|
+
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.debug(f"Failed to convert image: {e}")
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def image_to_braille(
|
|
241
|
+
image_data: bytes,
|
|
242
|
+
width: int = 60,
|
|
243
|
+
threshold: int = 128,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""
|
|
246
|
+
Convert image to braille pattern art.
|
|
247
|
+
Each braille character represents a 2x4 pixel grid.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
from PIL import Image
|
|
251
|
+
except ImportError:
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
img = Image.open(io.BytesIO(image_data))
|
|
256
|
+
|
|
257
|
+
# Braille is 2 dots wide, 4 dots tall per character
|
|
258
|
+
char_width = width
|
|
259
|
+
aspect_ratio = img.height / img.width
|
|
260
|
+
char_height = int(char_width * aspect_ratio * 0.5)
|
|
261
|
+
|
|
262
|
+
pixel_width = char_width * 2
|
|
263
|
+
pixel_height = char_height * 4
|
|
264
|
+
|
|
265
|
+
img = img.resize((pixel_width, pixel_height))
|
|
266
|
+
img = img.convert("L") # Grayscale
|
|
267
|
+
|
|
268
|
+
pixels = list(img.getdata())
|
|
269
|
+
|
|
270
|
+
def get_pixel(x, y):
|
|
271
|
+
if 0 <= x < pixel_width and 0 <= y < pixel_height:
|
|
272
|
+
return pixels[y * pixel_width + x] < threshold
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
# Braille dot positions
|
|
276
|
+
# 1 4
|
|
277
|
+
# 2 5
|
|
278
|
+
# 3 6
|
|
279
|
+
# 7 8
|
|
280
|
+
dot_map = [
|
|
281
|
+
(0, 0, 0x01), (1, 0, 0x08),
|
|
282
|
+
(0, 1, 0x02), (1, 1, 0x10),
|
|
283
|
+
(0, 2, 0x04), (1, 2, 0x20),
|
|
284
|
+
(0, 3, 0x40), (1, 3, 0x80),
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
lines = []
|
|
288
|
+
for cy in range(char_height):
|
|
289
|
+
line = ""
|
|
290
|
+
for cx in range(char_width):
|
|
291
|
+
px = cx * 2
|
|
292
|
+
py = cy * 4
|
|
293
|
+
|
|
294
|
+
code = 0x2800 # Braille base
|
|
295
|
+
for dx, dy, bit in dot_map:
|
|
296
|
+
if get_pixel(px + dx, py + dy):
|
|
297
|
+
code |= bit
|
|
298
|
+
|
|
299
|
+
line += chr(code)
|
|
300
|
+
lines.append(line)
|
|
301
|
+
|
|
302
|
+
return "\n".join(lines)
|
|
303
|
+
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug(f"Failed to convert image: {e}")
|
|
306
|
+
return ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def video_thumbnail_to_ascii(
|
|
310
|
+
video_id: str,
|
|
311
|
+
width: int = 40,
|
|
312
|
+
quality: str = "mqdefault",
|
|
313
|
+
style: str = "colored_blocks", # colored_blocks, colored_simple, blocks, braille
|
|
314
|
+
) -> str:
|
|
315
|
+
"""
|
|
316
|
+
Download YouTube thumbnail and convert to ASCII art.
|
|
317
|
+
|
|
318
|
+
Styles:
|
|
319
|
+
- colored_blocks: Half-block characters with full color (best quality)
|
|
320
|
+
- colored_simple: Full blocks with color
|
|
321
|
+
- blocks: Grayscale block characters
|
|
322
|
+
- braille: Braille dot patterns (high detail, monochrome)
|
|
323
|
+
"""
|
|
324
|
+
image_data = download_thumbnail(video_id, quality)
|
|
325
|
+
if not image_data:
|
|
326
|
+
return ""
|
|
327
|
+
|
|
328
|
+
if style == "colored_blocks":
|
|
329
|
+
return image_to_ascii_blocks(image_data, width=width, colored=True)
|
|
330
|
+
elif style == "colored_simple":
|
|
331
|
+
return image_to_ascii_colored_simple(image_data, width=width)
|
|
332
|
+
elif style == "braille":
|
|
333
|
+
return image_to_braille(image_data, width=width)
|
|
334
|
+
else: # blocks
|
|
335
|
+
return image_to_ascii_simple(image_data, width=width)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def video_thumbnail_to_ascii_async(
|
|
339
|
+
video_id: str,
|
|
340
|
+
width: int = 40,
|
|
341
|
+
quality: str = "mqdefault",
|
|
342
|
+
style: str = "colored_blocks",
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Async version of video_thumbnail_to_ascii."""
|
|
345
|
+
image_data = await download_thumbnail_async(video_id, quality)
|
|
346
|
+
if not image_data:
|
|
347
|
+
return ""
|
|
348
|
+
|
|
349
|
+
if style == "colored_blocks":
|
|
350
|
+
return await asyncio.to_thread(image_to_ascii_blocks, image_data, width, True)
|
|
351
|
+
elif style == "colored_simple":
|
|
352
|
+
return await asyncio.to_thread(image_to_ascii_colored_simple, image_data, width)
|
|
353
|
+
elif style == "braille":
|
|
354
|
+
return await asyncio.to_thread(image_to_braille, image_data, width)
|
|
355
|
+
else:
|
|
356
|
+
return await asyncio.to_thread(image_to_ascii_simple, image_data, width)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# Cache for thumbnails
|
|
360
|
+
_thumbnail_cache: dict[str, str] = {}
|
|
361
|
+
_cache_max_size = 50
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_cached_ascii(video_id: str) -> Optional[str]:
|
|
365
|
+
"""Get cached ASCII art for a video."""
|
|
366
|
+
return _thumbnail_cache.get(video_id)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cache_ascii(video_id: str, ascii_art: str) -> None:
|
|
370
|
+
"""Cache ASCII art for a video."""
|
|
371
|
+
global _thumbnail_cache
|
|
372
|
+
|
|
373
|
+
if len(_thumbnail_cache) >= _cache_max_size:
|
|
374
|
+
oldest = next(iter(_thumbnail_cache))
|
|
375
|
+
del _thumbnail_cache[oldest]
|
|
376
|
+
|
|
377
|
+
_thumbnail_cache[video_id] = ascii_art
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def clear_cache() -> None:
|
|
381
|
+
"""Clear the thumbnail cache."""
|
|
382
|
+
global _thumbnail_cache
|
|
383
|
+
_thumbnail_cache = {}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def get_or_fetch_ascii(
|
|
387
|
+
video_id: str,
|
|
388
|
+
width: int = 40,
|
|
389
|
+
quality: str = "mqdefault",
|
|
390
|
+
style: str = "colored_blocks",
|
|
391
|
+
) -> str:
|
|
392
|
+
"""Get ASCII art from cache or fetch it."""
|
|
393
|
+
cache_key = f"{video_id}_{style}_{width}"
|
|
394
|
+
cached = _thumbnail_cache.get(cache_key)
|
|
395
|
+
if cached:
|
|
396
|
+
return cached
|
|
397
|
+
|
|
398
|
+
ascii_art = await video_thumbnail_to_ascii_async(
|
|
399
|
+
video_id,
|
|
400
|
+
width=width,
|
|
401
|
+
quality=quality,
|
|
402
|
+
style=style,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if ascii_art:
|
|
406
|
+
_thumbnail_cache[cache_key] = ascii_art
|
|
407
|
+
|
|
408
|
+
return ascii_art
|
wrkmon/utils/config.py
CHANGED
|
@@ -22,7 +22,8 @@ class Config:
|
|
|
22
22
|
"general": {
|
|
23
23
|
"volume": 80,
|
|
24
24
|
"shuffle": False,
|
|
25
|
-
"
|
|
25
|
+
"repeat_mode": "none", # none, one, all
|
|
26
|
+
"show_trending_on_start": True,
|
|
26
27
|
},
|
|
27
28
|
"player": {
|
|
28
29
|
"mpv_path": "mpv",
|
|
@@ -34,8 +35,11 @@ class Config:
|
|
|
34
35
|
"max_entries": 1000,
|
|
35
36
|
},
|
|
36
37
|
"ui": {
|
|
37
|
-
"theme": "
|
|
38
|
+
"theme": "github_dark", # github_dark, matrix, dracula, nord
|
|
38
39
|
"show_fake_stats": True,
|
|
40
|
+
"show_thumbnails": True,
|
|
41
|
+
"thumbnail_style": "colored_blocks", # colored_blocks, colored_simple, braille, blocks
|
|
42
|
+
"thumbnail_width": 45,
|
|
39
43
|
},
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -70,7 +74,9 @@ class Config:
|
|
|
70
74
|
|
|
71
75
|
def _load(self) -> None:
|
|
72
76
|
"""Load configuration from file."""
|
|
73
|
-
|
|
77
|
+
# Deep copy default config
|
|
78
|
+
import copy
|
|
79
|
+
self._config = copy.deepcopy(self.DEFAULT_CONFIG)
|
|
74
80
|
|
|
75
81
|
if self._config_file.exists() and tomllib is not None:
|
|
76
82
|
try:
|
|
@@ -134,6 +140,7 @@ class Config:
|
|
|
134
140
|
"""Get cache file path."""
|
|
135
141
|
return self._data_dir / "cache.db"
|
|
136
142
|
|
|
143
|
+
# General settings
|
|
137
144
|
@property
|
|
138
145
|
def volume(self) -> int:
|
|
139
146
|
"""Get current volume setting."""
|
|
@@ -144,6 +151,38 @@ class Config:
|
|
|
144
151
|
"""Set volume."""
|
|
145
152
|
self.set("general", "volume", max(0, min(100, value)))
|
|
146
153
|
|
|
154
|
+
@property
|
|
155
|
+
def repeat_mode(self) -> str:
|
|
156
|
+
"""Get repeat mode (none, one, all)."""
|
|
157
|
+
return self.get("general", "repeat_mode", "none")
|
|
158
|
+
|
|
159
|
+
@repeat_mode.setter
|
|
160
|
+
def repeat_mode(self, value: str) -> None:
|
|
161
|
+
"""Set repeat mode."""
|
|
162
|
+
if value in ("none", "one", "all"):
|
|
163
|
+
self.set("general", "repeat_mode", value)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def shuffle(self) -> bool:
|
|
167
|
+
"""Get shuffle setting."""
|
|
168
|
+
return self.get("general", "shuffle", False)
|
|
169
|
+
|
|
170
|
+
@shuffle.setter
|
|
171
|
+
def shuffle(self, value: bool) -> None:
|
|
172
|
+
"""Set shuffle."""
|
|
173
|
+
self.set("general", "shuffle", value)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def show_trending_on_start(self) -> bool:
|
|
177
|
+
"""Whether to show trending videos on startup."""
|
|
178
|
+
return self.get("general", "show_trending_on_start", True)
|
|
179
|
+
|
|
180
|
+
@show_trending_on_start.setter
|
|
181
|
+
def show_trending_on_start(self, value: bool) -> None:
|
|
182
|
+
"""Set show trending on start."""
|
|
183
|
+
self.set("general", "show_trending_on_start", value)
|
|
184
|
+
|
|
185
|
+
# Player settings
|
|
147
186
|
@property
|
|
148
187
|
def mpv_path(self) -> str:
|
|
149
188
|
"""Get mpv executable path."""
|
|
@@ -151,14 +190,56 @@ class Config:
|
|
|
151
190
|
configured = self.get("player", "mpv_path", "mpv")
|
|
152
191
|
if configured != "mpv":
|
|
153
192
|
return configured
|
|
154
|
-
# Auto-detect mpv location
|
|
155
193
|
return get_mpv_path()
|
|
156
194
|
|
|
195
|
+
# Cache settings
|
|
157
196
|
@property
|
|
158
197
|
def url_ttl_hours(self) -> int:
|
|
159
198
|
"""Get URL cache TTL in hours."""
|
|
160
199
|
return self.get("cache", "url_ttl_hours", 6)
|
|
161
200
|
|
|
201
|
+
# UI settings
|
|
202
|
+
@property
|
|
203
|
+
def theme(self) -> str:
|
|
204
|
+
"""Get UI theme."""
|
|
205
|
+
return self.get("ui", "theme", "github_dark")
|
|
206
|
+
|
|
207
|
+
@theme.setter
|
|
208
|
+
def theme(self, value: str) -> None:
|
|
209
|
+
"""Set UI theme."""
|
|
210
|
+
self.set("ui", "theme", value)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def show_thumbnails(self) -> bool:
|
|
214
|
+
"""Whether to show thumbnails."""
|
|
215
|
+
return self.get("ui", "show_thumbnails", True)
|
|
216
|
+
|
|
217
|
+
@show_thumbnails.setter
|
|
218
|
+
def show_thumbnails(self, value: bool) -> None:
|
|
219
|
+
"""Set show thumbnails."""
|
|
220
|
+
self.set("ui", "show_thumbnails", value)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def thumbnail_style(self) -> str:
|
|
224
|
+
"""Get thumbnail rendering style."""
|
|
225
|
+
return self.get("ui", "thumbnail_style", "colored_blocks")
|
|
226
|
+
|
|
227
|
+
@thumbnail_style.setter
|
|
228
|
+
def thumbnail_style(self, value: str) -> None:
|
|
229
|
+
"""Set thumbnail style."""
|
|
230
|
+
if value in ("colored_blocks", "colored_simple", "braille", "blocks"):
|
|
231
|
+
self.set("ui", "thumbnail_style", value)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def thumbnail_width(self) -> int:
|
|
235
|
+
"""Get thumbnail width in characters."""
|
|
236
|
+
return self.get("ui", "thumbnail_width", 45)
|
|
237
|
+
|
|
238
|
+
@thumbnail_width.setter
|
|
239
|
+
def thumbnail_width(self, value: int) -> None:
|
|
240
|
+
"""Set thumbnail width."""
|
|
241
|
+
self.set("ui", "thumbnail_width", max(20, min(80, value)))
|
|
242
|
+
|
|
162
243
|
|
|
163
244
|
# Global config instance
|
|
164
245
|
_config: Config | None = None
|