changeset 2439:06da5db2fafe

rewrite ascoltami to use the graph for more playback data
author drewp@bigasterisk.com
date Thu, 30 May 2024 01:08:07 -0700
parents f2b3cfcc23d3
children d1f86109e3cc
files src/light9/ascoltami/main.py src/light9/ascoltami/player.py src/light9/ascoltami/webapp.py web/Light9CursorCanvas.ts web/ascoltami/Light9AscoltamiTimeline.ts web/ascoltami/Light9AscoltamiUi.ts web/ascoltami/Light9SongListing.ts web/ascoltami/PlayerState.ts web/ascoltami/index.html web/light9-timeline-audio.ts web/show_specific.ts web/timeline/viewstate.ts
diffstat 12 files changed, 423 insertions(+), 404 deletions(-) [+]
line wrap: on
line diff
--- a/src/light9/ascoltami/main.py	Wed May 29 17:28:25 2024 -0700
+++ b/src/light9/ascoltami/main.py	Thu May 30 01:08:07 2024 -0700
@@ -1,8 +1,6 @@
-#!bin/python
 import logging
 from typing import cast
 
-import louie
 from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 from rdflib import ConjunctiveGraph, Literal, URIRef
 from rdflib.graph import _ContextType
@@ -19,6 +17,7 @@
 from light9.newtypes import decimalLiteral
 from light9.run_local import log
 
+
 class Ascoltami:
 
     def __init__(self, graph: SyncedGraph, show: URIRef):
@@ -31,10 +30,7 @@
         self.playlist = Playlist(graph, show)
 
     def onStateChange(self, s: PlayerState):
-        log.info('louie send')
-        louie.send(webapp.OnStateChange, s=s)
         g = self.stateAsGraph(s)
-
         self.graph.patchSubgraph(newGraph=g, context=self.ctx)
         self.playerState = s
 
@@ -58,19 +54,6 @@
     def getPlayerState(self) -> PlayerState:
         return self.playerState
 
-    def onEOS(self, song):
-        self.player.pause()
-        self.player.seek(0)
-
-        thisSongUri = webapp.songUri(self.graph, URIRef(song))
-
-        try:
-            nextSong = self.playlist.nextSong(thisSongUri)
-        except NoSuchSong:  # we're at the end of the playlist
-            return
-
-        self.player.setSong(webapp.songLocation(self.graph, nextSong), play=False)
-
 
 def main():
     logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
@@ -84,9 +67,7 @@
         debug=True,
         routes=[
             Route("/config", h.get_config),
-            Route("/time", h.get_time, methods=["GET"]),
             Route("/time", h.post_time, methods=["POST"]),
-            Route("/time/stream", h.timeStream),
             Route("/song", h.post_song, methods=["POST"]),
             Route("/songs", h.get_songs),
             Route("/seekPlayOrPause", h.post_seekPlayOrPause),
--- a/src/light9/ascoltami/player.py	Wed May 29 17:28:25 2024 -0700
+++ b/src/light9/ascoltami/player.py	Thu May 30 01:08:07 2024 -0700
@@ -30,6 +30,8 @@
     pausedSongTime: float | None = None  # if we're paused, this has the song time
     endOfSong: bool = False  # True if we're in the stopped state due to EOS
 
+def roundTime(secs: float) -> float:
+    return round(secs, 2)
 
 class Player:
 
@@ -69,10 +71,10 @@
                 playing = self.isPlaying() and not eos
                 ps = PlayerState(
                     song=self._getSongFileUri(),
-                    duration=round(self.duration(), 2),
-                    wallStartTime=round(now - t, 2) if playing else None,
+                    duration=roundTime(self.duration()),
+                    wallStartTime=roundTime(now - t) if playing else None,
                     playing=playing,
-                    pausedSongTime=None if playing else t,
+                    pausedSongTime=None if playing else roundTime(t),
                     endOfSong=eos,
                 )
 
--- a/src/light9/ascoltami/webapp.py	Wed May 29 17:28:25 2024 -0700
+++ b/src/light9/ascoltami/webapp.py	Thu May 30 01:08:07 2024 -0700
@@ -1,20 +1,15 @@
 """
 this module shouldn't be necessary for playback to work
 """
-import asyncio
-import json
 import logging
 import socket
 import subprocess
-from dataclasses import dataclass, field
-import time
+from dataclasses import dataclass
 from typing import Callable
 from typing import Literal as Lit
 
-import louie
 from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 from rdflib import URIRef
-from sse_starlette.sse import EventSourceResponse
 from starlette.requests import Request
 from starlette.responses import JSONResponse, PlainTextResponse
 
@@ -25,10 +20,6 @@
 log = logging.getLogger("web")
 
 
-class OnStateChange:
-    pass
-
-
 @dataclass
 class PlayerState2(PlayerState):
     song2: URIRef | None = None
@@ -55,26 +46,6 @@
                     'post': 0
                 }))
 
