2026-01-14 10:31:26 +11:00
|
|
|
from textual.containers import Vertical, VerticalScroll, Horizontal, VerticalGroup, HorizontalGroup
|
|
|
|
|
from textual.widgets import Rule
|
2026-01-13 16:06:57 +11:00
|
|
|
from textual.app import ComposeResult
|
2026-01-14 12:20:53 +11:00
|
|
|
from textual import on, events
|
2026-01-13 16:06:57 +11:00
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
from ui.widgets.chunk_types.audio import AudioChunk, Chunk
|
|
|
|
|
from ui.widgets.play_head import PlayHead
|
|
|
|
|
from project import ChunkType
|
2026-01-13 20:06:28 +11:00
|
|
|
|
2026-01-15 07:58:09 +11:00
|
|
|
from song_player import SongPlayer
|
|
|
|
|
|
2026-01-13 16:06:57 +11:00
|
|
|
|
|
|
|
|
class TimelineRow(Horizontal):
|
|
|
|
|
DEFAULT_CSS = """
|
|
|
|
|
TimelineRow {
|
|
|
|
|
background: $surface-lighten-1;
|
2026-01-14 15:51:34 +11:00
|
|
|
height: 8;
|
2026-01-13 16:06:57 +11:00
|
|
|
margin-bottom: 1;
|
2026-01-15 05:51:52 +11:00
|
|
|
|
|
|
|
|
&.-muted {
|
|
|
|
|
background: $error 25%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.-solo {
|
|
|
|
|
background: $warning 25%;
|
|
|
|
|
}
|
2026-01-13 16:06:57 +11:00
|
|
|
}
|
|
|
|
|
"""
|
2026-01-15 16:24:43 +11:00
|
|
|
|
|
|
|
|
def __init__(self, project_channel):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.project_channel = project_channel
|
2026-01-13 16:06:57 +11:00
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
class Timeline(Vertical):
|
2026-01-13 16:06:57 +11:00
|
|
|
DEFAULT_CSS = """
|
|
|
|
|
Timeline {
|
2026-01-15 05:51:52 +11:00
|
|
|
overflow-x: auto;
|
|
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
#rows {
|
|
|
|
|
hatch: "-" $surface-lighten-1;
|
2026-01-14 15:51:34 +11:00
|
|
|
padding: 0 0;
|
2026-01-15 05:51:52 +11:00
|
|
|
|
|
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
|
|
|
|
|
.beat-line {
|
|
|
|
|
color: $surface-lighten-1;
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bar-line {
|
|
|
|
|
color: $surface-lighten-2;
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.beat-line, .bar-line {
|
|
|
|
|
dock: left;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PlayHead {
|
|
|
|
|
layer: top;
|
|
|
|
|
}
|
2026-01-13 16:06:57 +11:00
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
def __init__(self):
|
2026-01-14 12:20:53 +11:00
|
|
|
super().__init__(id="timeline")
|
|
|
|
|
|
|
|
|
|
self.calc_bar_offset()
|
2026-01-15 07:58:09 +11:00
|
|
|
self.song_player = SongPlayer(self, self.app.project)
|
2026-01-14 12:20:53 +11:00
|
|
|
|
|
|
|
|
def calc_bar_offset(self):
|
|
|
|
|
self.bar_offset = self.app.project.bpm / 8 * (0.03333333333 / self.app.zoom_level)
|
|
|
|
|
|
2026-01-15 05:51:52 +11:00
|
|
|
for row in self.query(TimelineRow):
|
|
|
|
|
row.styles.width = self.bar_offset * self.app.project.song_length
|
|
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
@on(events.MouseScrollDown)
|
|
|
|
|
async def mouse_scroll_down(self, event: events.MouseScrollDown):
|
|
|
|
|
self.app.zoom_level += (self.app.scroll_sensitivity_x / 200)
|
|
|
|
|
self.calc_bar_offset()
|
|
|
|
|
self.app.last_zoom_level = self.app.zoom_level
|
|
|
|
|
self.handle_zoom()
|
2026-01-14 10:31:26 +11:00
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
@on(events.MouseScrollUp)
|
|
|
|
|
async def mouse_scroll_up(self, event: events.MouseScrollUp):
|
|
|
|
|
self.app.zoom_level = max(self.app.zoom_level - self.app.scroll_sensitivity_x/200, 0.001)
|
|
|
|
|
|
|
|
|
|
if self.app.zoom_level != self.app.last_zoom_level:
|
|
|
|
|
self.app.last_zoom_level = self.app.zoom_level
|
|
|
|
|
self.calc_bar_offset()
|
|
|
|
|
self.handle_zoom()
|
|
|
|
|
|
2026-01-15 07:58:09 +11:00
|
|
|
@on(events.MouseDown)
|
|
|
|
|
async def mouse_down(self, event: events.MouseDown):
|
|
|
|
|
if event.button != 2: return
|
|
|
|
|
|
|
|
|
|
# get bar number
|
|
|
|
|
bar_number = event.x / self.bar_offset
|
|
|
|
|
|
|
|
|
|
# convert bar number to seconds
|
|
|
|
|
seconds = bar_number / self.app.project.seconds_per_bar
|
|
|
|
|
|
|
|
|
|
# seek to number of seconds
|
|
|
|
|
self.song_player.seek(seconds)
|
|
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
def handle_zoom(self):
|
|
|
|
|
for chunk in self.query(Chunk):
|
|
|
|
|
chunk.calculate_size()
|
|
|
|
|
chunk.update_offset(self)
|
|
|
|
|
|
|
|
|
|
for bar_line in self.query(Rule):
|
|
|
|
|
if not isinstance(bar_line, PlayHead):
|
|
|
|
|
bar_line.offset = (self.bar_offset * bar_line.index, 0)
|
2026-01-14 13:04:38 +11:00
|
|
|
|
|
|
|
|
if self.app.zoom_level >= 0.09 and bar_line.has_class("beat-line"):
|
|
|
|
|
bar_line.display = False
|
|
|
|
|
else:
|
|
|
|
|
bar_line.display = True
|
2026-01-15 07:58:09 +11:00
|
|
|
|
|
|
|
|
if self.song_player.paused and self.song_player.project:
|
|
|
|
|
self.run_worker(self.song_player.update_visual_playhead())
|
2026-01-14 10:31:26 +11:00
|
|
|
|
2026-01-13 16:06:57 +11:00
|
|
|
def compose(self) -> ComposeResult:
|
2026-01-14 10:31:26 +11:00
|
|
|
|
|
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
with VerticalScroll(id="rows"):
|
2026-01-14 12:20:53 +11:00
|
|
|
for channel in self.app.project.channels:
|
2026-01-15 16:24:43 +11:00
|
|
|
with TimelineRow(channel) as row:
|
2026-01-15 05:51:52 +11:00
|
|
|
|
2026-01-15 11:11:07 +11:00
|
|
|
row.styles.width = self.bar_offset * self.app.project.song_length
|
|
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
for chunk in channel.chunks:
|
2026-01-15 11:11:07 +11:00
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
if chunk.chunk_type == ChunkType.CHUNK:
|
|
|
|
|
yield Chunk(chunk_name=chunk.name, bar_pos=chunk.position)
|
|
|
|
|
elif chunk.chunk_type == ChunkType.AUDIO:
|
|
|
|
|
yield AudioChunk(chunk.audio_data, chunk.sample_rate, chunk.name, chunk.position)
|
2026-01-14 13:04:38 +11:00
|
|
|
|
2026-01-15 07:58:09 +11:00
|
|
|
for i in range(1, self.app.project.song_length+1):
|
2026-01-14 10:31:26 +11:00
|
|
|
bar = None
|
2026-01-14 12:20:53 +11:00
|
|
|
if i % 4 == 0:
|
2026-01-14 10:31:26 +11:00
|
|
|
bar = Rule.vertical(classes="bar-line", line_style="double")
|
|
|
|
|
else:
|
|
|
|
|
bar = Rule.vertical(classes="beat-line")
|
|
|
|
|
|
|
|
|
|
bar.offset = (self.bar_offset * i, 0)
|
2026-01-14 12:20:53 +11:00
|
|
|
bar.index = i
|
2026-01-14 10:31:26 +11:00
|
|
|
|
|
|
|
|
yield bar
|
|
|
|
|
|
2026-01-14 12:20:53 +11:00
|
|
|
|
2026-01-14 10:31:26 +11:00
|
|
|
|
2026-01-14 13:04:38 +11:00
|
|
|
yield PlayHead()
|