beets-quicktag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: beets-quicktag
3
+ Version: 0.1.0
4
+ Summary: A beets plugin for quickly tagging tracks with customizable attributes
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: beets>=2.3.0
8
+ Requires-Dist: just-playback>=0.1.8
9
+ Requires-Dist: textual>=3.2.0
10
+
11
+ # Beets QuickTag Plugin
12
+
13
+ This is a plugin for [beets](https://beets.io/) that scratches my own itch to categorize my music using custom tags, for DJing, as efficiently as possible. If it's not at a 1.0 release, it's probably not stable for use by others, though I'll still try and look at issues if for some reason you've found this (hi!).
14
+
15
+ It's a work in progress though much of the functionality is already there. I initially prototyped this using Claude 3.7 and Gemini 2.5 Pro Preview but ended up re-working quite a bit of it.
16
+
17
+ TODO:
18
+ - add tests
19
+ - add rating or energy level widget
20
+ - consider bundling libmpv
21
+ - automate releases
22
+ - test on Windows
23
+ - summarize changes after QuickTag finishes running
24
+ - consider bundling libmpv
25
+
26
+ ## Requirements
27
+
28
+ You must have `libmpv` installed.
29
+
30
+ ## Configuration
31
+
32
+ Add a `quicktag` section to your beets `config.yaml`. Here's an example:
33
+
34
+ ```yaml
35
+
36
+ quicktag:
37
+ autoplay_at_launch: yes
38
+ autoplay_on_track_change: no
39
+ autosave_on_quit: yes
40
+ categories:
41
+ collection:
42
+ - DJ
43
+ - Sample
44
+ mood:
45
+ - happy
46
+ - sad
47
+ - bright
48
+ - dark
49
+ - angry
50
+ ```
51
+
52
+ ## Credits
53
+
54
+ This was inspired by the Quick Tag functionality in [One Tagger](https://onetagger.github.io/), which is an excellent application. One Tagger also has [a spreadsheet](https://docs.google.com/spreadsheets/d/1wYokScjoS5Xb1IvqFMXbSbknrXJ7bySLLihTucOS4qY/edit?gid=0#gid=0) that might provide inspiration from existing systems to categorize tracks in this way. I believe the One Tagger system, or at least the default categories it has, are inspired by [a reddit post by u/nonomomomo](https://www.reddit.com/r/DJs/comments/c3o2jk/my_ultimate_track_tagging_system_the_little_data/).
@@ -0,0 +1,10 @@
1
+ beetsplug/quicktag/__init__.py,sha256=VVZL0EsW-OVcc2AYErRngmINqrLV8lSw8biow6qRO5c,2596
2
+ beetsplug/quicktag/app.py,sha256=Cxkcbgw80CC_MCIReoeav-ajwI054FirGgFC05R9BLw,17309
3
+ beetsplug/quicktag/widgets/custom_selection_list.py,sha256=rWUQAsopKU9shr1kGso3aypfQsUV6KuLTwaiiKq5SSE,2945
4
+ beetsplug/quicktag/widgets/input_with_label.py,sha256=QGa6CHEWYoZ8UxEj_sL4aLYladbthbvsE3YT38315EA,911
5
+ beetsplug/quicktag/widgets/playback.py,sha256=_AHzwuqDadid173LOHxH3mORwrZorZibVYST1IJA8NI,6724
6
+ beetsplug/quicktag/widgets/playback_progress.py,sha256=R0x31d4uSZ8Xpv4E7izrSO4vZUe8RQQBZYY5ZcKDxT0,3510
7
+ beets_quicktag-0.1.0.dist-info/METADATA,sha256=DEEUv8e-3AySiAjHk6QM0b_Mzktrdy9cfYl0VrywAn0,2000
8
+ beets_quicktag-0.1.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
9
+ beets_quicktag-0.1.0.dist-info/top_level.txt,sha256=za8u6CXAGbgac8cUyn9NlsgXqqq-_4HHZcNmJnfmyWc,10
10
+ beets_quicktag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ beetsplug
@@ -0,0 +1,76 @@
1
+ import optparse
2
+
3
+ from beets import ui
4
+ from beets.dbcore.db import Results as BeetsResults
5
+ from beets.library import Library as BeetsLibrary
6
+ from beets.plugins import BeetsPlugin
7
+
8
+ from .app import QuickTagApp
9
+
10
+
11
+ class QuickTagPlugin(BeetsPlugin):
12
+ def __init__(self):
13
+ super(QuickTagPlugin, self).__init__()
14
+ self.config.add(
15
+ {
16
+ "categories": {},
17
+ "autoplay_on_track_change": False,
18
+ "autoplay_at_launch": False,
19
+ "autonext_at_track_end": False,
20
+ "autosave_on_quit": False,
21
+ "keep_playing_on_track_change_if_playing": True,
22
+ }
23
+ )
24
+
25
+ def commands(self):
26
+ cmd = ui.Subcommand(
27
+ "quicktag",
28
+ help="Quickly tag tracks with predefined options.",
29
+ aliases=["qt"],
30
+ )
31
+ cmd.func = self.run_quicktag
32
+ return [cmd]
33
+
34
+ def run_quicktag(self, lib: BeetsLibrary, opts: optparse.Values, args):
35
+ query = ui.decargs(args)
36
+ items: BeetsResults = lib.items(query)
37
+
38
+ if not items:
39
+ ui.print_("No tracks found to tag.")
40
+ return
41
+
42
+ categories_config = self.config["categories"].get(dict)
43
+ autoplay_on_track_change_enabled = self.config["autoplay_on_track_change"].get(
44
+ bool
45
+ )
46
+ autoplay_at_launch_enabled = self.config["autoplay_at_launch"].get(bool)
47
+ autonext_at_track_end_enabled = self.config["autonext_at_track_end"].get(bool)
48
+ autosave_on_quit_enabled = self.config["autosave_on_quit"].get(bool)
49
+ keep_playing_on_track_change_if_playing_enabled = self.config["keep_playing_on_track_change_if_playing"].get(bool)
50
+
51
+ if not categories_config:
52
+ ui.print_(
53
+ "No categories defined in the configuration. Please configure the quicktag plugin."
54
+ )
55
+ ui.print_("Example configuration:")
56
+ ui.print_("quicktag:")
57
+ ui.print_(" categories:")
58
+ ui.print_(" mood: [happy, sad, energetic, calm]")
59
+ ui.print_(
60
+ " genre_custom: [electronic, ambient, experimental, soundtrack]"
61
+ )
62
+ return
63
+
64
+ categories = list(categories_config.items())
65
+
66
+ app = QuickTagApp(
67
+ lib,
68
+ items,
69
+ categories,
70
+ autoplay_on_track_change_enabled,
71
+ autoplay_at_launch_enabled,
72
+ autonext_at_track_end_enabled,
73
+ autosave_on_quit_enabled,
74
+ keep_playing_on_track_change_if_playing_enabled,
75
+ )
76
+ app.run()
@@ -0,0 +1,439 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+
4
+ from beets.dbcore.db import Results as BeetsResults
5
+ from beets.library import Item as BeetsItem
6
+ from beets.library import Library as BeetsLibrary
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Vertical
10
+ from textual.dom import NoMatches
11
+ from textual.widgets import Footer, Static
12
+ from textual.widgets.selection_list import Selection
13
+
14
+ from .widgets.custom_selection_list import CustomSelectionList
15
+ from .widgets.input_with_label import InputWithLabel
16
+ from .widgets.playback import PlaybackEnded, PlaybackWidget
17
+
18
+
19
+ class NavigateDirection(Enum):
20
+ """Enum for seek direction."""
21
+
22
+ FORWARD = 1
23
+ BACKWARD = -1
24
+
25
+
26
+ class HeaderWidget(Vertical):
27
+ """A custom widget for the application header, now including a playback widget."""
28
+
29
+ DEFAULT_CSS = """
30
+ HeaderWidget {
31
+ dock: top;
32
+ width: 100%;
33
+ height: auto; /* Adjusts to content: title line + playback widget */
34
+ background: $panel;
35
+ color: $text;
36
+ padding: 0 1;
37
+ }
38
+ #header_text_content {
39
+ width: 100%;
40
+ height: 1;
41
+ }
42
+ """
43
+
44
+ def __init__(self, playback_widget: PlaybackWidget, item=None, **kwargs):
45
+ super().__init__(**kwargs)
46
+ self.item: BeetsItem = item
47
+ self._header_text_display = Static(id="header_text_content")
48
+ self.playback_widget = playback_widget
49
+
50
+ def compose(self) -> ComposeResult:
51
+ """Compose the header with text and the playback widget."""
52
+ yield self._header_text_display
53
+ yield self.playback_widget
54
+
55
+ def on_mount(self) -> None:
56
+ """Set the header text when the widget is mounted."""
57
+ self.update_header()
58
+
59
+ def update_header(self, item: Optional[BeetsItem] = None) -> None:
60
+ """Updates the header text."""
61
+ if item:
62
+ self.item = item
63
+
64
+ header_text_value = "QuickTag"
65
+ if self.item:
66
+ header_text_value = f"Tagging: {self.item.artist} - {self.item.title}"
67
+
68
+ self._header_text_display.update(header_text_value)
69
+
70
+
71
+ class QuickTagApp(App):
72
+ BINDINGS = [
73
+ Binding("escape", "quit", "Quit", show=True, priority=True),
74
+ Binding("left", "previous_item", "Previous", show=True, priority=True),
75
+ Binding("right", "next_item", "Next", show=True, priority=True),
76
+ ("/", "play_pause_current_item", "Play/Pause"),
77
+ ("<", "seek_backward(5)", "Seek -5s"),
78
+ (">", "seek_forward(5)", "Seek +5s"),
79
+ ]
80
+
81
+ DEFAULT_CSS = """
82
+ Screen {
83
+ align: center middle;
84
+ }
85
+
86
+ SelectionList {
87
+ padding: 1;
88
+ border: solid $accent;
89
+ /* width: 80%; */
90
+ /* height: 80%; */
91
+ }
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ lib: BeetsLibrary,
97
+ items: BeetsResults,
98
+ categories: list[tuple[str, list[str]]],
99
+ autoplay_on_track_change_enabled: bool,
100
+ autoplay_at_launch_enabled: bool,
101
+ autonext_at_track_end_enabled: bool,
102
+ autosave_on_quit_enabled: bool,
103
+ keep_playing_on_track_change_if_playing_enabled: bool,
104
+ **kwargs,
105
+ ):
106
+ super().__init__(**kwargs)
107
+ self.lib = lib
108
+ self.items = items
109
+ self.categories = categories
110
+ self.autoplay_on_track_change_enabled = autoplay_on_track_change_enabled
111
+ self.autoplay_at_launch_enabled = autoplay_at_launch_enabled
112
+ self.autonext_at_track_end_enabled = autonext_at_track_end_enabled
113
+ self.autosave_on_quit_enabled = autosave_on_quit_enabled
114
+ self.keep_playing_on_track_change_if_playing_enabled = keep_playing_on_track_change_if_playing_enabled
115
+
116
+ self.current_item_index = 0
117
+ self.item = items[0] if items else None
118
+ self.playback_widget = PlaybackWidget()
119
+ self.header_widget = HeaderWidget(
120
+ item=self.item, playback_widget=self.playback_widget
121
+ )
122
+
123
+ self.log.info("QuickTagApp initialized.")
124
+
125
+ async def on_mount(self) -> None:
126
+ """Called when the app is mounted."""
127
+ self.theme = "gruvbox"
128
+ await self._set_item(self.item, save_current_item_tags=False)
129
+ if not self.autoplay_at_launch_enabled:
130
+ self.playback_widget.pause()
131
+
132
+ async def on_unmount(self) -> None:
133
+ """Called when the app is unmounted."""
134
+ self.log.info("QuickTagApp unmounted.")
135
+
136
+ def compose(self) -> ComposeResult:
137
+ yield self.header_widget
138
+
139
+ if self.item:
140
+ for category_name, options in self.categories:
141
+ selection_options = [
142
+ Selection(option_text, option_idx)
143
+ for option_idx, option_text in enumerate(options)
144
+ ]
145
+ category_selection_list = CustomSelectionList(
146
+ *selection_options, id=f"selection-{category_name}"
147
+ )
148
+ category_selection_list.border_title = category_name
149
+ yield category_selection_list
150
+ yield InputWithLabel(input_label="Comments:", id="comments-input")
151
+ else:
152
+ yield Static("No items to tag.")
153
+
154
+ yield Footer()
155
+
156
+ async def action_quit(self) -> None:
157
+ """Action to quit the application."""
158
+ self.log.info(
159
+ f"action_quit called. autosave_on_quit_enabled: {self.autosave_on_quit_enabled}"
160
+ )
161
+ if self.autosave_on_quit_enabled and self.item:
162
+ self.log.info("Autosaving tags before quitting.")
163
+ await self._save_current_item_tags()
164
+ self.exit()
165
+
166
+ async def _load_current_item_for_playback(self) -> None:
167
+ """Load the current item for playback."""
168
+
169
+ item_path_bytes = self.item.path
170
+
171
+ try:
172
+ item_path_str = item_path_bytes.decode("utf-8", "surrogateescape")
173
+ except AttributeError:
174
+ item_path_str = item_path_bytes
175
+ except Exception as e:
176
+ self.log.error(f"Error decoding item path: {e}")
177
+ return
178
+
179
+ self.playback_widget.load_track(item_path_str)
180
+
181
+ async def _set_item(self, item: BeetsItem, save_current_item_tags=True) -> None:
182
+ """Handle changes to the current item."""
183
+ # Capture the current playback state before changing items
184
+ was_playing_before = self.playback_widget.is_playing()
185
+
186
+ if save_current_item_tags:
187
+ await self._save_current_item_tags()
188
+ self.item = item
189
+ self.header_widget.update_header(item)
190
+ await self._load_tags_for_current_item()
191
+ self.log.info(f"Item set to: {item.artist} - {item.title}")
192
+
193
+ if self.categories:
194
+ first_category_name, _ = self.categories[0]
195
+ try:
196
+ self.query_one(
197
+ f"#selection-{first_category_name}", CustomSelectionList
198
+ ).focus()
199
+ except NoMatches:
200
+ pass
201
+
202
+ # Load the new track (this doesn't start playback automatically)
203
+ await self._load_current_item_for_playback()
204
+
205
+ # Determine whether to start playing the new track
206
+ should_play = False
207
+
208
+ if was_playing_before and self.keep_playing_on_track_change_if_playing_enabled:
209
+ # If we were playing before and the setting allows it, continue playing the new track
210
+ should_play = True
211
+ self.log.info("Continuing playback with new track (was playing before and keep_playing_on_track_change_if_playing enabled)")
212
+ elif self.autoplay_on_track_change_enabled:
213
+ # If autoplay is enabled, start playing regardless of previous state
214
+ should_play = True
215
+ self.log.info("Starting playback due to autoplay_on_track_change setting")
216
+ else:
217
+ # We were paused or keep_playing_on_track_change_if_playing is disabled, stay paused
218
+ self.log.info("Keeping playback paused (was paused, autoplay disabled, or keep_playing_on_track_change_if_playing disabled)")
219
+
220
+ if should_play:
221
+ self.playback_widget.play()
222
+ else:
223
+ # Ensure we're paused if we shouldn't be playing
224
+ if self.playback_widget.is_playing():
225
+ self.playback_widget.pause()
226
+
227
+ async def _navigate(self, direction: NavigateDirection) -> None:
228
+ """Navigates through the items list in the specified direction."""
229
+ # TODO: Do we need this?
230
+ # TODO: should be exception?
231
+ if not self.item:
232
+ return
233
+
234
+ # TODO: do we stop here or elsewhere?
235
+ # self.playback_widget.stop()
236
+ # self.log.info("Playback stopped for next item.")
237
+
238
+ if direction == NavigateDirection.FORWARD:
239
+ if self.current_item_index < len(self.items) - 1:
240
+ self.current_item_index += 1
241
+ else:
242
+ self.header_widget._header_text_display.update(
243
+ "All items processed. Press Esc to quit."
244
+ )
245
+ return
246
+ elif direction == NavigateDirection.BACKWARD:
247
+ if self.current_item_index > 0:
248
+ self.current_item_index -= 1
249
+ else:
250
+ # We don't want to loop back to the last item
251
+ return
252
+ else:
253
+ # this should never happen...
254
+ error_message = f"Invalid direction: {direction}. Use {NavigateDirection.FORWARD} (NavigateDirection.FORWARD) or {NavigateDirection.BACKWARD} (NavigateDirection.BACKWARD)."
255
+ self.log.error(error_message)
256
+ raise ValueError(error_message)
257
+
258
+ await self._set_item(self.items[self.current_item_index])
259
+
260
+ async def action_next_item(self) -> None:
261
+ """Saves tags for the current item and moves to the next item."""
262
+ await self._navigate(NavigateDirection.FORWARD)
263
+ self.log.info("action_next_item called.")
264
+
265
+ async def action_previous_item(self) -> None:
266
+ """Saves tags for the current item and moves to the previous item."""
267
+ await self._navigate(NavigateDirection.BACKWARD)
268
+ self.log.info("action_previous_item called.")
269
+
270
+ async def action_play_pause_current_item(self) -> None:
271
+ """Toggles play/pause for the current item."""
272
+ # might not need to check if item exists if we validate earlier
273
+ # but the path does need to exist
274
+ # TODO: check if path exists, probably not here though
275
+ if not self.item or not hasattr(self.item, "path"):
276
+ self.log.warning("No item selected or item has no path.")
277
+ return
278
+
279
+ self.playback_widget.play_pause()
280
+ self.log.info("Requested play/pause for current item.")
281
+
282
+ async def action_seek_forward(self, seconds: int = 5) -> None:
283
+ """Seeks forward in the current track."""
284
+ if self.item:
285
+ self.playback_widget.seek_relative(seconds)
286
+
287
+ async def action_seek_backward(self, seconds: int = 5) -> None:
288
+ """Seeks backward in the current track."""
289
+ if self.item:
290
+ self.playback_widget.seek_relative(-seconds)
291
+
292
+ async def on_playback_widget_track_ended(self, message: PlaybackEnded) -> None:
293
+ """Handles the TrackEnded message from PlaybackWidget."""
294
+ self.log.info(
295
+ f"PlaybackWidget.TrackEnded message received. Autoplay next: {self.autoplay_on_track_change_enabled}"
296
+ )
297
+ if self.autonext_at_track_end_enabled:
298
+ if self.current_item_index < len(self.items) - 1:
299
+ self.log.info("Autoplaying next item due to TrackEnded.")
300
+ await self.action_next_item()
301
+ else:
302
+ self.log.info("Track ended, but already at the last item.")
303
+ else:
304
+ self.playback_widget.pause()
305
+ self.playback_widget.seek(0)
306
+
307
+ async def _save_current_item_tags(self) -> None:
308
+ """Saves the tags for the current item based on selections."""
309
+ if not self.item:
310
+ self.log.warning("_save_current_item_tags: No item to save.")
311
+ return
312
+
313
+ self.log.info(
314
+ f"_save_current_item_tags: Attempting to save tags for {self.item.artist} - {self.item.title}"
315
+ )
316
+ changed = False
317
+ for category_name, options_list in self.categories:
318
+ try:
319
+ selection_list = self.query_one(
320
+ f"#selection-{category_name}", CustomSelectionList
321
+ )
322
+ except NoMatches:
323
+ self.log.error(
324
+ f"Could not find SelectionList for category: {category_name} during save."
325
+ )
326
+ continue
327
+
328
+ selected_indices_in_list = selection_list.selected
329
+ selected_values = [options_list[i] for i in selected_indices_in_list]
330
+
331
+ current_tag_value = ", ".join(selected_values) if selected_values else None
332
+ old_value = self.item.get(category_name)
333
+ self.log.debug(
334
+ f"Category {category_name} for '{self.item.title}': current_tag_value: '{current_tag_value}', old_value: '{old_value}'"
335
+ )
336
+
337
+ if current_tag_value:
338
+ if old_value != current_tag_value:
339
+ self.log.info(
340
+ f"Updating tag {category_name} from '{old_value}' to '{current_tag_value}' for {self.item.title}"
341
+ )
342
+ self.item[category_name] = current_tag_value
343
+ changed = True
344
+ elif old_value is not None:
345
+ self.log.info(
346
+ f"Removing tag {category_name} (was '{old_value}') for {self.item.title}"
347
+ )
348
+ del self.item[category_name]
349
+ changed = True
350
+
351
+ # Save comments using InputWithLabel
352
+ try:
353
+ comments_widget = self.query_one("#comments-input", InputWithLabel)
354
+ new_comments = comments_widget.value
355
+ old_comments = self.item.get(
356
+ "comments", ""
357
+ ) # Use get with default for comments
358
+ if isinstance(old_comments, bytes): # Ensure old_comments is a string
359
+ old_comments = old_comments.decode("utf-8", "ignore")
360
+
361
+ if old_comments != new_comments:
362
+ self.log.info(
363
+ f"Updating comments from '{old_comments}' to '{new_comments}' for {self.item.title}"
364
+ )
365
+ if new_comments:
366
+ self.item["comments"] = new_comments
367
+ elif "comments" in self.item: # Only delete if it exists
368
+ del self.item["comments"]
369
+ changed = True
370
+ except NoMatches:
371
+ self.log.error("Could not find comments input for saving.")
372
+
373
+ if changed:
374
+ self.log.info(
375
+ f"Changes detected for '{self.item.artist} - {self.item.title}'. Storing item."
376
+ )
377
+ try:
378
+ self.item.store()
379
+ self.log.info(
380
+ f"Successfully stored item: {self.item.artist} - {self.item.title}"
381
+ )
382
+ except Exception as e:
383
+ self.log.error(
384
+ f"Error storing item {self.item.artist} - {self.item.title}: {e}"
385
+ )
386
+ else:
387
+ self.log.info(
388
+ f"No changes detected for '{self.item.artist} - {self.item.title}'. Nothing to store."
389
+ )
390
+
391
+ async def _load_tags_for_current_item(self) -> None:
392
+ """Loads the tags for the current item into the selection lists."""
393
+ # TODO: I think we should validate earlier that we have a valid items
394
+ if not self.item:
395
+ return
396
+
397
+ for category_name, options_list in self.categories:
398
+ try:
399
+ selection_list = self.query_one(
400
+ f"#selection-{category_name}", CustomSelectionList
401
+ )
402
+ except NoMatches:
403
+ self.log.error(
404
+ f"Could not find SelectionList for category: {category_name} during load."
405
+ )
406
+ continue
407
+
408
+ selection_list.deselect_all()
409
+
410
+ current_tag_string = self.item.get(category_name)
411
+ if not current_tag_string:
412
+ continue
413
+
414
+ tagged_values_for_category = {
415
+ val.strip() for val in current_tag_string.split(",")
416
+ }
417
+
418
+ newly_selected_indices_in_list = []
419
+ for i, option_text_in_list in enumerate(options_list):
420
+ if option_text_in_list in tagged_values_for_category:
421
+ newly_selected_indices_in_list.append(i)
422
+
423
+ if newly_selected_indices_in_list:
424
+ for index_to_select in newly_selected_indices_in_list:
425
+ selection_list.select(index_to_select)
426
+
427
+ selection_list.scroll_to_highlight()
428
+
429
+ # Load comments using InputWithLabel
430
+ try:
431
+ comments_widget = self.query_one("#comments-input", InputWithLabel)
432
+ current_comments = self.item.get(
433
+ "comments", ""
434
+ ) # Use get with default for comments
435
+ if isinstance(current_comments, bytes): # Ensure comments is a string
436
+ current_comments = current_comments.decode("utf-8", "ignore")
437
+ comments_widget.value = current_comments
438
+ except NoMatches:
439
+ self.log.error("Could not find comments input for loading.")
@@ -0,0 +1,66 @@
1
+ from textual.widgets import SelectionList
2
+ from textual import events
3
+
4
+ class CustomSelectionList(SelectionList):
5
+ """
6
+ A custom SelectionList that handles quick selection via alphanumeric key presses.
7
+ """
8
+
9
+ def __init__(self, *args, **kwargs):
10
+ super().__init__(*args, **kwargs)
11
+ self.search_char: str | None = None
12
+
13
+ async def on_key(self, event: events.Key) -> None:
14
+ """Handle key presses for quick selection.
15
+ Pressing an alphanumeric key attempts to jump to the next item in this
16
+ SelectionList (relative to the currently highlighted item) that starts
17
+ with that character, wrapping around if necessary.
18
+ """
19
+ if not event.character or not event.character.isalnum() or len(event.character) != 1:
20
+ # If the key is not a single alphanumeric character, let Textual's default
21
+ # event handling take care of it (e.g., for arrow keys, Enter).
22
+ return
23
+
24
+ pressed_char = event.character.lower()
25
+
26
+ # The character to search for is the one just pressed.
27
+ self.search_char = pressed_char
28
+
29
+ if not self.options:
30
+ # No options to search through.
31
+ event.stop() # Consume the event as it's handled
32
+ return
33
+
34
+ # Determine the starting point for the search.
35
+ # If nothing is highlighted, effectively start from index -1.
36
+ # The search will then begin at index 0 after adding 1.
37
+ current_highlight_idx = self.highlighted if self.highlighted is not None else -1
38
+
39
+ num_options = len(self.options)
40
+
41
+ # Start searching from the item *after* the currently highlighted one.
42
+ # If current_highlight_idx is -1, start_search_from_idx will be 0.
43
+ # Otherwise, it's (highlighted_index + 1).
44
+ start_search_from_idx = (current_highlight_idx + 1) % num_options
45
+
46
+ # Iterate through all options once, effectively wrapping around the list.
47
+ # The loop runs num_options times to check every item starting from start_search_from_idx.
48
+ for i in range(num_options):
49
+ check_idx = (start_search_from_idx + i) % num_options
50
+
51
+ selection_item = self.options[check_idx]
52
+ # Assuming selection_item.prompt is always a string or Text-compatible object
53
+ option_text = str(selection_item.prompt).lower()
54
+
55
+ if option_text.startswith(self.search_char):
56
+ # Match found. Highlight it and stop the event.
57
+ self.highlighted = check_idx
58
+ self.scroll_to_highlight()
59
+ event.stop()
60
+ return
61
+
62
+ # If the loop completes, no item starting with self.search_char was found
63
+ # (from the position after the current highlight, wrapping around).
64
+ # The current highlight remains unchanged.
65
+ event.stop() # Consume the event even if no match is found to prevent other actions.
66
+
@@ -0,0 +1,38 @@
1
+ from textual.widget import Widget
2
+ from textual.widgets import Input, Label
3
+ from textual.app import ComposeResult
4
+
5
+
6
+ class InputWithLabel(Widget):
7
+ """An input with a label."""
8
+
9
+ DEFAULT_CSS = """
10
+ InputWithLabel {
11
+ layout: horizontal;
12
+ height: auto;
13
+ }
14
+ InputWithLabel Label {
15
+ padding: 1;
16
+ width: 12;
17
+ text-align: right;
18
+ }
19
+ InputWithLabel Input {
20
+ width: 1fr;
21
+ }
22
+ """
23
+
24
+ def __init__(self, input_label: str, id: str | None = None) -> None:
25
+ self.input_label = input_label
26
+ super().__init__(id=id)
27
+
28
+ def compose(self) -> ComposeResult:
29
+ yield Label(self.input_label)
30
+ yield Input(placeholder="Enter comments here...")
31
+
32
+ @property
33
+ def value(self) -> str:
34
+ return self.query_one(Input).value
35
+
36
+ @value.setter
37
+ def value(self, value: str) -> None:
38
+ self.query_one(Input).value = value
@@ -0,0 +1,182 @@
1
+ from just_playback import Playback
2
+ from textual.app import ComposeResult
3
+ from textual.message import Message
4
+ from textual.widget import Widget
5
+ from textual.timer import Timer
6
+
7
+ from .playback_progress import PlaybackProgressWidget
8
+
9
+
10
+ class PlaybackEnded(Message):
11
+ """Posted when playback finishes (EOF)."""
12
+
13
+ pass
14
+
15
+
16
+ class PlaybackWidget(Widget):
17
+ DEFAULT_CSS = """
18
+ PlaybackWidget {
19
+ width: 100%;
20
+ height: 1;
21
+ }
22
+ """
23
+
24
+ def __init__(self, **kwargs) -> None:
25
+ super().__init__(**kwargs)
26
+ self._current_path: str | None = None
27
+ self._eof_check_timer: Timer | None = None
28
+
29
+ try:
30
+ self.player = Playback()
31
+ self._playback_progress = PlaybackProgressWidget(player=self.player)
32
+ # self.log.info("just_playback Player initialized in PlaybackWidget")
33
+ except Exception:
34
+ # self.log.error(f"Failed to initialize just_playback player in PlaybackWidget: {e}")
35
+ self.player = None
36
+
37
+ async def on_mount(self) -> None:
38
+ # Start a timer to check for end-of-file conditions since just_playback doesn't have property observation
39
+ self._eof_check_timer = self.set_interval(0.5, self._check_eof)
40
+
41
+ async def on_unmount(self) -> None:
42
+ await self._terminate_player()
43
+
44
+ def compose(self) -> ComposeResult:
45
+ yield self._playback_progress
46
+
47
+ def render(self):
48
+ return "hi"
49
+
50
+ def _check_eof(self) -> None:
51
+ """Check if playback has reached end of file and post PlaybackEnded message if so."""
52
+ if (
53
+ self.player
54
+ and self._current_path
55
+ and hasattr(self.player, 'duration')
56
+ and hasattr(self.player, 'curr_pos')
57
+ and self.player.duration
58
+ and self.player.curr_pos is not None
59
+ ):
60
+ # Check if we've reached the end (within 0.5 seconds tolerance)
61
+ if (
62
+ not self.player.active
63
+ and self.player.curr_pos >= (self.player.duration - 0.5)
64
+ ):
65
+ self.log.info(
66
+ f"just_playback: End of file - {self._current_path or 'Unknown file'}"
67
+ )
68
+ self.post_message(PlaybackEnded())
69
+
70
+ async def _terminate_player(self) -> None:
71
+ if self._eof_check_timer:
72
+ self._eof_check_timer.stop()
73
+ self._eof_check_timer = None
74
+
75
+ if self.player:
76
+ try:
77
+ self.player.stop()
78
+ self.log.info("just_playback player stopped from PlaybackWidget.")
79
+ except Exception as e:
80
+ self.log.error(f"Error stopping just_playback player in PlaybackWidget: {e}")
81
+ self.player = None
82
+ self._current_path = None
83
+
84
+ def load_track(self, new_path: str) -> None:
85
+ """Loads a track for playback. Does not start playing immediately."""
86
+ if not self.player:
87
+ self.log.warning("just_playback player not available. Cannot load track.")
88
+ return
89
+ if not new_path:
90
+ self.log.warning("No path provided to load_track.")
91
+ self.stop() # Clear current state if path is None
92
+ return
93
+
94
+ if self._current_path != new_path:
95
+ try:
96
+ self.player.load_file(new_path)
97
+ self._current_path = new_path
98
+ self.log.info(f"just_playback: Loaded track {new_path}")
99
+ except Exception as e:
100
+ self.log.error(f"just_playback: Error loading track {new_path}: {e}")
101
+ self._current_path = None
102
+ else:
103
+ self.log.info(f"just_playback: Track {new_path} already loaded.")
104
+
105
+ def play(self) -> None:
106
+ """Starts or resumes playback of the currently loaded track."""
107
+ if not self.player:
108
+ self.log.warning("just_playback player not available for play.")
109
+ return
110
+ if not self._current_path:
111
+ self.log.warning("No track loaded to play.")
112
+ return
113
+
114
+ try:
115
+ if self.player.paused:
116
+ self.player.resume()
117
+ self.log.info(
118
+ f"just_playback: Resumed play for {self._current_path} via play() method."
119
+ )
120
+ elif not self.player.playing:
121
+ self.player.play()
122
+ self.log.info(
123
+ f"just_playback: Started play for {self._current_path} via play() method."
124
+ )
125
+ else:
126
+ self.log.info(
127
+ f"just_playback: Already playing {self._current_path}. play() called."
128
+ )
129
+ except Exception as e:
130
+ self.log.error(f"just_playback: Error during play for {self._current_path}: {e}")
131
+
132
+ def pause(self) -> None:
133
+ """Pauses playback of the currently playing track."""
134
+ if not self.player:
135
+ self.log.warning("just_playback player not available for pause.")
136
+ return
137
+ if not self.is_player_active() or self.player.paused:
138
+ self.log.warning("No track playing or already paused. Cannot pause.")
139
+ return
140
+
141
+ try:
142
+ self.player.pause()
143
+ self.log.info(
144
+ f"just_playback: Paused playback for {self._current_path}. Player pause state: {self.player.paused}"
145
+ )
146
+ except Exception as e:
147
+ self.log.error(f"just_playback: Error during pause for {self._current_path}: {e}")
148
+
149
+ def play_pause(self) -> None:
150
+ """Toggles play/pause for the currently loaded track."""
151
+ if not self.player:
152
+ self.log.warning("just_playback player not available for play/pause.")
153
+ return
154
+ if not self._current_path:
155
+ self.log.warning("No track loaded to play/pause.")
156
+ return
157
+
158
+ if self.is_playing():
159
+ self.pause()
160
+ else:
161
+ self.play()
162
+
163
+ def stop(self) -> None:
164
+ if self.player and self._current_path:
165
+ self.log.info(
166
+ f"just_playback: Stopping playback for {self._current_path or 'unknown file'}"
167
+ )
168
+ self.player.stop()
169
+
170
+ self._current_path = None
171
+
172
+ def seek_relative(self, seconds: int) -> None:
173
+ if self.player and hasattr(self.player, 'curr_pos') and hasattr(self.player, 'duration'):
174
+ if self.player.duration and self.player.curr_pos is not None:
175
+ new_position = max(0, min(self.player.curr_pos + seconds, self.player.duration))
176
+ self.player.seek(new_position)
177
+
178
+ def is_playing(self) -> bool:
179
+ return bool(self.player and self.player.playing)
180
+
181
+ def is_player_active(self) -> bool:
182
+ return bool(self.player and self.player.active)
@@ -0,0 +1,100 @@
1
+ import math
2
+
3
+ from just_playback import Playback
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Horizontal
6
+ from textual.timer import Timer
7
+ from textual.widget import Widget
8
+ from textual.widgets import ProgressBar, Static
9
+
10
+ # TODO: update progress bar with timer
11
+
12
+
13
+ def format_seconds_to_time_str(seconds: float | None) -> str:
14
+ """Format seconds into MM:SS string."""
15
+ if seconds is None:
16
+ return "--:--"
17
+ if seconds < 0:
18
+ seconds = 0
19
+ total_seconds = int(math.floor(seconds))
20
+ mins = total_seconds // 60
21
+ secs = total_seconds % 60
22
+ return f"{mins:02d}:{secs:02d}"
23
+
24
+
25
+ class PlaybackProgressWidget(Widget):
26
+ DEFAULT_CSS = """
27
+ #playback_progress_bar_container {
28
+ layout: horizontal;
29
+ width: 100%;
30
+ height: 1;
31
+ }
32
+ #playback_progress {
33
+ width: 1fr;
34
+ height: 1;
35
+ }
36
+ #time_remaining_text {
37
+ width: auto;
38
+ min-width: 5; /* For MM:SS format */
39
+ height: 1;
40
+ padding: 0 0 0 1; /* Padding on the left of time */
41
+ text-align: right;
42
+ }
43
+ """
44
+
45
+ def __init__(self, player: Playback):
46
+ super().__init__()
47
+ self.player = player
48
+ self._playback_timer: Timer | None = None
49
+ self._progress_bar = ProgressBar(show_percentage=False, show_eta=False)
50
+ self._time_remaining_display = Static("", id="time_remaining_text")
51
+ # Note: just_playback doesn't have property observation, so we'll use polling
52
+
53
+ async def on_mount(self) -> None:
54
+ self._playback_timer = self.set_interval(1 / 2, self._update_progress_display)
55
+ self._progress_bar.progress = 0
56
+ self._progress_bar.total = 100
57
+
58
+ def compose(self) -> ComposeResult:
59
+ with Horizontal(id="playback_progress_bar_container"):
60
+ yield self._progress_bar
61
+ yield self._time_remaining_display
62
+
63
+ def _update_progress_display(self, *args, **kwargs) -> None:
64
+ if (
65
+ self.player
66
+ and hasattr(self.player, 'duration')
67
+ and hasattr(self.player, 'curr_pos')
68
+ and self.player.duration is not None
69
+ and self.player.duration > 0
70
+ ):
71
+ duration = self.player.duration
72
+ time_pos = self.player.curr_pos if self.player.curr_pos is not None else 0
73
+ time_remaining_seconds = duration - time_pos if time_pos is not None else None
74
+
75
+ self._progress_bar.total = duration
76
+ self._progress_bar.progress = time_pos
77
+ self._progress_bar.visible = True
78
+
79
+ self._time_remaining_display.update(
80
+ f"-{format_seconds_to_time_str(time_remaining_seconds)}"
81
+ )
82
+ self._time_remaining_display.visible = True
83
+
84
+ # Check if playback has ended
85
+ if (
86
+ not self.player.active
87
+ and time_pos is not None
88
+ and time_pos >= (duration - 0.5)
89
+ ):
90
+ self._progress_bar.progress = self._progress_bar.total
91
+ self._time_remaining_display.update("00:00")
92
+ if self._playback_timer:
93
+ self._playback_timer.pause()
94
+ else:
95
+ if self._progress_bar.visible or self._time_remaining_display.visible:
96
+ self._progress_bar.progress = 0
97
+ self._progress_bar.total = 100
98
+ self._progress_bar.visible = False
99
+ self._time_remaining_display.update("")
100
+ self._time_remaining_display.visible = False