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/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}")