comparison web/ascoltami/Light9AscoltamiUi.ts @ 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 4556eebe5d73
children 2ce77421c0b7
comparison
equal deleted inserted replaced
2438:f2b3cfcc23d3 2439:06da5db2fafe
1 import debug from "debug"; 1 import debug from "debug";
2 import { css, html, LitElement } from "lit"; 2 import { css, html, LitElement, PropertyValues } from "lit";
3 import { customElement, property } from "lit/decorators.js"; 3 import { customElement, property } from "lit/decorators.js";
4 import { classMap } from "lit/directives/class-map.js";
5 import { NamedNode } from "n3"; 4 import { NamedNode } from "n3";
6 import Sylvester from "sylvester";
7 import { Zoom } from "../light9-timeline-audio";
8 import { PlainViewState } from "../Light9CursorCanvas"; 5 import { PlainViewState } from "../Light9CursorCanvas";
9 import { getTopGraph } from "../RdfdbSyncedGraph"; 6 import { getTopGraph } from "../RdfdbSyncedGraph";
10 import { SyncedGraph } from "../SyncedGraph"; 7 import { SyncedGraph } from "../SyncedGraph";
11 import { TimingUpdate } from "./main"; 8 import { PlayerState } from "./PlayerState";
12 import { showRoot } from "../show_specific";
13 export { Light9TimelineAudio } from "../light9-timeline-audio";
14 export { Light9CursorCanvas } from "../Light9CursorCanvas";
15 export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph"; 9 export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
16 export { ResourceDisplay } from "../ResourceDisplay"; 10 export { ResourceDisplay } from "../ResourceDisplay";
17 const $V = Sylvester.Vector.create; 11 export { Light9AscoltamiTimeline } from "./Light9AscoltamiTimeline";
12 export { Light9SongListing } from "./Light9SongListing";
18 13
19 debug.enable("*"); 14 debug.enable("*");
20 const log = debug("asco"); 15 const log = debug("asco");
21 16
22 function byId(id: string): HTMLElement {
23 return document.getElementById(id)!;
24 }
25 async function postJson(url: string, jsBody: Object) { 17 async function postJson(url: string, jsBody: Object) {
26 return fetch(url, { 18 return fetch(url, {
27 method: "POST", 19 method: "POST",
28 headers: { "Content-Type": "applcation/json" }, 20 headers: { "Content-Type": "applcation/json" },
29 body: JSON.stringify(jsBody), 21 body: JSON.stringify(jsBody),
30 }); 22 });
31 } 23 }
24
32 @customElement("light9-ascoltami-ui") 25 @customElement("light9-ascoltami-ui")
33 export class Light9AscoltamiUi extends LitElement { 26 export class Light9AscoltamiUi extends LitElement {
34 graph!: SyncedGraph; 27 graph!: SyncedGraph;
35 times!: { intro: number; post: number };
36 @property() nextText: string = "";
37 @property() isPlaying: boolean = false;
38 @property() show: NamedNode | null = null; 28 @property() show: NamedNode | null = null;
39 @property() song: NamedNode | null = null; 29 @property() song: NamedNode | null = null;
40 @property() selectedSong: NamedNode | null = null; 30 @property() selectedSong: NamedNode | null = null;
41 @property() currentDuration: number = 0;
42 @property() zoom: Zoom;
43 @property() overviewZoom: Zoom;
44 @property() viewState: PlainViewState | null = null; 31 @property() viewState: PlainViewState | null = null;
32 @property() host: any;
33 @property() playerState: PlayerState = { duration: null, endOfSong: null, pausedSongTime: null, playing: null, song: null, wallStartTime: null };
34 @property() playerTime: number = 0;
45 static styles = [ 35 static styles = [
46 css` 36 css`
47 :host { 37 :host {
48 display: flex; 38 display: flex;
49 flex-direction: column; 39 flex-direction: column;
50 } 40 }
51 .timeRow { 41
52 margin: 14px; 42 .keyCap {
53 position: relative; 43 color: #ccc;
54 } 44 background: #525252;
55 #overview { 45 display: inline-block;
56 height: 60px; 46 border: 1px outset #b3b3b3;
57 } 47 padding: 2px 3px;
58 #zoomed { 48 margin: 3px 0;
59 margin-top: 40px; 49 margin-left: 0.4em;
60 height: 80px; 50 font-size: 16px;
61 } 51 box-shadow: 0.9px 0.9px 0px 2px #565656;
62 #cursor { 52 border-radius: 2px;
63 position: absolute; 53 }
64 left: 0; 54
65 top: 0; 55 button {
66 width: 100%; 56 min-height: 48pt;
67 height: 100%; 57 min-width: 65pt;
68 } 58 }
69 #grow { 59
70 flex: 1 1 auto; 60 #mainRow {
71 display: flex; 61 display: flex;
72 } 62 flex-direction: row;
73 #grow > span { 63 }
74 display: flex; 64
75 position: relative; 65 light9-song-listing {
76 width: 50%; 66 flex-grow: 1;
77 } 67 }
78 #playSelected { 68
79 height: 100px; 69 th {
80 } 70 text-align: right;
81 #songList {
82 overflow-y: scroll;
83 position: absolute;
84 left: 0;
85 top: 0;
86 right: 0;
87 bottom: 0;
88 }
89 #songList .row {
90 width: 60%;
91 min-height: 40px;
92 text-align: left;
93 position: relative;
94 }
95 #songList .row:nth-child(even) {
96 background: #333;
97 }
98 #songList .row:nth-child(odd) {
99 background: #444;
100 }
101 #songList button {
102 min-height: 40px;
103 margin-bottom: 10px;
104 }
105 #songList .row.playing {
106 box-shadow: 0 0 30px red;
107 background-color: #de5050;
108 } 71 }
109 `, 72 `,
110 ]; 73 ];
74
75 constructor() {
76 super();
77 getTopGraph().then((g) => {
78 this.graph = g;
79 this.graph.runHandler(this.updatePlayState.bind(this), "playstate-ui");
80 });
81 setInterval(this.updateT.bind(this), 100);
82 }
83
84 protected async firstUpdated(_changedProperties: PropertyValues<this>) {
85 this.bindKeys();
86 const config = await (await fetch("/service/ascoltami/config")).json();
87 document.title = document.title.replace("{{host}}", config.host);
88 this.host = config.host;
89 }
90
91 updatePlayState() {
92 const U = this.graph.U();
93 const asco = U(":ascoltami");
94 this.playerState = {
95 duration: this.graph.optionalFloatValue(asco, U(":duration")),
96 endOfSong: this.graph.optionalBooleanValue(asco, U(":endOfSong")),
97 pausedSongTime: this.graph.optionalFloatValue(asco, U(":pausedSongTime")),
98 wallStartTime: this.graph.optionalFloatValue(asco, U(":wallStartTime")),
99 playing: this.graph.optionalBooleanValue(asco, U(":playing")),
100 song: this.graph.optionalUriValue(asco, U(":song")),
101 };
102 this.updateT();
103 }
104
105 updateT() {
106 if (this.playerState.wallStartTime !== null) {
107 this.playerTime = Date.now() / 1000 - this.playerState.wallStartTime;
108 } else if (this.playerState.pausedSongTime !== null) {
109 this.playerTime = this.playerState.pausedSongTime;
110 } else {
111 this.playerTime = 0;
112 }
113 }
114
111 render() { 115 render() {
112 return html`<rdfdb-synced-graph></rdfdb-synced-graph> 116 return html`
113 117 <h1>ascoltami on ${this.host}</h1>
114 <link rel="stylesheet" href="../style.css" /> 118
115 119 <span><rdfdb-synced-graph></rdfdb-synced-graph></span>
116 <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> --> 120 <div id="mainRow">
117 121 <light9-song-listing
118 <div id="grow"> 122 @selectsong=${(ev: any) => {
119 <span> 123 this.selectedSong = ev.detail.song;
120 <div id="songList"> 124 }}
121 <table> 125 .selectedSong=${this.selectedSong}
122 ${this.songList.map( 126 ></light9-song-listing>
123 (song) => html` 127 <div>
124 <tr 128 <light9-ascoltami-timeline .playerState=${this.playerState} .playerTime=${this.playerTime}></light9-ascoltami-timeline>
125 class="row ${classMap({ 129 <div>
126 playing: !!(this.song && song.equals(this.song)), 130 <button ?disabled=${this.selectedSong == null} @click=${this.onLoadSelected}>Change to and play selected <span class="keyCap">l</span></button>
127 })}" 131 <button ?disabled=${false} @click=${this.onCmdZero}>Seek to zero <span class="keyCap">z</span></button>
128 > 132 <button ?disabled=${this.playerState.playing || this.playerState.song == null} @click=${this.onCmdPlay}>Play <span class="keyCap">p</span></button>
129 <td><resource-display .uri=${song} noclick></resource-display></td> 133 <button ?disabled=${!this.playerState.playing} @click=${this.onCmdStop}>Stop <span class="keyCap">s</span></button>
130 <td> 134 <button ?disabled=${true} @click=${this.onCmdGo}>Go <span class="keyCap">g</span></button>
131 <button @click=${this.onSelectSong.bind(this, song)}>
132 <span>Select</span>
133 </button>
134 </td>
135 </tr>
136 `
137 )}
138 </table>
139 </div> </span
140 ><span>
141 <div id="right">
142 <div>
143 Selected:
144 <resource-display .uri=${this.selectedSong}></resource-display>
145 </div>
146 <div>
147 <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
148 </div>
149 </div> 135 </div>
150 </span> 136 ${this.renderPlayerStateTable()}
137 </div>
151 </div> 138 </div>
152 139 `;
153 <div class="timeRow"> 140 }
154 <div id="timeSlider"></div> 141 renderPlayerStateTable() {
155 <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio> 142 return html` <table>
156 <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio> 143 <tr>
157 <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas> 144 <th>duration</th>
158 </div> 145 <td>${this.playerState.duration}</td>
159 146 </tr>
160 <div class="commands"> 147 <tr>
161 <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}"> 148 <th>endOfSong</th>
162 <strong>Stop</strong> 149 <td>${this.playerState.endOfSong}</td>
163 <div class="key">s</div> 150 </tr>
164 </button> 151 <tr>
165 <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}"> 152 <th>pausedSongTime</th>
166 <strong>Play</strong> 153 <td>${this.playerState.pausedSongTime?.toFixed(3)}</td>
167 <div class="key">p</div> 154 </tr>
168 </button> 155 <tr>
169 <button id="cmd-intro" @click=${this.onCmdIntro}> 156 <th>playing</th>
170 <strong>Skip intro</strong> 157 <td>${this.playerState.playing}</td>
171 <div class="key">i</div> 158 </tr>
172 </button> 159 <tr>
173 <button id="cmd-post" @click=${this.onCmdPost}> 160 <th>song</th>
174 <strong>Skip to Post</strong> 161 <td>${this.playerState.song?.value}</td>
175 <div class="key">t</div> 162 </tr>
176 </button> 163 <tr>
177 <button id="cmd-go" @click=${this.onCmdGo}> 164 <th>wallStartTime</th>
178 <strong>Go</strong> 165 <td>${this.playerState.wallStartTime}</td>
179 <div class="key">g</div> 166 </tr>
180 <div id="next">${this.nextText}</div> 167 <tr>
181 </button> 168 <th>t</th>
182 </div>`; 169 <td>${this.playerTime.toFixed(3)}</td>
170 </tr>
171 </table>`;
183 } 172 }
184 173
185 onSelectSong(song: NamedNode, ev: MouseEvent) { 174 onSelectSong(song: NamedNode, ev: MouseEvent) {
186 if (this.selectedSong && song.equals(this.selectedSong)) { 175 if (this.selectedSong && song.equals(this.selectedSong)) {
187 this.selectedSong = null; 176 this.selectedSong = null;
188 } else { 177 } else {
189 this.selectedSong = song; 178 this.selectedSong = song;
190 } 179 }
191 } 180 }
192 async onPlaySelected(ev: Event) { 181
182 async onLoadSelected() {
193 if (!this.selectedSong) { 183 if (!this.selectedSong) {
194 return; 184 return;
195 } 185 }
196 await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value }); 186 await fetch("/service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
187 this.selectedSong = null;
197 } 188 }
198 189
199 onCmdStop(ev?: MouseEvent): void { 190 onCmdStop(ev?: MouseEvent): void {
200 postJson("../service/ascoltami/time", { pause: true }); 191 postJson("/service/ascoltami/time", { pause: true });
201 } 192 }
193
202 onCmdPlay(ev?: MouseEvent): void { 194 onCmdPlay(ev?: MouseEvent): void {
203 postJson("../service/ascoltami/time", { resume: true }); 195 postJson("/service/ascoltami/time", { resume: true });
204 } 196 }
205 onCmdIntro(ev?: MouseEvent): void { 197
206 postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
207 }
208 onCmdPost(ev?: MouseEvent): void {
209 postJson("../service/ascoltami/time", {
210 t: this.currentDuration - this.times.post,
211 resume: true,
212 });
213 }
214 onCmdGo(ev?: MouseEvent): void { 198 onCmdGo(ev?: MouseEvent): void {
215 postJson("../service/ascoltami/go", {}); 199 postJson("/service/ascoltami/go", {});
200 }
201
202 onCmdZero(ev?: MouseEvent): void {
203 postJson("/service/ascoltami/time", { t: 0 });
216 } 204 }
217 205
218 bindKeys() { 206 bindKeys() {
219 document.addEventListener("keypress", (ev) => { 207 document.addEventListener("keypress", (ev) => {
220 if (ev.which == 115) { 208 if (ev.key == "l") {
209 this.onLoadSelected();
210 } else if (ev.key == "z") {
211 this.onCmdZero();
212 } else if (ev.key == "p") {
213 this.onCmdPlay();
214 } else if (ev.key == "s") {
221 this.onCmdStop(); 215 this.onCmdStop();
222 return false; 216 } else if (ev.key == "g") {
223 }
224 if (ev.which == 112) {
225 this.onCmdPlay();
226 return false;
227 }
228 if (ev.which == 105) {
229 this.onCmdIntro();
230 return false;
231 }
232 if (ev.which == 116) {
233 this.onCmdPost();
234 return false;
235 }
236
237 if (ev.key == "g") {
238 this.onCmdGo(); 217 this.onCmdGo();
239 return false; 218 } else {
240 } 219 return true;
241 return true; 220 }
242 }); 221 });
243 } 222 }
244
245 async musicSetup() {
246 // shoveled over from the vanillajs version
247 const config = await (await fetch("../service/ascoltami/config")).json();
248 this.show = new NamedNode(config.show);
249 this.times = config.times;
250 document.title = document.title.replace("{{host}}", config.host);
251 try {
252 const h1 = document.querySelector("h1")!;
253 h1.innerText = h1.innerText.replace("{{host}}", config.host);
254 } catch (e) {}
255
256 (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
257 }
258
259 onOldStyleUpdate(data: TimingUpdate) {
260 this.nextText = data.next;
261 this.isPlaying = data.playing;
262 this.currentDuration = data.duration;
263 this.song = new NamedNode(data.song);
264 this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
265 const t1 = data.t - 2,
266 t2 = data.t + 20;
267 this.zoom = { duration: data.duration, t1, t2 };
268 const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
269 const w = timeRow.offsetWidth;
270 this.viewState = {
271 zoomSpec: { t1: () => t1, t2: () => t2 },
272 cursor: { t: () => data.t },
273 audioY: () => 0,
274 audioH: () => 60,
275 zoomedTimeY: () => 60,
276 zoomedTimeH: () => 40,
277 fullZoomX: (sec: number) => (sec / data.duration) * w,
278 zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
279 mouse: { pos: () => $V([0, 0]) },
280 };
281 }
282
283 @property() songList: NamedNode[] = [];
284 constructor() {
285 super();
286 this.bindKeys();
287 this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
288
289 getTopGraph().then((g) => {
290 this.graph = g;
291 this.musicSetup(); // async
292 this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
293 });
294 }
295 graphChanged() {
296 this.songList = [];
297 try {
298 const playList = this.graph.uriValue(
299 //
300 this.graph.Uri(showRoot),
301 this.graph.Uri(":playList")
302 );
303 log(playList);
304 this.songList = this.graph.items(playList) as NamedNode[];
305 } catch (e) {
306 log("no playlist yet");
307 }
308 log(this.songList.length);
309 }
310 } 223 }