from textual.containers import Vertical, VerticalScroll, Horizontal, VerticalGroup, HorizontalGroup from textual.widgets import Rule from textual.app import ComposeResult from textual import on, events from ui.widgets.chunk_types.audio import AudioChunk, Chunk from ui.widgets.play_head import PlayHead from project import ChunkType from song_player import SongPlayer class TimelineRow(Horizontal): DEFAULT_CSS = """ TimelineRow { background: $surface-lighten-1; height: 8; margin-bottom: 1; &.-muted { background: $error 25%; } &.-solo { background: $warning 25%; } } """ def __init__(self, project_channel): super().__init__() self.project_channel = project_channel class Timeline(Vertical): DEFAULT_CSS = """ Timeline { overflow-x: auto; #rows { hatch: "-" $surface-lighten-1; padding: 0 0; .beat-line { color: $surface-lighten-1; } .bar-line { color: $surface-lighten-2; } .beat-line, .bar-line { dock: left; margin: 0; } } PlayHead { layer: top; } } """ def __init__(self): super().__init__(id="timeline") self.calc_bar_offset() self.song_player = SongPlayer(self, self.app.project) def calc_bar_offset(self): self.bar_offset = self.app.project.bpm / 8 * (0.03333333333 / self.app.zoom_level) for row in self.query(TimelineRow): row.styles.width = self.bar_offset * self.app.project.song_length @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 await self.handle_zoom() @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() await self.handle_zoom() @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) async 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) if self.app.zoom_level >= 0.09 and bar_line.has_class("beat-line"): bar_line.display = False else: if self.app.zoom_level < 0.2: bar_line.display = True else: if bar_line.has_class("bar-line") and bar_line.index % 16 == 0: bar_line.display = True else: bar_line.display = False if self.song_player.paused and self.song_player.project: await self.song_player.update_visual_playhead() def compose(self) -> ComposeResult: with VerticalScroll(id="rows"): for channel in self.app.project.channels: with TimelineRow(channel) as row: row.styles.width = self.bar_offset * self.app.project.song_length for chunk in channel.chunks: 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) for i in range(1, self.app.project.song_length+1): bar = None if i % 4 == 0: bar = Rule.vertical(classes="bar-line", line_style="double") else: bar = Rule.vertical(classes="beat-line") bar.offset = (self.bar_offset * i, 0) bar.index = i yield bar yield PlayHead()