-    def currentState(self, player: Player, playlist: Playlist) -> PlayerState2:
-        if player.isAutostopped():
-            nextAction = 'finish'
-        elif player.isPlaying():
-            nextAction = 'disabled'
-        else:
-            nextAction = 'play'
-
-        ps = self.getPlayerState()
-        return PlayerState2(
-            song2=playlist.songUri((ps.song)) if ps.song else None,
-            duration=ps.duration,
-            playing=ps.playing,
-            # state= player.states(),
-            nextAction=nextAction,
-        )
-
-    async def get_time(self, request: Request) -> JSONResponse:
-        return JSONResponse({'t': self.player.currentTime()})
-
     async def post_time(self, request: Request) -> PlainTextResponse:
         """
         post a json object with {pause: true} or {resume: true} if you
@@ -90,40 +61,6 @@
             self.player.seek(params['t'])
         return PlainTextResponse("ok")
 
-    async def timeStream(self, request: Request):
-
-        async def event_generator():
-            last_sent = None
-            last_sent_time = 0.0
-
-            def onStateChange(s: PlayerState2):
-                log.info('ws heanndlerr gets state')
-
-            louie.connect(onStateChange, OnStateChange, weak=False)
-
-            try:
-                while True:
-                    now = time.time()
-                    msg = self.currentState(self.player, self.playlist)
-                    if msg != last_sent or now > last_sent_time + 2:
-                        event_data = json.dumps({
-                            # obsolete- watch the graph for these
-                            'duration': msg.duration,
-                            'playing': msg.playing,
-                            'song': self.playlist.songUri(msg.song) if msg.song else None,
-                            'state': {},
-                        })
-                        yield event_data
-                        last_sent = msg
-                        last_sent_time = now
-
-                    await asyncio.sleep(0.1)
-            finally:
-                log.info(f'bye listnner {event_generator}')
-                louie.disconnect(onStateChange, OnStateChange, weak=False)
-
-        return EventSourceResponse(event_generator())
-
     async def get_songs(self, request: Request) -> JSONResponse:
 
         songs_data = [
@@ -137,7 +74,7 @@
         return JSONResponse({"songs": songs_data})
 
     async def post_song(self, request: Request) -> PlainTextResponse:
-        """post a uri of song to switch to (and start playing)"""
+        """post a uri of song to switch to (and seek to 0)"""
 
         song_uri = URIRef((await request.body()).decode('utf8'))
         self.player.setSong(self.playlist.fileUri(song_uri))
--- a/web/Light9CursorCanvas.ts	Wed May 29 17:28:25 2024 -0700
+++ b/web/Light9CursorCanvas.ts	Thu May 30 01:08:07 2024 -0700
@@ -3,6 +3,7 @@
 import { customElement, property } from "lit/decorators.js";
 import Sylvester from "sylvester";
 import { line } from "./drawing";
+import { Vector } from "./lib/sylvester";
 
 const $V = Sylvester.Vector.create;
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/Light9AscoltamiTimeline.ts	Thu May 30 01:08:07 2024 -0700
@@ -0,0 +1,114 @@
+import { css, html, LitElement, PropertyValueMap } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import Sylvester from "sylvester";
+import { Zoom } from "../light9-timeline-audio";
+import { PlainViewState } from "../Light9CursorCanvas";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { show } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+import { PlayerState } from "./PlayerState";
+export { Light9TimelineAudio } from "../light9-timeline-audio";
+export { Light9CursorCanvas } from "../Light9CursorCanvas";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+
+const $V = Sylvester.Vector.create;
+
+async function postJson(url: string, jsBody: Object) {
+  return fetch(url, {
+    method: "POST",
+    headers: { "Content-Type": "applcation/json" },
+    body: JSON.stringify(jsBody),
+  });
+}
+
+@customElement("light9-ascoltami-timeline")
+export class Light9AscoltamiTimeline extends LitElement {
+  static styles = [
+    css`
+      .timeRow {
+        margin: 14px;
+        position: relative;
+      }
+      #overview {
+        height: 60px;
+      }
+      #zoomed {
+        margin-top: 40px;
+        height: 80px;
+      }
+      #cursor {
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      }
+    `,
+  ];
+  graph!: SyncedGraph;
+  @property() playerState: PlayerState = {
+    duration: null,
+    endOfSong: null,
+    pausedSongTime: null,
+    playing: null,
+    song: null,
+    wallStartTime: null,
+  };
+  @property() playerTime: number = 0;
+  @state() zoom: Zoom;
+  @state() overviewZoom: Zoom;
+  @state() viewState: PlainViewState | null = null;
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+    });
+    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
+  }
+  protected willUpdate(_changedProperties: PropertyValueMap<this>): void {
+    super.willUpdate(_changedProperties);
+    if ((_changedProperties.has("playerState") || _changedProperties.has("playerTime")) && this.playerState !== null) {
+      const duration = this.playerState.duration;
+      const t = this.playerTime;
+      if (duration !== null) {
+        const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
+        if (timeRow != null) {
+          this.updateZooms(duration, t, timeRow);
+        }
+      }
+    }
+  }
+
+  updateZooms(duration: number, t: number, timeRow: HTMLDivElement) {
+    this.overviewZoom = { duration: duration, t1: 0, t2: duration };
+    const t1 = t - 2;
+    const t2 = t + 20;
+    this.zoom = { duration: duration, t1, t2 };
+    const w = timeRow.offsetWidth;
+    this.viewState = {
+      zoomSpec: { t1: () => t1, t2: () => t2 },
+      cursor: { t: () => t },
+      audioY: () => 0,
+      audioH: () => 60,
+      zoomedTimeY: () => 60,
+      zoomedTimeH: () => 40,
+      fullZoomX: (sec: number) => (sec / duration) * w,
+      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
+      mouse: { pos: () => $V([0, 0]) },
+    };
+  }
+
+  render() {
+    const song = this.playerState?.song;
+    if (!song) return html`(spectrogram)`;
+    return html`
+      <div class="timeRow">
+        <div id="timeSlider"></div>
+        <light9-timeline-audio id="overview" .show=${show} .song=${song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
+        <light9-timeline-audio id="zoomed" .show=${show} .song=${song} .zoom=${this.zoom}></light9-timeline-audio>
+        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
+      </div>
+    `;
+  }
+}
--- a/web/ascoltami/Light9AscoltamiUi.ts	Wed May 29 17:28:25 2024 -0700
+++ b/web/ascoltami/Light9AscoltamiUi.ts	Thu May 30 01:08:07 2024 -0700
@@ -1,27 +1,19 @@
 import debug from "debug";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, PropertyValues } from "lit";
 import { customElement, property } from "lit/decorators.js";
-import { classMap } from "lit/directives/class-map.js";
 import { NamedNode } from "n3";
-import Sylvester from "sylvester";
-import { Zoom } from "../light9-timeline-audio";
 import { PlainViewState } from "../Light9CursorCanvas";
 import { getTopGraph } from "../RdfdbSyncedGraph";
 import { SyncedGraph } from "../SyncedGraph";
-import { TimingUpdate } from "./main";
-import { showRoot } from "../show_specific";
-export { Light9TimelineAudio } from "../light9-timeline-audio";
-export { Light9CursorCanvas } from "../Light9CursorCanvas";
+import { PlayerState } from "./PlayerState";
 export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
 export { ResourceDisplay } from "../ResourceDisplay";
-const $V = Sylvester.Vector.create;
+export { Light9AscoltamiTimeline } from "./Light9AscoltamiTimeline";
+export { Light9SongListing } from "./Light9SongListing";
 
 debug.enable("*");
 const log = debug("asco");
 
-function byId(id: string): HTMLElement {
-  return document.getElementById(id)!;
-}
 async function postJson(url: string, jsBody: Object) {
   return fetch(url, {
     method: "POST",
@@ -29,157 +21,154 @@
     body: JSON.stringify(jsBody),
   });
 }
+
 @customElement("light9-ascoltami-ui")
 export class Light9AscoltamiUi extends LitElement {
   graph!: SyncedGraph;
-  times!: { intro: number; post: number };
-  @property() nextText: string = "";
-  @property() isPlaying: boolean = false;
   @property() show: NamedNode | null = null;
   @property() song: NamedNode | null = null;
   @property() selectedSong: NamedNode | null = null;
-  @property() currentDuration: number = 0;
-  @property() zoom: Zoom;
-  @property() overviewZoom: Zoom;
   @property() viewState: PlainViewState | null = null;
+  @property() host: any;
+  @property() playerState: PlayerState = { duration: null, endOfSong: null, pausedSongTime: null, playing: null, song: null, wallStartTime: null };
+  @property() playerTime: number = 0;
   static styles = [
     css`
       :host {
         display: flex;
         flex-direction: column;
       }
-      .timeRow {
-        margin: 14px;
-        position: relative;
-      }
-      #overview {
-        height: 60px;
-      }
-      #zoomed {
-        margin-top: 40px;
-        height: 80px;
-      }
-      #cursor {
-        position: absolute;
-        left: 0;
-        top: 0;
-        width: 100%;
-        height: 100%;
-      }
-      #grow {
-        flex: 1 1 auto;
-        display: flex;
-      }
-      #grow > span {
-        display: flex;
-        position: relative;
-        width: 50%;
+
+      .keyCap {
+        color: #ccc;
+        background: #525252;
+        display: inline-block;
+        border: 1px outset #b3b3b3;
+        padding: 2px 3px;
+        margin: 3px 0;
+        margin-left: 0.4em;
+        font-size: 16px;
+        box-shadow: 0.9px 0.9px 0px 2px #565656;
+        border-radius: 2px;
       }
-      #playSelected {
-        height: 100px;
-      }
-      #songList {
-        overflow-y: scroll;
-        position: absolute;
-        left: 0;
-        top: 0;
-        right: 0;
-        bottom: 0;
+
+      button {
+        min-height: 48pt;
+        min-width: 65pt;
       }
-      #songList .row {
-        width: 60%;
-        min-height: 40px;
-        text-align: left;
-        position: relative;
-      }
-      #songList .row:nth-child(even) {
-        background: #333;
+
+      #mainRow {
+        display: flex;
+        flex-direction: row;
       }
-      #songList .row:nth-child(odd) {
-        background: #444;
+
+      light9-song-listing {
+        flex-grow: 1;
       }
-      #songList button {
-        min-height: 40px;
-        margin-bottom: 10px;
-      }
-      #songList .row.playing {
-        box-shadow: 0 0 30px red;
-        background-color: #de5050;
+
+      th {
+        text-align: right;
       }
     `,
   ];
-  render() {
-    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.updatePlayState.bind(this), "playstate-ui");
+    });
+    setInterval(this.updateT.bind(this), 100);
+  }
+
+  protected async firstUpdated(_changedProperties: PropertyValues<this>) {
+    this.bindKeys();
+    const config = await (await fetch("/service/ascoltami/config")).json();
+    document.title = document.title.replace("{{host}}", config.host);
+    this.host = config.host;
+  }
 
-      <link rel="stylesheet" href="../style.css" />
+  updatePlayState() {
+    const U = this.graph.U();
+    const asco = U(":ascoltami");
+    this.playerState = {
+      duration: this.graph.optionalFloatValue(asco, U(":duration")),
+      endOfSong: this.graph.optionalBooleanValue(asco, U(":endOfSong")),
+      pausedSongTime: this.graph.optionalFloatValue(asco, U(":pausedSongTime")),
+      wallStartTime: this.graph.optionalFloatValue(asco, U(":wallStartTime")),
+      playing: this.graph.optionalBooleanValue(asco, U(":playing")),
+      song: this.graph.optionalUriValue(asco, U(":song")),
+    };
+    this.updateT();
+  }
 
-      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
+  updateT() {
+    if (this.playerState.wallStartTime !== null) {
+      this.playerTime = Date.now() / 1000 - this.playerState.wallStartTime;
+    } else if (this.playerState.pausedSongTime !== null) {
+      this.playerTime = this.playerState.pausedSongTime;
+    } else {
+      this.playerTime = 0;
+    }
+  }
+
+  render() {
+    return html`
+      <h1>ascoltami on ${this.host}</h1>
 
-      <div id="grow">
-        <span>
-          <div id="songList">
-            <table>
-              ${this.songList.map(
-                (song) => html`
-                  <tr
-                    class="row ${classMap({
-                      playing: !!(this.song && song.equals(this.song)),
-                    })}"
-                  >
-                    <td><resource-display .uri=${song} noclick></resource-display></td>
-                    <td>
-                      <button @click=${this.onSelectSong.bind(this, song)}>
-                        <span>Select</span>
-                      </button>
-                    </td>
-                  </tr>
-                `
-              )}
-            </table>
-          </div> </span
-        ><span>
-          <div id="right">
-            <div>
-              Selected:
-              <resource-display .uri=${this.selectedSong}></resource-display>
-            </div>
-            <div>
-              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
-            </div>
+      <span><rdfdb-synced-graph></rdfdb-synced-graph></span>
+      <div id="mainRow">
+        <light9-song-listing
+          @selectsong=${(ev: any) => {
+            this.selectedSong = ev.detail.song;
+          }}
+          .selectedSong=${this.selectedSong}
+        ></light9-song-listing>
+        <div>
+          <light9-ascoltami-timeline .playerState=${this.playerState} .playerTime=${this.playerTime}></light9-ascoltami-timeline>
+          <div>
+            <button ?disabled=${this.selectedSong == null} @click=${this.onLoadSelected}>Change to and play selected <span class="keyCap">l</span></button>
+            <button ?disabled=${false} @click=${this.onCmdZero}>Seek to zero <span class="keyCap">z</span></button>
+            <button ?disabled=${this.playerState.playing || this.playerState.song == null} @click=${this.onCmdPlay}>Play <span class="keyCap">p</span></button>
+            <button ?disabled=${!this.playerState.playing} @click=${this.onCmdStop}>Stop <span class="keyCap">s</span></button>
+            <button ?disabled=${true} @click=${this.onCmdGo}>Go <span class="keyCap">g</span></button>
           </div>
-        </span>
-      </div>
-
-      <div class="timeRow">
-        <div id="timeSlider"></div>
-        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
-        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
-        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
+          ${this.renderPlayerStateTable()}
+        </div>
       </div>
-
-      <div class="commands">
-        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
-          <strong>Stop</strong>
-          <div class="key">s</div>
-        </button>
-        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
-          <strong>Play</strong>
-          <div class="key">p</div>
-        </button>
-        <button id="cmd-intro" @click=${this.onCmdIntro}>
-          <strong>Skip intro</strong>
-          <div class="key">i</div>
-        </button>
-        <button id="cmd-post" @click=${this.onCmdPost}>
-          <strong>Skip to Post</strong>
-          <div class="key">t</div>
-        </button>
-        <button id="cmd-go" @click=${this.onCmdGo}>
-          <strong>Go</strong>
-          <div class="key">g</div>
-          <div id="next">${this.nextText}</div>
-        </button>
-      </div>`;
+    `;
+  }
+  renderPlayerStateTable() {
+    return html` <table>
+      <tr>
+        <th>duration</th>
+        <td>${this.playerState.duration}</td>
+      </tr>
+      <tr>
+        <th>endOfSong</th>
+        <td>${this.playerState.endOfSong}</td>
+      </tr>
+      <tr>
+        <th>pausedSongTime</th>
+        <td>${this.playerState.pausedSongTime?.toFixed(3)}</td>
+      </tr>
+      <tr>
+        <th>playing</th>
+        <td>${this.playerState.playing}</td>
+      </tr>
+      <tr>
+        <th>song</th>
+        <td>${this.playerState.song?.value}</td>
+      </tr>
+      <tr>
+        <th>wallStartTime</th>
+        <td>${this.playerState.wallStartTime}</td>
+      </tr>
+      <tr>
+        <th>t</th>
+        <td>${this.playerTime.toFixed(3)}</td>
+      </tr>
+    </table>`;
   }
 
   onSelectSong(song: NamedNode, ev: MouseEvent) {
@@ -189,122 +178,46 @@
       this.selectedSong = song;
     }
   }
-  async onPlaySelected(ev: Event) {
+
+  async onLoadSelected() {
     if (!this.selectedSong) {
       return;
     }
-    await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
+    await fetch("/service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
+    this.selectedSong = null;
   }
 
   onCmdStop(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { pause: true });
+    postJson("/service/ascoltami/time", { pause: true });
   }
+
   onCmdPlay(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { resume: true });
-  }
-  onCmdIntro(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
+    postJson("/service/ascoltami/time", { resume: true });
   }
-  onCmdPost(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", {
-      t: this.currentDuration - this.times.post,
-      resume: true,
-    });
+
+  onCmdGo(ev?: MouseEvent): void {
+    postJson("/service/ascoltami/go", {});
   }
-  onCmdGo(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/go", {});
+
+  onCmdZero(ev?: MouseEvent): void {
+    postJson("/service/ascoltami/time", { t: 0 });
   }
 
   bindKeys() {
     document.addEventListener("keypress", (ev) => {
-      if (ev.which == 115) {
+      if (ev.key == "l") {
+        this.onLoadSelected();
+      } else if (ev.key == "z") {
+        this.onCmdZero();
+      } else if (ev.key == "p") {
+        this.onCmdPlay();
+      } else if (ev.key == "s") {
         this.onCmdStop();
-        return false;
-      }
-      if (ev.which == 112) {
-        this.onCmdPlay();
-        return false;
+      } else if (ev.key == "g") {
+        this.onCmdGo();
+      } else {
+        return true;
       }
-      if (ev.which == 105) {
-        this.onCmdIntro();
-        return false;
-      }
-      if (ev.which == 116) {
-        this.onCmdPost();
-        return false;
-      }
-
-      if (ev.key == "g") {
-        this.onCmdGo();
-        return false;
-      }
-      return true;
     });
   }
-
-  async musicSetup() {
-    // shoveled over from the vanillajs version
-    const config = await (await fetch("../service/ascoltami/config")).json();
-    this.show = new NamedNode(config.show);
-    this.times = config.times;
-    document.title = document.title.replace("{{host}}", config.host);
-    try {
-      const h1 = document.querySelector("h1")!;
-      h1.innerText = h1.innerText.replace("{{host}}", config.host);
-    } catch (e) {}
-
-    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
-  }
-
-  onOldStyleUpdate(data: TimingUpdate) {
-    this.nextText = data.next;
-    this.isPlaying = data.playing;
-    this.currentDuration = data.duration;
-    this.song = new NamedNode(data.song);
-    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
-    const t1 = data.t - 2,
-      t2 = data.t + 20;
-    this.zoom = { duration: data.duration, t1, t2 };
-    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
-    const w = timeRow.offsetWidth;
-    this.viewState = {
-      zoomSpec: { t1: () => t1, t2: () => t2 },
-      cursor: { t: () => data.t },
-      audioY: () => 0,
-      audioH: () => 60,
-      zoomedTimeY: () => 60,
-      zoomedTimeH: () => 40,
-      fullZoomX: (sec: number) => (sec / data.duration) * w,
-      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
-      mouse: { pos: () => $V([0, 0]) },
-    };
-  }
-
-  @property() songList: NamedNode[] = [];
-  constructor() {
-    super();
-    this.bindKeys();
-    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.musicSetup(); // async
-      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
-    });
-  }
-  graphChanged() {
-    this.songList = [];
-    try {
-      const playList = this.graph.uriValue(
-        //
-        this.graph.Uri(showRoot),
-        this.graph.Uri(":playList")
-      );
-      log(playList);
-      this.songList = this.graph.items(playList) as NamedNode[];
-    } catch (e) {
-      log("no playlist yet");
-    }
-    log(this.songList.length);
-  }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/Light9SongListing.ts	Thu May 30 01:08:07 2024 -0700
@@ -0,0 +1,122 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { show } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+export { Light9TimelineAudio } from "../light9-timeline-audio";
+export { Light9CursorCanvas } from "../Light9CursorCanvas";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+const log = debug("songs");
+
+async function postJson(url: string, jsBody: Object) {
+  return fetch(url, {
+    method: "POST",
+    headers: { "Content-Type": "applcation/json" },
+    body: JSON.stringify(jsBody),
+  });
+}
+
+class SongRow {
+  constructor(public uri: NamedNode, public label: string) {}
+}
+
+@customElement("light9-song-listing")
+export class Light9SongListing extends LitElement {
+  static styles = [
+    css`
+      td {
+        font-size: 18pt;
+        white-space: nowrap;
+        padding: 0 10px;
+        background: #9b9b9b;
+      }
+      tr.current td {
+        background: #dbf5e4;
+      }
+      tr td:first-child {
+        box-shadow: initial;
+      }
+      tr.current td:first-child {
+        box-shadow: inset 0px 2px 10px 0px black;
+      }
+      button {
+        min-height: 30pt;
+        min-width: 65pt;
+      }
+    `,
+  ];
+  graph!: SyncedGraph;
+  @state() playingSong: NamedNode | null = null;
+  @property() selectedSong: NamedNode | null = null;
+  @state() songs: SongRow[] = [];
+  @state() isPlaying = false;
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.getSongs.bind(this), "getsongs");
+      this.graph.runHandler(this.playState.bind(this), "playstate");
+    });
+  }
+  getSongs() {
+    this.songs = [];
+    const U = this.graph.U();
+    try {
+      const playlist: NamedNode = this.graph.uriValue(show, U(":playList"));
+
+      (this.graph.items(playlist) as NamedNode[]).forEach((song: NamedNode) => {
+        this.songs.push(new SongRow(song, this.graph.labelOrTail(song)));
+      });
+    } catch (e) {
+      // likely it's startup- no graph yet
+      log(e);
+    }
+  }
+  playState() {
+    const U = this.graph.U();
+    try {
+      this.isPlaying = this.graph.booleanValue(U(":ascoltami"), U(":playing"));
+      this.playingSong = this.graph.uriValue(U(":ascoltami"), U(":song"));
+    } catch (e) {
+      log(e);
+    }
+  }
+  render() {
+    return html`
+      <table>
+        ${this.songs.map((song) => {
+          const playing = this.playingSong && song.uri.equals(this.playingSong);
+          const selected = this.selectedSong && song.uri.equals(this.selectedSong);
+          // could use <resource-display .uri=${song} noclick></resource-display>
+          return html`
+            <tr class="song ${playing ? "current" : ""}" data-uri=${song.uri.value} @click=${this.clickSongRow}>
+              <td>
+                <a href=${song.uri.value}>
+                  <span class="num">${song.label.slice(0, 2)}</span>
+                  <span class="song-name">${song.label.slice(2).trim()}</span>
+                </a>
+              </td>
+              <td>
+                <button ?disabled=${this.isPlaying || (this.selectedSong != null && !selected)}>${selected ? "Deselect" : "Select"}</button>
+              </td>
+            </tr>
+          `;
+        })}
+      </table>
+    `;
+  }
+  clickSongRow(ev: MouseEvent) {
+    ev.preventDefault();
+    const row = (ev.target as HTMLElement).closest(".song") as HTMLElement;
+    const song = new NamedNode(row.dataset.uri!);
+    if (this.selectedSong && song.equals(this.selectedSong)) {
+      this.selectedSong = null;
+    } else {
+      this.selectedSong = song;
+    }
+    this.dispatchEvent(new CustomEvent("selectsong", { detail: { song: this.selectedSong } }));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/PlayerState.ts	Thu May 30 01:08:07 2024 -0700
@@ -0,0 +1,10 @@
+import { NamedNode } from "n3";
+
+export interface PlayerState {
+  duration: number | null;
+  endOfSong: boolean | null;
+  pausedSongTime: number | null;
+  playing: boolean | null;
+  song: NamedNode<string> | null;
+  wallStartTime: number | null;
+}
--- a/web/ascoltami/index.html	Wed May 29 17:28:25 2024 -0700
+++ b/web/ascoltami/index.html	Thu May 30 01:08:07 2024 -0700
@@ -3,81 +3,11 @@
   <head>
     <title>ascoltami on {{host}}</title>
     <link rel="stylesheet" href="../style.css" />
-    <style>
-      #cmd-go {
-        min-width: 5em;
-      }
-      .song-name {
-        padding-left: 0.4em;
-      }
-      .dimStalled #currentTime {
-        font-size: 20px;
-        background: green;
-        color: black;
-        padding: 3px;
-      }
-      .dimStalled {
-        font-size: 90%;
-      }
-      body {
-        margin: 0;
-        padding: 0;
-        overflow: hidden;
-        min-height: 100vh;
-      }
-      #page {
-        width: 100%;
-        height: 100vh; /* my phone was losing the bottom :( */
-        display: flex;
-        flex-direction: column;
-      }
-      #page > div,
-      #page > p {
-        flex: 0 1 auto;
-        margin: 0;
-      }
-      light9-ascoltami-ui {
-        flex: 1 1 auto;
-      }
-    </style>
-    <meta
-      name="viewport"
-      content="user-scalable=no, width=device-width, initial-scale=.7"
-    />
+    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=.7" />
     <script type="module" src="./Light9AscoltamiUi"></script>
   </head>
   <body>
-    <div id="page">
-      <h1>ascoltami on {{host}}</h1>
-      <div class="songs" style="display: none"></div>
-
-      <div class="dimStalled">
-        <table>
-          <tr>
-            <td colspan="3">
-              <strong>Song:</strong> <span id="currentSong"></span>
-            </td>
-          </tr>
-          <tr>
-            <td><strong>Time:</strong> <span id="currentTime"></span></td>
-            <td><strong>Left:</strong> <span id="leftTime"></span></td>
-            <td>
-              <strong>Until autostop:</strong>
-              <span id="leftAutoStopTime"></span>
-            </td>
-          </tr>
-          <tr>
-            <td colspan="3">
-               <span id="states"></span>
-            </td>
-          </tr>
-        </table>
-      </div>
-
-      <hr />
-      <light9-ascoltami-ui></light9-ascoltami-ui>
-      <p><a href="">reload</a></p>
-    </div>
-    <script type="module" src="./main.ts"></script>
+    <light9-ascoltami-ui></light9-ascoltami-ui>
+    <p><a href="">reload</a></p>
   </body>
 </html>
--- a/web/light9-timeline-audio.ts	Wed May 29 17:28:25 2024 -0700
+++ b/web/light9-timeline-audio.ts	Thu May 30 01:08:07 2024 -0700
@@ -27,6 +27,7 @@
 @customElement("light9-timeline-audio")
 export class Light9TimelineAudio extends LitElement {
   graph!: SyncedGraph;
+  graphReady: Promise<void>;
   render() {
     return html`
       <style>
@@ -62,15 +63,17 @@
   constructor() {
     super();
 
-    getTopGraph().then((g) => {
+    this.graphReady = getTopGraph().then((g) => {
       this.graph = g;
     });
   }
 
-  updated(changedProperties: PropertyValues) {
+  async updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
     if (changedProperties.has("song") || changedProperties.has("show")) {
+      await this.graphReady;
       if (this.song && this.show) {
-        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song);
+        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song.value);
       }
     }
     if (changedProperties.has("zoom")) {
--- a/web/show_specific.ts	Wed May 29 17:28:25 2024 -0700
+++ b/web/show_specific.ts	Thu May 30 01:08:07 2024 -0700
@@ -1,2 +1,5 @@
+import { NamedNode } from "n3";
+
 export const shortShow = "dance2024";
-export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
\ No newline at end of file
+export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
+export const show = new NamedNode(showRoot)
\ No newline at end of file
--- a/web/timeline/viewstate.ts	Wed May 29 17:28:25 2024 -0700
+++ b/web/timeline/viewstate.ts	Thu May 30 01:08:07 2024 -0700
@@ -1,6 +1,9 @@
-import * as ko from "knockout";
 import * as d3 from "d3";
 import debug from "debug";
+import * as ko from "knockout";
+
+import Sylvester from "sylvester";
+const $V = Sylvester.Vector.create;
 
 const log = debug("viewstate");
 export class ViewState {