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.
- beets_quicktag-0.1.0.dist-info/METADATA +54 -0
- beets_quicktag-0.1.0.dist-info/RECORD +10 -0
- beets_quicktag-0.1.0.dist-info/WHEEL +5 -0
- beets_quicktag-0.1.0.dist-info/top_level.txt +1 -0
- beetsplug/quicktag/__init__.py +76 -0
- beetsplug/quicktag/app.py +439 -0
- beetsplug/quicktag/widgets/custom_selection_list.py +66 -0
- beetsplug/quicktag/widgets/input_with_label.py +38 -0
- beetsplug/quicktag/widgets/playback.py +182 -0
- beetsplug/quicktag/widgets/playback_progress.py +100 -0
|
@@ -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 @@
|
|
|
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
|