git-cli-yt 1.1.9 → 2.0.0
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.
- package/PRIVACY_POLICY.md +10 -0
- package/README.md +49 -1
- package/app.py +1322 -0
- package/audio/__init__.py +0 -0
- package/audio/audio_player.py +566 -0
- package/bin/cli.js +105 -67
- package/main.py +8 -682
- package/mix/__init__.py +0 -0
- package/mix/mix_service.py +227 -0
- package/package.json +32 -29
- package/requirements.txt +10 -8
- package/utils/__init__.py +0 -0
- package/utils/helpers.py +37 -0
- package/youtube/__init__.py +0 -0
- package/youtube/auth.py +196 -0
- package/youtube/liked.py +121 -0
- package/youtube/playlist.py +139 -0
- package/youtube/search.py +362 -0
package/app.py
ADDED
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
"""Aplicação principal — interface Textual do player de música V2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.widgets import (
|
|
14
|
+
Button,
|
|
15
|
+
Footer,
|
|
16
|
+
Input,
|
|
17
|
+
Label,
|
|
18
|
+
ListItem,
|
|
19
|
+
ListView,
|
|
20
|
+
ProgressBar,
|
|
21
|
+
)
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
|
|
24
|
+
from audio.audio_player import AudioPlayer
|
|
25
|
+
from mix.mix_service import MixService
|
|
26
|
+
from utils.helpers import format_time
|
|
27
|
+
from youtube import auth, liked, playlist as youtube_playlist, search
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class YouTubePlayer(App):
|
|
31
|
+
CSS = """
|
|
32
|
+
Screen {
|
|
33
|
+
layout: vertical;
|
|
34
|
+
background: #0f0f0f;
|
|
35
|
+
color: #f1f1f1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#sidebar {
|
|
39
|
+
width: 20;
|
|
40
|
+
background: #121212;
|
|
41
|
+
padding: 1 0;
|
|
42
|
+
height: 1fr;
|
|
43
|
+
border-right: solid #272727;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#sidebar Label {
|
|
47
|
+
text-style: bold;
|
|
48
|
+
margin-bottom: 1;
|
|
49
|
+
padding-left: 1;
|
|
50
|
+
color: #ffffff;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.nav-btn {
|
|
54
|
+
width: 100%;
|
|
55
|
+
margin-bottom: 1;
|
|
56
|
+
text-align: left;
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: #aaaaaa;
|
|
59
|
+
border: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.nav-btn:hover {
|
|
63
|
+
background: #272727;
|
|
64
|
+
color: #ffffff;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.nav-btn:focus {
|
|
68
|
+
background: #212121;
|
|
69
|
+
color: #ff4e45;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#sidebar-account-info {
|
|
74
|
+
padding-left: 1;
|
|
75
|
+
color: #aaaaaa;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#user-playlists-sidebar {
|
|
79
|
+
width: 32;
|
|
80
|
+
height: 1fr;
|
|
81
|
+
background: #121212;
|
|
82
|
+
border-left: solid #272727;
|
|
83
|
+
padding: 1 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#user-playlists-title {
|
|
87
|
+
height: 2;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
text-style: bold;
|
|
90
|
+
color: #ffffff;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#user-playlists-list {
|
|
94
|
+
height: 1fr;
|
|
95
|
+
background: #121212;
|
|
96
|
+
scrollbar-size-vertical: 1;
|
|
97
|
+
scrollbar-color: #ff4e45 #181818;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#user-playlists-status {
|
|
101
|
+
height: 2;
|
|
102
|
+
padding: 0 1;
|
|
103
|
+
color: #aaaaaa;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#main {
|
|
107
|
+
height: 1fr;
|
|
108
|
+
background: #0f0f0f;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#content {
|
|
112
|
+
width: 1fr;
|
|
113
|
+
height: 1fr;
|
|
114
|
+
padding: 0;
|
|
115
|
+
margin: 0;
|
|
116
|
+
background: #0f0f0f;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#section-browser {
|
|
120
|
+
height: 1fr;
|
|
121
|
+
width: 100%;
|
|
122
|
+
padding: 0;
|
|
123
|
+
margin: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#search-row {
|
|
127
|
+
height: 3;
|
|
128
|
+
width: 100%;
|
|
129
|
+
margin: 0;
|
|
130
|
+
padding: 0;
|
|
131
|
+
background: #0f0f0f;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#search-input {
|
|
135
|
+
width: 100%;
|
|
136
|
+
height: 3;
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0 1;
|
|
139
|
+
background: #121212;
|
|
140
|
+
color: #f1f1f1;
|
|
141
|
+
border: tall #272727;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#search-input:focus {
|
|
145
|
+
border: tall #ff0000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#results-row {
|
|
149
|
+
height: 1fr;
|
|
150
|
+
width: 100%;
|
|
151
|
+
margin: 0;
|
|
152
|
+
padding: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#results-pane {
|
|
156
|
+
width: 1fr;
|
|
157
|
+
height: 100%;
|
|
158
|
+
border: solid #272727;
|
|
159
|
+
padding: 0;
|
|
160
|
+
margin: 0;
|
|
161
|
+
background: #0f0f0f;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#playlist-pane {
|
|
165
|
+
width: 1fr;
|
|
166
|
+
height: 100%;
|
|
167
|
+
border: solid #272727;
|
|
168
|
+
padding: 0;
|
|
169
|
+
margin: 0 0 0 1;
|
|
170
|
+
background: #0f0f0f;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#results-title, #playlist-title {
|
|
174
|
+
height: 1;
|
|
175
|
+
width: 100%;
|
|
176
|
+
padding: 0 1;
|
|
177
|
+
text-style: bold;
|
|
178
|
+
background: #181818;
|
|
179
|
+
color: #ffffff;
|
|
180
|
+
border-bottom: solid #ff0000;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#results-list {
|
|
184
|
+
height: 1fr;
|
|
185
|
+
background: #0f0f0f;
|
|
186
|
+
scrollbar-size-vertical: 1;
|
|
187
|
+
scrollbar-color: #ff0000 #181818;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#playlist-list {
|
|
191
|
+
height: 1fr;
|
|
192
|
+
background: #0f0f0f;
|
|
193
|
+
scrollbar-size-vertical: 1;
|
|
194
|
+
scrollbar-color: #ff0000 #181818;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ListItem {
|
|
198
|
+
padding: 0 1;
|
|
199
|
+
background: #0f0f0f;
|
|
200
|
+
color: #f1f1f1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
ListItem:hover {
|
|
204
|
+
background: #272727;
|
|
205
|
+
color: #ffffff;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ListItem.-selected {
|
|
209
|
+
background: #212121;
|
|
210
|
+
color: #ffffff;
|
|
211
|
+
text-style: bold;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#btn-load-more {
|
|
215
|
+
width: 100%;
|
|
216
|
+
margin-top: 0;
|
|
217
|
+
margin-bottom: 0;
|
|
218
|
+
height: 3;
|
|
219
|
+
background: #181818;
|
|
220
|
+
color: #aaaaaa;
|
|
221
|
+
border: solid #272727;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#btn-load-more:hover {
|
|
225
|
+
background: #272727;
|
|
226
|
+
color: #ff4e45;
|
|
227
|
+
border: solid #ff0000;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#player-panel {
|
|
231
|
+
height: 7;
|
|
232
|
+
min-height: 7;
|
|
233
|
+
max-height: 7;
|
|
234
|
+
background: #181818;
|
|
235
|
+
border-top: heavy #ff0000;
|
|
236
|
+
padding: 0 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#player-info-row {
|
|
240
|
+
height: 1;
|
|
241
|
+
width: 100%;
|
|
242
|
+
align: center middle;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#player-info-row Button {
|
|
246
|
+
min-width: 3;
|
|
247
|
+
height: 1;
|
|
248
|
+
border: none;
|
|
249
|
+
padding: 0 1;
|
|
250
|
+
margin: 0;
|
|
251
|
+
background: transparent;
|
|
252
|
+
color: #aaaaaa;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#player-info-row Button:hover {
|
|
256
|
+
color: #ffffff;
|
|
257
|
+
background: #272727;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#now-playing {
|
|
261
|
+
text-style: bold;
|
|
262
|
+
width: 1fr;
|
|
263
|
+
text-align: left;
|
|
264
|
+
color: #f1f1f1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#status {
|
|
268
|
+
width: 1fr;
|
|
269
|
+
color: #ff4e45;
|
|
270
|
+
text-align: center;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#vol-label {
|
|
274
|
+
width: 6;
|
|
275
|
+
text-align: center;
|
|
276
|
+
text-style: bold;
|
|
277
|
+
color: #aaaaaa;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#progress-row {
|
|
281
|
+
height: 1;
|
|
282
|
+
width: 100%;
|
|
283
|
+
align: center middle;
|
|
284
|
+
margin-top: 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#progress-row Label {
|
|
288
|
+
width: 7;
|
|
289
|
+
text-align: center;
|
|
290
|
+
color: #aaaaaa;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#progress-row ProgressBar {
|
|
294
|
+
width: 1fr;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
Bar > .bar--bar {
|
|
298
|
+
color: #ff0000;
|
|
299
|
+
background: #272727;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
Bar > .bar--complete {
|
|
303
|
+
color: #ff0000;
|
|
304
|
+
background: #272727;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#controls {
|
|
308
|
+
height: 3;
|
|
309
|
+
width: 100%;
|
|
310
|
+
align: center middle;
|
|
311
|
+
margin-top: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#controls Button {
|
|
315
|
+
margin: 0 1;
|
|
316
|
+
min-width: 8;
|
|
317
|
+
background: #272727;
|
|
318
|
+
color: #f1f1f1;
|
|
319
|
+
border: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#controls Button:hover {
|
|
323
|
+
background: #3f3f3f;
|
|
324
|
+
color: #ffffff;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#btn-toggle {
|
|
328
|
+
background: #ff0000;
|
|
329
|
+
color: #ffffff;
|
|
330
|
+
text-style: bold;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#btn-toggle:hover {
|
|
334
|
+
background: #cc0000;
|
|
335
|
+
color: #ffffff;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#btn-stop {
|
|
339
|
+
background: #212121;
|
|
340
|
+
color: #888888;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#btn-stop:hover {
|
|
344
|
+
background: #272727;
|
|
345
|
+
color: #ff4e45;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#login-pane {
|
|
349
|
+
width: 100%;
|
|
350
|
+
height: 100%;
|
|
351
|
+
align: center middle;
|
|
352
|
+
padding: 2;
|
|
353
|
+
background: #0f0f0f;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#login-pane Button {
|
|
357
|
+
margin: 1;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#btn-do-login {
|
|
361
|
+
background: #ff0000;
|
|
362
|
+
color: #ffffff;
|
|
363
|
+
text-style: bold;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#config-pane {
|
|
367
|
+
width: 100%;
|
|
368
|
+
height: 100%;
|
|
369
|
+
align: center middle;
|
|
370
|
+
padding: 2;
|
|
371
|
+
background: #0f0f0f;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
Footer {
|
|
375
|
+
background: #0f0f0f;
|
|
376
|
+
color: #aaaaaa;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
Footer > .footer--key {
|
|
380
|
+
background: #272727;
|
|
381
|
+
color: #ff0000;
|
|
382
|
+
text-style: bold;
|
|
383
|
+
}
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
BINDINGS = [
|
|
387
|
+
Binding("space", "toggle_play", "Play/Pause", show=True),
|
|
388
|
+
Binding("up", "volume_up", "Volume +", show=True),
|
|
389
|
+
Binding("down", "volume_down", "Volume -", show=True),
|
|
390
|
+
Binding("q", "quit", "Sair", show=True),
|
|
391
|
+
Binding("n", "next_track", "Próxima", show=True),
|
|
392
|
+
Binding("p", "prev_track", "Anterior", show=True),
|
|
393
|
+
Binding("f", "seek_forward", "+10s", show=True),
|
|
394
|
+
Binding("b", "seek_back", "-10s", show=True),
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
def __init__(self) -> None:
|
|
398
|
+
super().__init__()
|
|
399
|
+
self.player = AudioPlayer()
|
|
400
|
+
self.mix = MixService(self._fetch_for_mix)
|
|
401
|
+
|
|
402
|
+
self._liked_next_page: Optional[str] = None
|
|
403
|
+
self._search_next_page: Optional[str] = None
|
|
404
|
+
self._search_query = ""
|
|
405
|
+
self._current_section = "inicio"
|
|
406
|
+
self._last_results: list[dict[str, Any]] = []
|
|
407
|
+
self._active_source = "search"
|
|
408
|
+
self._search_generation = 0
|
|
409
|
+
self._liked_generation = 0
|
|
410
|
+
self._mix_generation = 0
|
|
411
|
+
self._mix_lock = threading.Lock()
|
|
412
|
+
self._last_selection_key: Optional[str] = None
|
|
413
|
+
self._last_selection_at = 0.0
|
|
414
|
+
self._user_playlists: list[dict[str, Any]] = []
|
|
415
|
+
self._playlists_loaded = False
|
|
416
|
+
self._playlist_generation = 0
|
|
417
|
+
self._playlist_next_page: Optional[str] = None
|
|
418
|
+
self._active_playlist_url = ""
|
|
419
|
+
self._active_playlist_title = ""
|
|
420
|
+
|
|
421
|
+
# Callbacks do player para sincronização
|
|
422
|
+
self.player.on_state_change = self._on_player_state
|
|
423
|
+
self.player.on_progress = self._on_player_progress
|
|
424
|
+
self.player.on_finished = self._on_track_finished
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------------ #
|
|
427
|
+
# Interface Visual
|
|
428
|
+
# ------------------------------------------------------------------ #
|
|
429
|
+
|
|
430
|
+
def compose(self) -> ComposeResult:
|
|
431
|
+
|
|
432
|
+
with Horizontal(id="main"):
|
|
433
|
+
# Barra lateral de navegação (exatamente as 5 seções pedidas)
|
|
434
|
+
with Vertical(id="sidebar"):
|
|
435
|
+
yield Label("[bold]Menu Principal[/bold]")
|
|
436
|
+
yield Button("🏠 Início", id="nav-inicio", classes="nav-btn", variant="primary")
|
|
437
|
+
yield Button("📂 Playlists", id="nav-playlists", classes="nav-btn")
|
|
438
|
+
yield Button("🔑 Login", id="nav-login", classes="nav-btn")
|
|
439
|
+
yield Button("⚙️ Configurações", id="nav-config", classes="nav-btn")
|
|
440
|
+
yield Button("🚪 Sair", id="nav-sair", classes="nav-btn", variant="error")
|
|
441
|
+
yield Label("", id="sidebar-account-info")
|
|
442
|
+
|
|
443
|
+
# Área de conteúdo central
|
|
444
|
+
with Vertical(id="content"):
|
|
445
|
+
# Seção INÍCIO / CURTIDAS (área com busca, resultados e playlist lado a lado)
|
|
446
|
+
with Vertical(id="section-browser"):
|
|
447
|
+
with Horizontal(id="search-row"):
|
|
448
|
+
yield Input(
|
|
449
|
+
placeholder="🔍 Digite uma música ou artista e pressione Enter...",
|
|
450
|
+
id="search-input",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
with Horizontal(id="results-row"):
|
|
454
|
+
with Vertical(id="results-pane"):
|
|
455
|
+
yield Label("[bold]Resultados da Busca[/bold]", id="results-title")
|
|
456
|
+
yield ListView(id="results-list")
|
|
457
|
+
yield Button("➕ Carregar Mais", id="btn-load-more", variant="default")
|
|
458
|
+
|
|
459
|
+
with Vertical(id="playlist-pane"):
|
|
460
|
+
yield Label("[bold]Fila / Mix Automática[/bold]", id="playlist-title")
|
|
461
|
+
yield ListView(id="playlist-list")
|
|
462
|
+
|
|
463
|
+
# Seção LOGIN (oculta por padrão)
|
|
464
|
+
with Vertical(id="section-login", classes="hidden"):
|
|
465
|
+
with Vertical(id="login-pane"):
|
|
466
|
+
yield Label("[bold cyan]Gerenciamento de Conta YouTube / Google[/bold cyan]", id="login-title")
|
|
467
|
+
yield Label("Verificando credenciais...", id="login-account-label")
|
|
468
|
+
yield Button("🔑 Fazer Login com Google", id="btn-do-login", variant="primary")
|
|
469
|
+
yield Button("🔄 Trocar de Conta", id="btn-switch-account", variant="warning")
|
|
470
|
+
yield Button("🚪 Sair da Conta", id="btn-logout", variant="error")
|
|
471
|
+
|
|
472
|
+
# Seção CONFIGURAÇÕES (oculta por padrão)
|
|
473
|
+
with Vertical(id="section-config", classes="hidden"):
|
|
474
|
+
with Vertical(id="config-pane"):
|
|
475
|
+
yield Label("[bold]Configurações[/bold]")
|
|
476
|
+
yield Label("Em construção...")
|
|
477
|
+
|
|
478
|
+
# Player persistente no rodapé compacto
|
|
479
|
+
with Vertical(id="player-panel"):
|
|
480
|
+
with Horizontal(id="player-info-row"):
|
|
481
|
+
yield Label("Nenhuma música tocando", id="now-playing")
|
|
482
|
+
yield Label("Status: Pronto.", id="status")
|
|
483
|
+
yield Button("🔉", id="btn-vol-down")
|
|
484
|
+
yield Label("80%", id="vol-label")
|
|
485
|
+
yield Button("🔊", id="btn-vol-up")
|
|
486
|
+
|
|
487
|
+
with Horizontal(id="progress-row"):
|
|
488
|
+
yield Label("00:00", id="time-current")
|
|
489
|
+
yield ProgressBar(
|
|
490
|
+
id="song-progress",
|
|
491
|
+
total=100,
|
|
492
|
+
show_percentage=False,
|
|
493
|
+
)
|
|
494
|
+
yield Label("00:00", id="time-total")
|
|
495
|
+
|
|
496
|
+
with Horizontal(id="controls"):
|
|
497
|
+
yield Button("⏮", id="btn-prev")
|
|
498
|
+
yield Button("⏪ 10s", id="btn-back")
|
|
499
|
+
yield Button("▶ / ⏸", id="btn-toggle", variant="primary")
|
|
500
|
+
yield Button("10s ⏩", id="btn-forward")
|
|
501
|
+
yield Button("⏭", id="btn-next")
|
|
502
|
+
yield Button("⏹", id="btn-stop", variant="error")
|
|
503
|
+
with Vertical(id="user-playlists-sidebar"):
|
|
504
|
+
yield Label("[bold]Minhas Playlists[/bold]", id="user-playlists-title")
|
|
505
|
+
yield ListView(id="user-playlists-list")
|
|
506
|
+
yield Label("", id="user-playlists-status")
|
|
507
|
+
|
|
508
|
+
yield Footer()
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------ #
|
|
511
|
+
# Inicialização e Monitoramento
|
|
512
|
+
# ------------------------------------------------------------------ #
|
|
513
|
+
|
|
514
|
+
def on_mount(self) -> None:
|
|
515
|
+
self._show_section("inicio")
|
|
516
|
+
self.query_one("#user-playlists-sidebar").display = False
|
|
517
|
+
self._check_account_async()
|
|
518
|
+
self.query_one("#search-input", Input).focus()
|
|
519
|
+
|
|
520
|
+
def _show_section(self, section: str) -> None:
|
|
521
|
+
"""Alterna a visibilidade dos containers conforme a seção selecionada."""
|
|
522
|
+
self._current_section = section
|
|
523
|
+
self.query_one("#section-browser").display = False
|
|
524
|
+
self.query_one("#section-login").display = False
|
|
525
|
+
self.query_one("#section-config").display = False
|
|
526
|
+
|
|
527
|
+
if section in ("inicio", "curtidas"):
|
|
528
|
+
self.query_one("#section-browser").display = True
|
|
529
|
+
search_row = self.query_one("#search-row")
|
|
530
|
+
if section == "inicio":
|
|
531
|
+
search_row.display = True
|
|
532
|
+
self.query_one("#results-title", Label).update(
|
|
533
|
+
"[bold]Músicas Encontradas[/bold]"
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
search_row.display = False
|
|
537
|
+
self.query_one("#results-title", Label).update(
|
|
538
|
+
"[bold]Músicas Curtidas[/bold]"
|
|
539
|
+
)
|
|
540
|
+
elif section == "login":
|
|
541
|
+
self.query_one("#section-login").display = True
|
|
542
|
+
self._update_login_view()
|
|
543
|
+
elif section == "config":
|
|
544
|
+
self.query_one("#section-config").display = True
|
|
545
|
+
|
|
546
|
+
def _check_account_async(self) -> None:
|
|
547
|
+
"""Verifica a conta conectada sem bloquear a interface."""
|
|
548
|
+
|
|
549
|
+
def worker() -> None:
|
|
550
|
+
logged = auth.is_logged_in()
|
|
551
|
+
name = auth.get_account_name() if logged else None
|
|
552
|
+
self.call_from_thread(self._update_sidebar_account, logged, name)
|
|
553
|
+
|
|
554
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
555
|
+
|
|
556
|
+
def _update_sidebar_account(self, logged: bool, name: Optional[str]) -> None:
|
|
557
|
+
info_label = self.query_one("#sidebar-account-info", Label)
|
|
558
|
+
if logged:
|
|
559
|
+
info_label.update(f"[green]● {name or 'Conectado'}[/green]")
|
|
560
|
+
else:
|
|
561
|
+
info_label.update("[yellow]○ Não conectado[/yellow]")
|
|
562
|
+
|
|
563
|
+
def _toggle_user_playlists(self) -> None:
|
|
564
|
+
sidebar = self.query_one("#user-playlists-sidebar")
|
|
565
|
+
sidebar.display = not sidebar.display
|
|
566
|
+
if sidebar.display and not self._playlists_loaded:
|
|
567
|
+
self._load_user_playlists()
|
|
568
|
+
|
|
569
|
+
def _load_user_playlists(self) -> None:
|
|
570
|
+
self._playlist_generation += 1
|
|
571
|
+
generation = self._playlist_generation
|
|
572
|
+
status = self.query_one("#user-playlists-status", Label)
|
|
573
|
+
status.update("[yellow]Carregando playlists...[/yellow]")
|
|
574
|
+
self.query_one("#user-playlists-list", ListView).clear()
|
|
575
|
+
|
|
576
|
+
def worker() -> None:
|
|
577
|
+
try:
|
|
578
|
+
if not auth.is_logged_in():
|
|
579
|
+
self.call_from_thread(
|
|
580
|
+
self._show_user_playlists_error,
|
|
581
|
+
generation,
|
|
582
|
+
"Faça login para ver suas playlists.",
|
|
583
|
+
)
|
|
584
|
+
return
|
|
585
|
+
channel = auth.get_channel_info()
|
|
586
|
+
if not channel:
|
|
587
|
+
self.call_from_thread(
|
|
588
|
+
self._show_user_playlists_error,
|
|
589
|
+
generation,
|
|
590
|
+
"Não foi possível identificar o canal.",
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
items = youtube_playlist.fetch_user_playlists(
|
|
594
|
+
channel["id"],
|
|
595
|
+
cookies_file=os.environ.get("YTDLP_COOKIES_FILE"),
|
|
596
|
+
)
|
|
597
|
+
self.call_from_thread(self._show_user_playlists, generation, items)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
self.call_from_thread(
|
|
600
|
+
self._show_user_playlists_error, generation, str(exc)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
604
|
+
|
|
605
|
+
def _show_user_playlists(
|
|
606
|
+
self, generation: int, items: list[dict[str, Any]]
|
|
607
|
+
) -> None:
|
|
608
|
+
if generation != self._playlist_generation:
|
|
609
|
+
return
|
|
610
|
+
self._user_playlists = items
|
|
611
|
+
self._playlists_loaded = True
|
|
612
|
+
playlist_list = self.query_one("#user-playlists-list", ListView)
|
|
613
|
+
playlist_list.clear()
|
|
614
|
+
for item in items:
|
|
615
|
+
title = escape(str(item.get("title", "Playlist")))
|
|
616
|
+
count = item.get("count")
|
|
617
|
+
suffix = f" ({count})" if count else ""
|
|
618
|
+
icon = "★" if item.get("kind") == "liked" else "♫"
|
|
619
|
+
playlist_list.append(ListItem(Label(f"{icon} {title}{suffix}")))
|
|
620
|
+
self.query_one("#user-playlists-status", Label).update(
|
|
621
|
+
f"[green]{len(items)} playlists[/green]"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
def _show_user_playlists_error(self, generation: int, message: str) -> None:
|
|
625
|
+
if generation == self._playlist_generation:
|
|
626
|
+
self.query_one("#user-playlists-status", Label).update(
|
|
627
|
+
f"[red]{escape(message)}[/red]"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def _open_user_playlist(self, item: dict[str, Any]) -> None:
|
|
631
|
+
if item.get("kind") == "liked":
|
|
632
|
+
self._show_section("curtidas")
|
|
633
|
+
self._load_liked()
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
url = str(item.get("url", ""))
|
|
637
|
+
if not url:
|
|
638
|
+
return
|
|
639
|
+
self._active_source = "playlist"
|
|
640
|
+
self._active_playlist_url = url
|
|
641
|
+
self._active_playlist_title = str(item.get("title", "Playlist"))
|
|
642
|
+
self._playlist_generation += 1
|
|
643
|
+
generation = self._playlist_generation
|
|
644
|
+
self._show_playlist_tracks(self._active_playlist_title)
|
|
645
|
+
self._playlist_next_page = None
|
|
646
|
+
self._last_results = []
|
|
647
|
+
self.query_one("#results-list", ListView).clear()
|
|
648
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
649
|
+
|
|
650
|
+
def worker() -> None:
|
|
651
|
+
try:
|
|
652
|
+
tracks, next_page = youtube_playlist.fetch_playlist(url, count=50)
|
|
653
|
+
self.call_from_thread(
|
|
654
|
+
self._apply_playlist_results, generation, tracks, next_page, False
|
|
655
|
+
)
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
self.call_from_thread(self._show_playlist_error, generation, str(exc))
|
|
658
|
+
|
|
659
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
660
|
+
|
|
661
|
+
def _show_playlist_tracks(self, title: str) -> None:
|
|
662
|
+
self._current_section = "inicio"
|
|
663
|
+
self.query_one("#section-browser").display = True
|
|
664
|
+
self.query_one("#search-row").display = False
|
|
665
|
+
self.query_one("#results-title", Label).update(
|
|
666
|
+
f"[bold]{escape(title)}[/bold]"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _apply_playlist_results(
|
|
670
|
+
self,
|
|
671
|
+
generation: int,
|
|
672
|
+
tracks: list[dict[str, Any]],
|
|
673
|
+
next_page: Optional[str],
|
|
674
|
+
append: bool,
|
|
675
|
+
) -> None:
|
|
676
|
+
if generation != self._playlist_generation:
|
|
677
|
+
return
|
|
678
|
+
if append:
|
|
679
|
+
self._append_results(tracks)
|
|
680
|
+
else:
|
|
681
|
+
self._show_results(tracks)
|
|
682
|
+
self._playlist_next_page = next_page
|
|
683
|
+
self.query_one("#btn-load-more", Button).disabled = not bool(next_page)
|
|
684
|
+
self.query_one("#status", Label).update(
|
|
685
|
+
f"[green]+{len(tracks)} músicas da playlist.[/green]"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def _load_more_playlist(self) -> None:
|
|
689
|
+
if not self._active_playlist_url or not self._playlist_next_page:
|
|
690
|
+
self.query_one("#status", Label).update("Fim da playlist.")
|
|
691
|
+
return
|
|
692
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
693
|
+
generation = self._playlist_generation
|
|
694
|
+
|
|
695
|
+
def worker() -> None:
|
|
696
|
+
try:
|
|
697
|
+
tracks, next_page = youtube_playlist.fetch_playlist(
|
|
698
|
+
self._active_playlist_url,
|
|
699
|
+
count=50,
|
|
700
|
+
page_token=self._playlist_next_page,
|
|
701
|
+
)
|
|
702
|
+
self.call_from_thread(
|
|
703
|
+
self._apply_playlist_results, generation, tracks, next_page, True
|
|
704
|
+
)
|
|
705
|
+
except Exception as exc:
|
|
706
|
+
self.call_from_thread(self._show_playlist_error, generation, str(exc))
|
|
707
|
+
|
|
708
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
709
|
+
|
|
710
|
+
def _show_playlist_error(self, generation: int, message: str) -> None:
|
|
711
|
+
if generation == self._playlist_generation:
|
|
712
|
+
self.query_one("#status", Label).update(
|
|
713
|
+
f"[red]Não foi possível carregar a playlist: {escape(message)}[/red]"
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# ------------------------------------------------------------------ #
|
|
717
|
+
# Eventos de Teclado e Input
|
|
718
|
+
# ------------------------------------------------------------------ #
|
|
719
|
+
|
|
720
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
721
|
+
if event.input.id != "search-input":
|
|
722
|
+
return
|
|
723
|
+
query = event.value.strip()
|
|
724
|
+
if query:
|
|
725
|
+
self._do_search(query)
|
|
726
|
+
|
|
727
|
+
# ------------------------------------------------------------------ #
|
|
728
|
+
# Navegação e Botões
|
|
729
|
+
# ------------------------------------------------------------------ #
|
|
730
|
+
|
|
731
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
732
|
+
bid = event.button.id
|
|
733
|
+
|
|
734
|
+
if bid == "nav-inicio":
|
|
735
|
+
self._show_section("inicio")
|
|
736
|
+
self.query_one("#search-input", Input).focus()
|
|
737
|
+
elif bid == "nav-playlists":
|
|
738
|
+
self._toggle_user_playlists()
|
|
739
|
+
elif bid == "nav-login":
|
|
740
|
+
self._show_section("login")
|
|
741
|
+
elif bid == "nav-config":
|
|
742
|
+
self._show_section("config")
|
|
743
|
+
elif bid == "nav-sair":
|
|
744
|
+
self.player.stop()
|
|
745
|
+
self.exit()
|
|
746
|
+
elif bid == "btn-do-login":
|
|
747
|
+
self._execute_login(switch=False)
|
|
748
|
+
elif bid == "btn-switch-account":
|
|
749
|
+
self._reset_user_playlists()
|
|
750
|
+
self._execute_login(switch=True)
|
|
751
|
+
elif bid == "btn-logout":
|
|
752
|
+
auth.clear_session()
|
|
753
|
+
self._reset_user_playlists()
|
|
754
|
+
self._update_login_view()
|
|
755
|
+
self._check_account_async()
|
|
756
|
+
self.query_one("#status", Label).update("[yellow]Desconectado da conta.[/yellow]")
|
|
757
|
+
elif bid == "btn-toggle":
|
|
758
|
+
self.action_toggle_play()
|
|
759
|
+
elif bid == "btn-stop":
|
|
760
|
+
self.player.stop()
|
|
761
|
+
self.query_one("#status", Label).update("Reprodução parada.")
|
|
762
|
+
self.query_one("#now-playing", Label).update("Nenhuma música tocando no momento")
|
|
763
|
+
elif bid == "btn-forward":
|
|
764
|
+
self.action_seek_forward()
|
|
765
|
+
elif bid == "btn-back":
|
|
766
|
+
self.action_seek_back()
|
|
767
|
+
elif bid == "btn-vol-up":
|
|
768
|
+
self.action_volume_up()
|
|
769
|
+
elif bid == "btn-vol-down":
|
|
770
|
+
self.action_volume_down()
|
|
771
|
+
elif bid == "btn-next":
|
|
772
|
+
self.action_next_track()
|
|
773
|
+
elif bid == "btn-prev":
|
|
774
|
+
self.action_prev_track()
|
|
775
|
+
elif bid == "btn-load-more":
|
|
776
|
+
if self._active_source == "search":
|
|
777
|
+
self._load_more_search()
|
|
778
|
+
elif self._active_source == "liked":
|
|
779
|
+
self._load_more_liked()
|
|
780
|
+
else:
|
|
781
|
+
self._load_more_playlist()
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _reset_user_playlists(self) -> None:
|
|
785
|
+
self._playlist_generation += 1
|
|
786
|
+
self._playlists_loaded = False
|
|
787
|
+
self._user_playlists = []
|
|
788
|
+
sidebar = self.query_one("#user-playlists-sidebar")
|
|
789
|
+
sidebar.display = False
|
|
790
|
+
sidebar.query_one("#user-playlists-list", ListView).clear()
|
|
791
|
+
sidebar.query_one("#user-playlists-status", Label).update("")
|
|
792
|
+
# ------------------------------------------------------------------ #
|
|
793
|
+
# Lógica de Login
|
|
794
|
+
# ------------------------------------------------------------------ #
|
|
795
|
+
|
|
796
|
+
def _update_login_view(self) -> None:
|
|
797
|
+
account_label = self.query_one("#login-account-label", Label)
|
|
798
|
+
btn_login = self.query_one("#btn-do-login", Button)
|
|
799
|
+
btn_switch = self.query_one("#btn-switch-account", Button)
|
|
800
|
+
btn_logout = self.query_one("#btn-logout", Button)
|
|
801
|
+
|
|
802
|
+
logged = auth.is_logged_in()
|
|
803
|
+
if logged:
|
|
804
|
+
name = auth.get_account_name() or "Conta do YouTube"
|
|
805
|
+
account_label.update(f"[green]Conta conectada:[/green] [bold]{name}[/bold]")
|
|
806
|
+
btn_login.display = False
|
|
807
|
+
btn_switch.display = True
|
|
808
|
+
btn_logout.display = True
|
|
809
|
+
else:
|
|
810
|
+
account_label.update("[yellow]Nenhuma conta conectada no momento.[/yellow]")
|
|
811
|
+
btn_login.display = True
|
|
812
|
+
btn_switch.display = False
|
|
813
|
+
btn_logout.display = False
|
|
814
|
+
|
|
815
|
+
def _execute_login(self, switch: bool = False) -> None:
|
|
816
|
+
status = self.query_one("#status", Label)
|
|
817
|
+
status.update("[yellow]Abra o navegador para autorizar acesso ao YouTube...[/yellow]")
|
|
818
|
+
|
|
819
|
+
def worker() -> None:
|
|
820
|
+
try:
|
|
821
|
+
service = auth.switch_account() if switch else auth.get_youtube_service(interactive=True)
|
|
822
|
+
message = (
|
|
823
|
+
"[green]Login realizado com sucesso![/green]"
|
|
824
|
+
if service
|
|
825
|
+
else "[red]Não foi possível obter credenciais válidas.[/red]"
|
|
826
|
+
)
|
|
827
|
+
except Exception as exc:
|
|
828
|
+
message = f"[red]Erro no login: {exc}[/red]"
|
|
829
|
+
self.call_from_thread(self._finish_login, message)
|
|
830
|
+
|
|
831
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
832
|
+
|
|
833
|
+
def _finish_login(self, message: str) -> None:
|
|
834
|
+
self._reset_user_playlists()
|
|
835
|
+
self.query_one("#status", Label).update(message)
|
|
836
|
+
self._update_login_view()
|
|
837
|
+
self._check_account_async()
|
|
838
|
+
|
|
839
|
+
# ------------------------------------------------------------------ #
|
|
840
|
+
# Lógica de Busca (YouTube Data API v3)
|
|
841
|
+
# ------------------------------------------------------------------ #
|
|
842
|
+
|
|
843
|
+
def _do_search(self, query: str) -> None:
|
|
844
|
+
query = query.strip()
|
|
845
|
+
if not query:
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
self._search_generation += 1
|
|
849
|
+
self._liked_generation += 1
|
|
850
|
+
self._playlist_generation += 1
|
|
851
|
+
generation = self._search_generation
|
|
852
|
+
self._search_query = query
|
|
853
|
+
self._search_next_page = None
|
|
854
|
+
self._active_source = "search"
|
|
855
|
+
self.query_one("#status", Label).update(
|
|
856
|
+
f"[yellow]Buscando músicas para '{escape(query)}'...[/yellow]"
|
|
857
|
+
)
|
|
858
|
+
results_list = self.query_one("#results-list", ListView)
|
|
859
|
+
results_list.clear()
|
|
860
|
+
self._last_results = []
|
|
861
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
862
|
+
|
|
863
|
+
def worker() -> None:
|
|
864
|
+
try:
|
|
865
|
+
results, next_page = search.fetch_page(query, count=15)
|
|
866
|
+
self.call_from_thread(
|
|
867
|
+
self._apply_search_results, generation, results, next_page, False
|
|
868
|
+
)
|
|
869
|
+
except Exception as exc:
|
|
870
|
+
self.call_from_thread(
|
|
871
|
+
self._show_search_error, generation, str(exc)
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
875
|
+
|
|
876
|
+
def _load_more_search(self) -> None:
|
|
877
|
+
if not self._search_query:
|
|
878
|
+
self.query_one("#status", Label).update(
|
|
879
|
+
"[yellow]Nenhuma pesquisa ativa. Faça uma nova busca.[/yellow]"
|
|
880
|
+
)
|
|
881
|
+
return
|
|
882
|
+
if not self._search_next_page:
|
|
883
|
+
self.query_one("#status", Label).update("Fim dos resultados da busca.")
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
887
|
+
generation = self._search_generation
|
|
888
|
+
query = self._search_query
|
|
889
|
+
page_token = self._search_next_page
|
|
890
|
+
|
|
891
|
+
def worker() -> None:
|
|
892
|
+
try:
|
|
893
|
+
results, next_page = search.fetch_page(
|
|
894
|
+
query, count=15, page_token=page_token
|
|
895
|
+
)
|
|
896
|
+
self.call_from_thread(
|
|
897
|
+
self._apply_search_results, generation, results, next_page, True
|
|
898
|
+
)
|
|
899
|
+
except Exception as exc:
|
|
900
|
+
self.call_from_thread(
|
|
901
|
+
self._show_search_error, generation, str(exc)
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
905
|
+
|
|
906
|
+
def _apply_search_results(
|
|
907
|
+
self,
|
|
908
|
+
generation: int,
|
|
909
|
+
results: list[dict[str, Any]],
|
|
910
|
+
next_page: Optional[str],
|
|
911
|
+
append: bool,
|
|
912
|
+
) -> None:
|
|
913
|
+
if generation != self._search_generation:
|
|
914
|
+
return
|
|
915
|
+
if append:
|
|
916
|
+
self._append_results(results)
|
|
917
|
+
else:
|
|
918
|
+
self._show_results(results)
|
|
919
|
+
self._search_next_page = next_page
|
|
920
|
+
self.query_one("#btn-load-more", Button).disabled = not bool(next_page)
|
|
921
|
+
if results:
|
|
922
|
+
suffix = "adicionados" if append else "encontrados"
|
|
923
|
+
self.query_one("#status", Label).update(
|
|
924
|
+
f"[green]+{len(results)} resultados {suffix}.[/green]"
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
self.query_one("#status", Label).update("Nenhum resultado adicional encontrado.")
|
|
928
|
+
|
|
929
|
+
def _show_search_error(self, generation: int, message: str) -> None:
|
|
930
|
+
if generation != self._search_generation:
|
|
931
|
+
return
|
|
932
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
933
|
+
self.query_one("#status", Label).update(
|
|
934
|
+
f"[red]Não foi possível buscar no YouTube: {message}[/red]"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _load_liked(self) -> None:
|
|
938
|
+
self._liked_generation += 1
|
|
939
|
+
self._search_generation += 1
|
|
940
|
+
self._playlist_generation += 1
|
|
941
|
+
generation = self._liked_generation
|
|
942
|
+
self._active_source = "liked"
|
|
943
|
+
self._liked_next_page = None
|
|
944
|
+
self.query_one("#status", Label).update(
|
|
945
|
+
"[yellow]Carregando músicas curtidas do YouTube...[/yellow]"
|
|
946
|
+
)
|
|
947
|
+
self.query_one("#results-list", ListView).clear()
|
|
948
|
+
self._last_results = []
|
|
949
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
950
|
+
|
|
951
|
+
def worker() -> None:
|
|
952
|
+
if not auth.is_logged_in():
|
|
953
|
+
self.call_from_thread(
|
|
954
|
+
self._show_liked_error,
|
|
955
|
+
generation,
|
|
956
|
+
"faça login antes de abrir as curtidas",
|
|
957
|
+
)
|
|
958
|
+
return
|
|
959
|
+
try:
|
|
960
|
+
service = auth.get_youtube_service(interactive=False)
|
|
961
|
+
results, next_page = liked.fetch_liked(service)
|
|
962
|
+
self.call_from_thread(
|
|
963
|
+
self._apply_liked_results, generation, results, next_page, False
|
|
964
|
+
)
|
|
965
|
+
except Exception as exc:
|
|
966
|
+
self.call_from_thread(self._show_liked_error, generation, str(exc))
|
|
967
|
+
|
|
968
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
969
|
+
|
|
970
|
+
def _load_more_liked(self) -> None:
|
|
971
|
+
if not self._liked_next_page:
|
|
972
|
+
self.query_one("#status", Label).update("Fim das músicas curtidas.")
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
976
|
+
generation = self._liked_generation
|
|
977
|
+
page_token = self._liked_next_page
|
|
978
|
+
|
|
979
|
+
def worker() -> None:
|
|
980
|
+
try:
|
|
981
|
+
service = auth.get_youtube_service(interactive=False)
|
|
982
|
+
results, next_page = liked.fetch_liked(
|
|
983
|
+
service, page_token=page_token
|
|
984
|
+
)
|
|
985
|
+
self.call_from_thread(
|
|
986
|
+
self._apply_liked_results, generation, results, next_page, True
|
|
987
|
+
)
|
|
988
|
+
except Exception as exc:
|
|
989
|
+
self.call_from_thread(self._show_liked_error, generation, str(exc))
|
|
990
|
+
|
|
991
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
992
|
+
|
|
993
|
+
def _apply_liked_results(
|
|
994
|
+
self,
|
|
995
|
+
generation: int,
|
|
996
|
+
results: list[dict[str, Any]],
|
|
997
|
+
next_page: Optional[str],
|
|
998
|
+
append: bool,
|
|
999
|
+
) -> None:
|
|
1000
|
+
if generation != self._liked_generation:
|
|
1001
|
+
return
|
|
1002
|
+
if append:
|
|
1003
|
+
self._append_results(results)
|
|
1004
|
+
else:
|
|
1005
|
+
self._show_results(results)
|
|
1006
|
+
self._liked_next_page = next_page
|
|
1007
|
+
self.query_one("#btn-load-more", Button).disabled = not bool(next_page)
|
|
1008
|
+
if results:
|
|
1009
|
+
self.query_one("#status", Label).update(
|
|
1010
|
+
f"[green]+{len(results)} curtidas carregadas.[/green]"
|
|
1011
|
+
)
|
|
1012
|
+
else:
|
|
1013
|
+
self.query_one("#status", Label).update("Nenhuma curtida encontrada.")
|
|
1014
|
+
|
|
1015
|
+
def _show_liked_error(self, generation: int, message: str) -> None:
|
|
1016
|
+
if generation != self._liked_generation:
|
|
1017
|
+
return
|
|
1018
|
+
self.query_one("#btn-load-more", Button).disabled = True
|
|
1019
|
+
self.query_one("#status", Label).update(
|
|
1020
|
+
f"[red]Não foi possível carregar as curtidas: {message}[/red]"
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
# ------------------------------------------------------------------ #
|
|
1024
|
+
# Mix e Playlist
|
|
1025
|
+
# ------------------------------------------------------------------ #
|
|
1026
|
+
|
|
1027
|
+
def _fetch_for_mix(
|
|
1028
|
+
self, query: str, count: int, page_token: Optional[str] = None
|
|
1029
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
1030
|
+
"""Provedor paginado de busca usado pelo MixService."""
|
|
1031
|
+
return search.fetch_page(query, count=count, page_token=page_token)
|
|
1032
|
+
|
|
1033
|
+
def _should_ignore_selection(self, track: dict[str, Any]) -> bool:
|
|
1034
|
+
track_key = str(
|
|
1035
|
+
track.get("id") or track.get("url") or track.get("title", "")
|
|
1036
|
+
)
|
|
1037
|
+
if not track_key:
|
|
1038
|
+
return False
|
|
1039
|
+
now = time.monotonic()
|
|
1040
|
+
if (
|
|
1041
|
+
track_key == self._last_selection_key
|
|
1042
|
+
and now - self._last_selection_at < 0.8
|
|
1043
|
+
):
|
|
1044
|
+
return True
|
|
1045
|
+
self._last_selection_key = track_key
|
|
1046
|
+
self._last_selection_at = now
|
|
1047
|
+
return False
|
|
1048
|
+
|
|
1049
|
+
def _start_mix_for_track(self, track: dict[str, Any]) -> None:
|
|
1050
|
+
"""Toca a faixa imediatamente e prepara a Mix em segundo plano."""
|
|
1051
|
+
if self._should_ignore_selection(track):
|
|
1052
|
+
return
|
|
1053
|
+
status = self.query_one("#status", Label)
|
|
1054
|
+
title = escape(str(track.get("title", "Sem título")))
|
|
1055
|
+
status.update(f"[yellow]Carregando áudio de '{title}'...[/yellow]")
|
|
1056
|
+
|
|
1057
|
+
# A âncora toca fora da fila; a lista começa na primeira relacionada.
|
|
1058
|
+
self.player.set_queue([])
|
|
1059
|
+
self._refresh_playlist_ui()
|
|
1060
|
+
self.player.play_external(track)
|
|
1061
|
+
|
|
1062
|
+
self._mix_generation += 1
|
|
1063
|
+
generation = self._mix_generation
|
|
1064
|
+
seed_tracks = list(self._last_results)
|
|
1065
|
+
|
|
1066
|
+
def worker() -> None:
|
|
1067
|
+
try:
|
|
1068
|
+
with self._mix_lock:
|
|
1069
|
+
if generation != self._mix_generation:
|
|
1070
|
+
return
|
|
1071
|
+
mix_tracks = self.mix.start(
|
|
1072
|
+
track, seed_tracks=seed_tracks
|
|
1073
|
+
)
|
|
1074
|
+
self.call_from_thread(self._apply_mix, generation, mix_tracks)
|
|
1075
|
+
except Exception as exc:
|
|
1076
|
+
with self._mix_lock:
|
|
1077
|
+
partial_tracks = (
|
|
1078
|
+
list(self.mix.tracks)
|
|
1079
|
+
if generation == self._mix_generation
|
|
1080
|
+
else []
|
|
1081
|
+
)
|
|
1082
|
+
if len(partial_tracks) > 1:
|
|
1083
|
+
self.call_from_thread(
|
|
1084
|
+
self._apply_mix, generation, partial_tracks, str(exc)
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
self.call_from_thread(self._show_mix_error, generation, str(exc))
|
|
1088
|
+
|
|
1089
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
1090
|
+
|
|
1091
|
+
def _apply_mix(
|
|
1092
|
+
self,
|
|
1093
|
+
generation: int,
|
|
1094
|
+
mix_tracks: list[dict[str, Any]],
|
|
1095
|
+
warning: Optional[str] = None,
|
|
1096
|
+
) -> None:
|
|
1097
|
+
if generation != self._mix_generation:
|
|
1098
|
+
return
|
|
1099
|
+
related = mix_tracks[1:]
|
|
1100
|
+
if related:
|
|
1101
|
+
self.player.add_to_queue(related)
|
|
1102
|
+
self._refresh_playlist_ui()
|
|
1103
|
+
if warning:
|
|
1104
|
+
self.query_one("#status", Label).update(
|
|
1105
|
+
f"[yellow]Mix parcial: +{len(related)} músicas locais.[/yellow]"
|
|
1106
|
+
)
|
|
1107
|
+
else:
|
|
1108
|
+
self.query_one("#status", Label).update(
|
|
1109
|
+
f"[green]Mix ativa: +{len(related)} músicas relacionadas prontas.[/green]"
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
def _show_mix_error(self, generation: int, message: str) -> None:
|
|
1113
|
+
if generation == self._mix_generation:
|
|
1114
|
+
self.query_one("#status", Label).update(
|
|
1115
|
+
f"[yellow]A faixa está tocando, mas a Mix não pôde ser criada: {message}[/yellow]"
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def _refill_mix_if_needed(self) -> None:
|
|
1119
|
+
current_index = self.player.current_index
|
|
1120
|
+
remaining = (
|
|
1121
|
+
self.player.queue_length()
|
|
1122
|
+
if current_index is None
|
|
1123
|
+
else self.player.queue_length() - (current_index + 1)
|
|
1124
|
+
)
|
|
1125
|
+
if not self.mix.needs_refill(remaining):
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
generation = self._mix_generation
|
|
1129
|
+
|
|
1130
|
+
def worker() -> None:
|
|
1131
|
+
try:
|
|
1132
|
+
with self._mix_lock:
|
|
1133
|
+
if generation != self._mix_generation:
|
|
1134
|
+
return
|
|
1135
|
+
new_tracks = self.mix.refill()
|
|
1136
|
+
self.call_from_thread(self._apply_mix_refill, generation, new_tracks)
|
|
1137
|
+
except Exception as exc:
|
|
1138
|
+
self.call_from_thread(self._show_mix_error, generation, str(exc))
|
|
1139
|
+
|
|
1140
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
1141
|
+
|
|
1142
|
+
def _apply_mix_refill(
|
|
1143
|
+
self, generation: int, new_tracks: list[dict[str, Any]]
|
|
1144
|
+
) -> None:
|
|
1145
|
+
if generation != self._mix_generation or not new_tracks:
|
|
1146
|
+
return
|
|
1147
|
+
self.player.add_to_queue(new_tracks)
|
|
1148
|
+
self._refresh_playlist_ui()
|
|
1149
|
+
self.query_one("#status", Label).update(
|
|
1150
|
+
f"[green]+{len(new_tracks)} músicas adicionadas à Mix.[/green]"
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# ------------------------------------------------------------------ #
|
|
1154
|
+
# Callbacks do Player
|
|
1155
|
+
# ------------------------------------------------------------------ #
|
|
1156
|
+
|
|
1157
|
+
def _on_player_state(self, state: dict[str, Any]) -> None:
|
|
1158
|
+
self.call_from_thread(self._sync_player_state_ui, state)
|
|
1159
|
+
|
|
1160
|
+
def _sync_player_state_ui(self, state: dict[str, Any]) -> None:
|
|
1161
|
+
track = state.get("track")
|
|
1162
|
+
if track:
|
|
1163
|
+
title = escape(str(track.get("title", "Sem título")))
|
|
1164
|
+
self.query_one("#now-playing", Label).update(
|
|
1165
|
+
f"[bold red]▶ Tocando:[/bold red] [bold #ffffff]{title}[/bold #ffffff]"
|
|
1166
|
+
)
|
|
1167
|
+
else:
|
|
1168
|
+
self.query_one("#now-playing", Label).update("Nenhuma música tocando")
|
|
1169
|
+
|
|
1170
|
+
status = self.query_one("#status", Label)
|
|
1171
|
+
if state.get("playing"):
|
|
1172
|
+
status.update("[bold red]Reproduzindo ♪[/bold red]")
|
|
1173
|
+
elif state.get("paused"):
|
|
1174
|
+
status.update("[yellow]Pausado ⏸[/yellow]")
|
|
1175
|
+
self._refresh_vol_label()
|
|
1176
|
+
self._refresh_playlist_markers()
|
|
1177
|
+
|
|
1178
|
+
def _on_player_progress(self, progress: dict[str, Any]) -> None:
|
|
1179
|
+
self.call_from_thread(self._sync_player_progress_ui, progress)
|
|
1180
|
+
|
|
1181
|
+
def _sync_player_progress_ui(self, progress: dict[str, Any]) -> None:
|
|
1182
|
+
position = float(progress.get("position", 0.0) or 0.0)
|
|
1183
|
+
duration = float(progress.get("duration", 0.0) or 0.0)
|
|
1184
|
+
self.query_one("#time-current", Label).update(format_time(position))
|
|
1185
|
+
self.query_one("#time-total", Label).update(format_time(duration))
|
|
1186
|
+
bar = self.query_one("#song-progress", ProgressBar)
|
|
1187
|
+
bar.progress = min(100.0, (position / duration) * 100.0) if duration > 0 else 0
|
|
1188
|
+
|
|
1189
|
+
def _on_track_finished(self) -> None:
|
|
1190
|
+
self._refill_mix_if_needed()
|
|
1191
|
+
self.call_from_thread(self._refresh_playlist_markers)
|
|
1192
|
+
|
|
1193
|
+
def _refresh_vol_label(self) -> None:
|
|
1194
|
+
percentage = int(self.player.volume * 100)
|
|
1195
|
+
self.query_one("#vol-label", Label).update(f"{percentage}%")
|
|
1196
|
+
|
|
1197
|
+
# ------------------------------------------------------------------ #
|
|
1198
|
+
# Ações de Atalho (Bindings)
|
|
1199
|
+
# ------------------------------------------------------------------ #
|
|
1200
|
+
|
|
1201
|
+
async def action_quit(self) -> None:
|
|
1202
|
+
self.player.stop()
|
|
1203
|
+
self.exit()
|
|
1204
|
+
|
|
1205
|
+
def action_toggle_play(self) -> None:
|
|
1206
|
+
if self.player.is_playing and not self.player.is_paused:
|
|
1207
|
+
self.player.pause()
|
|
1208
|
+
elif self.player.is_paused or self.player.current_track:
|
|
1209
|
+
self.player.play()
|
|
1210
|
+
|
|
1211
|
+
def action_volume_up(self) -> None:
|
|
1212
|
+
self.player.set_volume(self.player.volume + 0.05)
|
|
1213
|
+
self._refresh_vol_label()
|
|
1214
|
+
|
|
1215
|
+
def action_volume_down(self) -> None:
|
|
1216
|
+
self.player.set_volume(self.player.volume - 0.05)
|
|
1217
|
+
self._refresh_vol_label()
|
|
1218
|
+
|
|
1219
|
+
def action_next_track(self) -> None:
|
|
1220
|
+
if self.player.play_next():
|
|
1221
|
+
self._refill_mix_if_needed()
|
|
1222
|
+
self._refresh_playlist_markers()
|
|
1223
|
+
|
|
1224
|
+
def action_prev_track(self) -> None:
|
|
1225
|
+
if self.player.play_prev():
|
|
1226
|
+
self._refresh_playlist_markers()
|
|
1227
|
+
|
|
1228
|
+
def action_seek_forward(self) -> None:
|
|
1229
|
+
self.player.seek(10)
|
|
1230
|
+
|
|
1231
|
+
def action_seek_back(self) -> None:
|
|
1232
|
+
self.player.seek(-10)
|
|
1233
|
+
|
|
1234
|
+
# ------------------------------------------------------------------ #
|
|
1235
|
+
# Eventos das Listas (Seleção de Música)
|
|
1236
|
+
# ------------------------------------------------------------------ #
|
|
1237
|
+
|
|
1238
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
1239
|
+
if event.list_view.id == "user-playlists-list":
|
|
1240
|
+
index = event.index
|
|
1241
|
+
if 0 <= index < len(self._user_playlists):
|
|
1242
|
+
self._open_user_playlist(self._user_playlists[index])
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
if event.list_view.id == "results-list":
|
|
1246
|
+
index = event.index
|
|
1247
|
+
if 0 <= index < len(self._last_results):
|
|
1248
|
+
self._start_mix_for_track(self._last_results[index])
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
if event.list_view.id == "playlist-list":
|
|
1252
|
+
index = event.index
|
|
1253
|
+
queue = self.player.queue
|
|
1254
|
+
if 0 <= index < len(queue) and not self._should_ignore_selection(
|
|
1255
|
+
queue[index]
|
|
1256
|
+
):
|
|
1257
|
+
self.player.play(queue[index])
|
|
1258
|
+
self._refill_mix_if_needed()
|
|
1259
|
+
self._refresh_playlist_markers()
|
|
1260
|
+
|
|
1261
|
+
def _result_item(self, result: dict[str, Any]) -> ListItem:
|
|
1262
|
+
icon = "🎵" if result.get("is_music") else "🎬"
|
|
1263
|
+
duration = format_time(result.get("duration", 0))
|
|
1264
|
+
title = escape(str(result.get("title", "Sem título")))
|
|
1265
|
+
artist = result.get("artist")
|
|
1266
|
+
suffix = f" — {escape(str(artist))}" if artist else ""
|
|
1267
|
+
return ListItem(
|
|
1268
|
+
Label(f"{icon} {title}{suffix} [dim #aaaaaa][{duration}][/dim #aaaaaa]")
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
def _show_results(self, results: list[dict[str, Any]]) -> None:
|
|
1272
|
+
self._last_results = list(results)
|
|
1273
|
+
results_list = self.query_one("#results-list", ListView)
|
|
1274
|
+
results_list.clear()
|
|
1275
|
+
for result in results:
|
|
1276
|
+
results_list.append(self._result_item(result))
|
|
1277
|
+
|
|
1278
|
+
def _append_results(self, results: list[dict[str, Any]]) -> None:
|
|
1279
|
+
results_list = self.query_one("#results-list", ListView)
|
|
1280
|
+
for result in results:
|
|
1281
|
+
self._last_results.append(result)
|
|
1282
|
+
results_list.append(self._result_item(result))
|
|
1283
|
+
|
|
1284
|
+
def _refresh_playlist_ui(self) -> None:
|
|
1285
|
+
playlist = self.query_one("#playlist-list", ListView)
|
|
1286
|
+
playlist.clear()
|
|
1287
|
+
queue = self.player.queue
|
|
1288
|
+
current_index = self.player.current_index
|
|
1289
|
+
for index, track in enumerate(queue):
|
|
1290
|
+
marker = "[bold red]▶[/bold red] " if index == current_index else " "
|
|
1291
|
+
title = escape(str(track.get("title", "Sem título")))
|
|
1292
|
+
styled = (
|
|
1293
|
+
f"[bold #ffffff]{title}[/bold #ffffff]"
|
|
1294
|
+
if index == current_index
|
|
1295
|
+
else f"[#aaaaaa]{title}[/#aaaaaa]"
|
|
1296
|
+
)
|
|
1297
|
+
playlist.append(ListItem(Label(f"{marker}{styled}")))
|
|
1298
|
+
if current_index is not None and 0 <= current_index < len(queue):
|
|
1299
|
+
playlist.index = current_index
|
|
1300
|
+
else:
|
|
1301
|
+
playlist.index = None
|
|
1302
|
+
|
|
1303
|
+
def _refresh_playlist_markers(self) -> None:
|
|
1304
|
+
playlist = self.query_one("#playlist-list", ListView)
|
|
1305
|
+
queue = self.player.queue
|
|
1306
|
+
if len(playlist.children) != len(queue):
|
|
1307
|
+
self._refresh_playlist_ui()
|
|
1308
|
+
return
|
|
1309
|
+
current_index = self.player.current_index
|
|
1310
|
+
if current_index is not None and 0 <= current_index < len(queue):
|
|
1311
|
+
playlist.index = current_index
|
|
1312
|
+
else:
|
|
1313
|
+
playlist.index = None
|
|
1314
|
+
for index, child in enumerate(playlist.children):
|
|
1315
|
+
marker = "[bold red]▶[/bold red] " if index == current_index else " "
|
|
1316
|
+
title = escape(str(queue[index].get("title", "Sem título")))
|
|
1317
|
+
styled = (
|
|
1318
|
+
f"[bold #ffffff]{title}[/bold #ffffff]"
|
|
1319
|
+
if index == current_index
|
|
1320
|
+
else f"[#aaaaaa]{title}[/#aaaaaa]"
|
|
1321
|
+
)
|
|
1322
|
+
child.query_one(Label).update(f"{marker}{styled}")
|