aboutsummaryrefslogtreecommitdiffstats
path: root/web-project
diff options
context:
space:
mode:
Diffstat (limited to 'web-project')
-rw-r--r--web-project/pyproject.toml4
-rw-r--r--web-project/src/solo_tool_web.py179
2 files changed, 129 insertions, 54 deletions
diff --git a/web-project/pyproject.toml b/web-project/pyproject.toml
index 440812e..844de72 100644
--- a/web-project/pyproject.toml
+++ b/web-project/pyproject.toml
@@ -8,10 +8,10 @@ authors = [
{ name = "Eddy Pedroni", email = "epedroni@pm.me" },
]
description = "A NiceGUI-based web frontend for the solo_tool library"
-requires-python = ">=3.12"
+requires-python = ">=3.13"
dependencies = [
"nicegui==2.11.1",
- "solo_tool"
+ "solo_tool>=2.0"
]
dynamic = ["version"]
diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py
index f854e1a..54b18de 100644
--- a/web-project/src/solo_tool_web.py
+++ b/web-project/src/solo_tool_web.py
@@ -1,56 +1,131 @@
+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
from solo_tool import SoloTool
+from solo_tool.session_manager import loadSession, saveSession
+from solo_tool import handlers
-st = SoloTool()
-st.loadSession("/home/eddy/music/solos/practice.json")
-
-def _createSeekHandler(delta):
- def f():
- newPosition = st.getPlaybackPosition() + delta
- newPosition = min(1.0, max(0.0, newPosition))
- st.setPlaybackPosition(newPosition)
- return f
-
-def main():
- with ui.splitter(value=30) as splitter:
- splitter.style('width: 100%; height: 100%;')
- with splitter.before:
- with ui.list().props('dense separator'):
- for song in st.getSongs():
- ui.item(song)
- with splitter.after:
- ui.slider(min=0, max=1.2, value=1.0, step=0.01, on_change=lambda e: st.setPlaybackVolume(e.value))
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('-5%', on_click=lambda: st.setPlaybackRate(max(0.5, st.getPlaybackRate() - 0.05)))
- ui.slider(min=0.5, max=1.2, step=0.05, value=st.getPlaybackRate(), on_change=lambda e: st.setPlaybackRate(e.value))
- ui.button('+5%', on_click=lambda: st.setPlaybackRate(min(1.2, st.getPlaybackRate() + 0.05)))
-
- ui.slider(min=0, max=100, value=0)
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Prev', on_click=st.previousSong)
- ui.button('-25%', on_click=_createSeekHandler(-0.25))
- ui.button('-5%', on_click=_createSeekHandler(-0.05))
- ui.button('-1%', on_click=_createSeekHandler(-0.01))
- ui.button('+1%', on_click=_createSeekHandler(0.01))
- ui.button('+5%', on_click=_createSeekHandler(0.05))
- ui.button('+25%', on_click=_createSeekHandler(0.25))
- ui.button('Next', on_click=st.nextSong)
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Set A')
- ui.button('Set B')
- ui.button('Previous AB')
- ui.button('Next AB')
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Toggle AB', on_click=lambda: st.setAbLimitEnable(not st.isAbLimitEnabled()))
- ui.button('Stop', on_click=st.stop)
- ui.button('Play', on_click=st.play)
- ui.button('Jump to A', on_click=st.jumpToA)
- ui.run()
-
-if __name__ in {'__main__', '__mp_main__'}:
- main()
+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}"))
+
+ui.run(binding_refresh_interval=0.5, port=int(sys.argv[1]))