from glob import glob import sys from os import getenv from os.path import basename, splitext from functools import partial from nicegui import ui from starlette.formparsers import MultiPartParser import click from solo_tool import SoloTool from solo_tool.session_manager import loadSession, saveSession from solo_tool import handlers SESSION_DIR = getenv("SESSION_DIR", "/home/eddy/music/sessions") SONG_POOL = getenv("SONG_POOL", "/home/eddy/music/songs") def fileName(path: str) -> str: return basename(splitext(path)[0]) @ui.refreshable def keyPointList(st: SoloTool) -> None: with ui.list().props('separator'): if st.keyPoints is not None: for kp in st.keyPoints: ui.item(f"{kp:0.2}", on_click=handlers.keyPointAbsolute(st, kp)).props('clickable v-ripple').classes('text-lg') @ui.refreshable def songList(st: SoloTool, songDrawer) -> None: with ui.list().props('separator'): for i, path in enumerate(st.songs): ui.item(fileName(path), on_click=handlers.songAbsolute(st, i, lambda: songDrawer.hide())).props('clickable v-ripple') sessions = {} for f in glob(f"{SESSION_DIR}/*.json"): sessionName = fileName(f) songTool = loadSession(f, SONG_POOL) songTool.registerKeyPointListCallback(lambda new: keyPointList.refresh()) songTool.registerSongSelectionCallback(lambda new: keyPointList.refresh()) songTool.registerSongListCallback(lambda new: songList.refresh()) sessions[sessionName] = songTool @ui.page('/{sessionName}') def sessionPage(sessionName: str): if sessionName not in sessions: return fullscreen = ui.fullscreen() ui.dark_mode().enable() ui.colors(secondary='#ffc107') ui.page_title(sessionName) # Improved performance with large file uploads MultiPartParser.max_file_size = 1024 * 1024 * 100 # 100 MB st = sessions[sessionName] # Upload song dialog def handleFileUpload(e): from shutil import copyfileobj newSong = f"{SONG_POOL}/{e.name}" with open(newSong, "wb") as f: copyfileobj(e.content, f) st.addSong(newSong) with ui.dialog() as uploadSongDialog: ui.upload(label="Upload songs", auto_upload=True, on_upload=handleFileUpload).classes('max-w-full') # Header with ui.header().classes('items-center justify-between'): with ui.row().classes('items-center justify-start'): ui.button(icon='menu', on_click=lambda: songDrawer.toggle()).props('flat dense round color=white') ui.label().bind_text_from(st, 'song', lambda index: fileName(st.songs[index]) if index is not None else "Select a song").classes('text-lg') with ui.row().classes('items-center justify-start'): ui.button(icon='home', on_click=lambda: ui.navigate.to("/")).props('flat dense round color=white') def save(): saveSession(st, f"{SESSION_DIR}/{sessionName}.json") ui.button(icon='save', on_click=save).props('flat dense round color=white') ui.button(icon='fullscreen', on_click=fullscreen.toggle).props('flat dense round color=white') # Key points list with ui.right_drawer(top_corner=True, bottom_corner=True).props('width=120 behavior=desktop'): ui.label("Key Points").classes('text-lg') keyPointList(st) def addKeyPoint() -> None: st.keyPoints += [st.keyPoint] ui.button(icon='add', on_click=addKeyPoint).props('flat round dense color=white') # Song list with ui.left_drawer(bottom_corner=True).props('overlay breakpoint=8000') as songDrawer: songList(st, songDrawer) ui.button(icon='add', on_click=uploadSongDialog.open).props('flat round dense color=white') # Playback position def setPosition(e) -> None: st.position = e.args ui.slider(min=0, max=1.0, step=0.001) \ .bind_value_from(st, 'position') \ .on('change', setPosition) \ .props('thumb-size=0px track-size=16px') # Key point position ui.slider(min=0, max=1.0, step=0.001).bind_value(st, 'keyPoint').props('selection-color=transparent color=secondary') # Play control with ui.button_group().classes('w-full').style('height: 80px'): buttonSize = "20px" ui.button(icon='skip_previous', on_click=handlers.restartOrPreviousSong(st, 0.01)).props(f"size={buttonSize}").style('flex: 1') ui.button(color='positive', on_click=handlers.playPause(st)).bind_icon_from(st, "playing", lambda playing: "pause" if playing else "play_arrow").props(f"size={buttonSize}").style('flex: 1') ui.button(icon='vertical_align_bottom', on_click=handlers.positionToKeyPoint(st), color='negative').props(f"size={buttonSize}").style('flex: 2') ui.button(icon='undo', on_click=st.jump, color='secondary').props(f"size={buttonSize}").style('flex: 2') ui.button(icon='skip_next', on_click=handlers.songRelative(st, 1)).props(f"size={buttonSize}").style('flex: 1') # Playback rate with ui.row().classes('w-full justify-between no-wrap items-center'): markerLabels = ",".join([f"{v}:'{v}x'" for v in [0.4, 0.6, 0.8, 1.0, 1.2]]) ui.slider(min=0.4, max=1.2, step=0.05).bind_value(st, 'rate').props(f'snap markers :marker-labels="{{{markerLabels}}}"').classes('q-px-md') # Volume with ui.row().classes('w-full justify-between no-wrap items-center'): volumeLabels = ",".join([f"{v}:'{int(v*100)}%'" for v in [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]]) ui.slider(min=0, max=1.25, step=0.01).bind_value(st, 'volume').props(f':marker-labels="{{{volumeLabels}}}"').classes('q-px-md') @ui.page('/') def landingPage(): ui.dark_mode().enable() ui.page_title("Solo Tool") # Header with ui.header().classes('items-center'): ui.label("Choose a session").classes('text-lg') for name, soloTool in sessions.items(): ui.button(name, on_click=partial(ui.navigate.to, f"/{name}")) def start(port, refresh, reload): try: ui.run(reload=reload, binding_refresh_interval=refresh, port=port) except KeyboardInterrupt: pass @click.command() @click.option("--port", type=int, default=8080, help="Port on which to bind.") @click.option("--refresh", type=float, default=0.5, help="Refresh interval in seconds.") @click.option("--reload/--no-reload", default=True, help="Auto-reload when files change.") def main(port, refresh, reload): start(port, refresh, reload) # Hardcoded dev settings if __name__ in {"__main__", "__mp_main__"}: start(8080, 0.5, True)