Mercurial > code > home > repos > light9
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 {