Thomas Dy пре 3 година
родитељ
комит
ac14fff580
23 измењених фајлова са 2690 додато и 2488 уклоњено
  1. 19 0
      package-lock.json
  2. 2 0
      package.json
  3. 3 0
      prettier.config.js
  4. 254 244
      src/audio.ts
  5. 57 51
      src/background.ts
  6. 270 261
      src/display.ts
  7. 8 4
      src/editor.css
  8. 3 4
      src/editor.html
  9. 332 305
      src/editor.ts
  10. 44 48
      src/game.ts
  11. 52 49
      src/game/common.ts
  12. 61 58
      src/game/loading.ts
  13. 203 206
      src/game/select.ts
  14. 308 292
      src/game/typing.ts
  15. 12 9
      src/global.d.ts
  16. 17 8
      src/index.html
  17. 519 461
      src/kana.ts
  18. 168 157
      src/level.ts
  19. 39 38
      src/polyfill.ts
  20. 145 136
      src/state.ts
  21. 20 14
      src/style.css
  22. 108 97
      src/util.ts
  23. 46 46
      src/youtube.ts

+ 19 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "license": "ISC",
       "devDependencies": {
         "@snowpack/plugin-typescript": "^1.2.1",
+        "prettier": "2.2.1",
         "snowpack": "^3.2.2",
         "typescript": "^4.0.0"
       }
@@ -333,6 +334,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/prettier": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
+      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/resolve": {
       "version": "1.20.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
@@ -687,6 +700,12 @@
       "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
       "dev": true
     },
+    "prettier": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
+      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
+      "dev": true
+    },
     "resolve": {
       "version": "1.20.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",

+ 2 - 0
package.json

@@ -5,12 +5,14 @@
   "scripts": {
     "build": "snowpack build",
     "dev": "snowpack dev",
+    "prettier": "prettier --write src",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "author": "Thomas Dy <thatsmydoing@gmail.com",
   "license": "ISC",
   "devDependencies": {
     "@snowpack/plugin-typescript": "^1.2.1",
+    "prettier": "2.2.1",
     "snowpack": "^3.2.2",
     "typescript": "^4.0.0"
   }

+ 3 - 0
prettier.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  "singleQuote": true
+}

+ 254 - 244
src/audio.ts

@@ -1,296 +1,306 @@
 import * as youtube from './youtube';
 
-  export class AudioManager {
-    context: AudioContext;
-    volume: GainNode;
-    output: AudioNode;
-
-    constructor() {
-      this.context = new AudioContext();
-      this.volume = this.context.createGain();
-      this.volume.connect(this.context.destination);
-      this.output = this.volume;
-    }
-
-    getTime(): number {
-      return this.context.currentTime;
-    }
+export class AudioManager {
+  context: AudioContext;
+  volume: GainNode;
+  output: AudioNode;
+
+  constructor() {
+    this.context = new AudioContext();
+    this.volume = this.context.createGain();
+    this.volume.connect(this.context.destination);
+    this.output = this.volume;
+  }
 
-    async loadTrack(url: string): Promise<FileTrack> {
-      const response = await window.fetch(url);
-      const buffer = await response.arrayBuffer();
-      const audioBuffer = await this.context.decodeAudioData(buffer);
-      return new FileTrack(this, audioBuffer);
-    }
+  getTime(): number {
+    return this.context.currentTime;
+  }
 
-    async loadTrackFromFile(file: File): Promise<FileTrack> {
-      const promise = new Promise<ArrayBuffer>((resolve, _) => {
-        const reader = new FileReader();
-        reader.onload = () => resolve(reader.result as ArrayBuffer);
-        reader.readAsArrayBuffer(file);
-      });
-      const buffer = await promise;
-      const audioBuffer = await this.context.decodeAudioData(buffer);
-      return new FileTrack(this, audioBuffer);
-    }
+  async loadTrack(url: string): Promise<FileTrack> {
+    const response = await window.fetch(url);
+    const buffer = await response.arrayBuffer();
+    const audioBuffer = await this.context.decodeAudioData(buffer);
+    return new FileTrack(this, audioBuffer);
+  }
 
-    async loadTrackWithProgress(url: string, listener: (percentage: number) => void): Promise<FileTrack> {
-      const promise = new Promise<ArrayBuffer>((resolve, reject) => {
-        let xhr = new XMLHttpRequest();
-        xhr.open('GET', url);
-        xhr.responseType = 'arraybuffer';
-        xhr.onprogress = (event) => {
-          if (event.lengthComputable) {
-            // only up to 80 to factor in decoding time
-            let percentage = event.loaded / event.total * 80;
-            listener(percentage);
-          }
-        };
-        xhr.onload = () => resolve(xhr.response);
-        xhr.onerror = () => reject();
-        xhr.send();
-      });
-      const buffer = await promise;
-      const audioBuffer = await this.context.decodeAudioData(buffer);
-      return new FileTrack(this, audioBuffer);
-    }
+  async loadTrackFromFile(file: File): Promise<FileTrack> {
+    const promise = new Promise<ArrayBuffer>((resolve, _) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(reader.result as ArrayBuffer);
+      reader.readAsArrayBuffer(file);
+    });
+    const buffer = await promise;
+    const audioBuffer = await this.context.decodeAudioData(buffer);
+    return new FileTrack(this, audioBuffer);
+  }
 
-    async loadTrackFromYoutube(id: string, element: HTMLElement, listener: (percentage: number) => void): Promise<YoutubeTrack> {
-      await youtube.loadYoutubeApi();
-      listener(30);
-      const player = await youtube.createPlayer(element);
-      listener(60);
-      const track = new YoutubeTrack(player, id);
-      await track.preload();
-      listener(90);
-      return track;
-    }
+  async loadTrackWithProgress(
+    url: string,
+    listener: (percentage: number) => void
+  ): Promise<FileTrack> {
+    const promise = new Promise<ArrayBuffer>((resolve, reject) => {
+      let xhr = new XMLHttpRequest();
+      xhr.open('GET', url);
+      xhr.responseType = 'arraybuffer';
+      xhr.onprogress = (event) => {
+        if (event.lengthComputable) {
+          // only up to 80 to factor in decoding time
+          let percentage = (event.loaded / event.total) * 80;
+          listener(percentage);
+        }
+      };
+      xhr.onload = () => resolve(xhr.response);
+      xhr.onerror = () => reject();
+      xhr.send();
+    });
+    const buffer = await promise;
+    const audioBuffer = await this.context.decodeAudioData(buffer);
+    return new FileTrack(this, audioBuffer);
   }
 
-  export enum PlayState {
-    UNSTARTED ='unstarted',
-    PLAYING = 'playing',
-    PAUSED = 'paused',
-    STOPPED = 'stopped'
+  async loadTrackFromYoutube(
+    id: string,
+    element: HTMLElement,
+    listener: (percentage: number) => void
+  ): Promise<YoutubeTrack> {
+    await youtube.loadYoutubeApi();
+    listener(30);
+    const player = await youtube.createPlayer(element);
+    listener(60);
+    const track = new YoutubeTrack(player, id);
+    await track.preload();
+    listener(90);
+    return track;
   }
+}
 
-  export type TrackListener = (track: Track, state: PlayState) => void;
+export enum PlayState {
+  UNSTARTED = 'unstarted',
+  PLAYING = 'playing',
+  PAUSED = 'paused',
+  STOPPED = 'stopped',
+}
 
-  export abstract class Track {
-    private listeners: TrackListener[] = [];
+export type TrackListener = (track: Track, state: PlayState) => void;
 
-    addListener(listener: TrackListener) {
-      this.listeners.push(listener);
-    }
+export abstract class Track {
+  private listeners: TrackListener[] = [];
 
-    clearListeners() {
-      this.listeners = [];
-    }
+  addListener(listener: TrackListener) {
+    this.listeners.push(listener);
+  }
 
-    emit(state: PlayState) {
-      this.listeners.forEach(l => l(this, state));
-    }
+  clearListeners() {
+    this.listeners = [];
+  }
 
-    abstract play(): void;
-    abstract start(fromTime?: number, duration?: number): void;
-    abstract pause(): void;
-    abstract stop(): void;
-    abstract exit(): void;
-    abstract getState(): PlayState;
-    abstract getTime(): number;
-    abstract getDuration(): number;
+  emit(state: PlayState) {
+    this.listeners.forEach((l) => l(this, state));
   }
 
-  export class FileTrack extends Track {
-    manager: AudioManager;
-    buffer: AudioBuffer;
-    source: AudioBufferSourceNode | null;
-    playStartTime: number;
-    resumeTime: number;
-    state: PlayState;
-
-    constructor(manager: AudioManager, buffer: AudioBuffer) {
-      super();
-      this.manager = manager;
-      this.buffer = buffer;
-      this.source = null;
-      this.playStartTime = 0;
-      this.resumeTime = 0;
-      this.state = PlayState.UNSTARTED;
-    }
+  abstract play(): void;
+  abstract start(fromTime?: number, duration?: number): void;
+  abstract pause(): void;
+  abstract stop(): void;
+  abstract exit(): void;
+  abstract getState(): PlayState;
+  abstract getTime(): number;
+  abstract getDuration(): number;
+}
+
+export class FileTrack extends Track {
+  manager: AudioManager;
+  buffer: AudioBuffer;
+  source: AudioBufferSourceNode | null;
+  playStartTime: number;
+  resumeTime: number;
+  state: PlayState;
+
+  constructor(manager: AudioManager, buffer: AudioBuffer) {
+    super();
+    this.manager = manager;
+    this.buffer = buffer;
+    this.source = null;
+    this.playStartTime = 0;
+    this.resumeTime = 0;
+    this.state = PlayState.UNSTARTED;
+  }
 
-    play(): void {
-      this.source = this.manager.context.createBufferSource();
-      this.source.buffer = this.buffer;
-      this.source.connect(this.manager.output);
-      this.playStartTime = this.manager.getTime();
-      this.setState(PlayState.PLAYING);
-      this.source.start();
-    }
+  play(): void {
+    this.source = this.manager.context.createBufferSource();
+    this.source.buffer = this.buffer;
+    this.source.connect(this.manager.output);
+    this.playStartTime = this.manager.getTime();
+    this.setState(PlayState.PLAYING);
+    this.source.start();
+  }
 
-    start(fromTime?: number, duration?: number): void {
-      if (fromTime !== undefined) {
-        this.resumeTime = fromTime;
-      }
-      this.source = this.manager.context.createBufferSource();
-      this.source.buffer = this.buffer;
-      this.source.connect(this.manager.output);
-      this.source.onended = (event) => {
-        if (this.source == event.target) {
-          this.resumeTime = this.manager.getTime() - this.playStartTime;
-          if (this.resumeTime > this.getDuration()) {
-            this.resumeTime = 0;
-            this.setState(PlayState.STOPPED);
-          } else {
-            this.setState(PlayState.PAUSED);
-          }
+  start(fromTime?: number, duration?: number): void {
+    if (fromTime !== undefined) {
+      this.resumeTime = fromTime;
+    }
+    this.source = this.manager.context.createBufferSource();
+    this.source.buffer = this.buffer;
+    this.source.connect(this.manager.output);
+    this.source.onended = (event) => {
+      if (this.source == event.target) {
+        this.resumeTime = this.manager.getTime() - this.playStartTime;
+        if (this.resumeTime > this.getDuration()) {
+          this.resumeTime = 0;
+          this.setState(PlayState.STOPPED);
+        } else {
+          this.setState(PlayState.PAUSED);
         }
       }
-      this.playStartTime = this.manager.getTime() - this.resumeTime;
-      this.setState(PlayState.PLAYING);
-      this.source.start(0, this.resumeTime, duration);
-    }
+    };
+    this.playStartTime = this.manager.getTime() - this.resumeTime;
+    this.setState(PlayState.PLAYING);
+    this.source.start(0, this.resumeTime, duration);
+  }
 
-    pause(): void {
-      if (this.state === PlayState.PAUSED || this.state === PlayState.STOPPED) return;
-      this.resumeTime = this.manager.getTime() - this.playStartTime;
-      if (this.source) {
-        this.source.stop();
-      }
-      this.setState(PlayState.PAUSED);
+  pause(): void {
+    if (this.state === PlayState.PAUSED || this.state === PlayState.STOPPED)
+      return;
+    this.resumeTime = this.manager.getTime() - this.playStartTime;
+    if (this.source) {
+      this.source.stop();
     }
+    this.setState(PlayState.PAUSED);
+  }
 
-    stop(): void {
-      this.resumeTime = 0;
-      if (this.source) {
-        this.source.stop();
-      }
-      this.setState(PlayState.STOPPED);
+  stop(): void {
+    this.resumeTime = 0;
+    if (this.source) {
+      this.source.stop();
     }
+    this.setState(PlayState.STOPPED);
+  }
 
-    exit(): void {
-      this.stop();
-    }
+  exit(): void {
+    this.stop();
+  }
 
-    getState(): PlayState {
-      return this.state;
-    }
+  getState(): PlayState {
+    return this.state;
+  }
 
-    getTime(): number {
-      if (this.state === PlayState.UNSTARTED) {
-        return 0;
-      }
-      else if (this.state === PlayState.PAUSED || this.state === PlayState.STOPPED) {
-        if (this.resumeTime > 0) {
-          return this.resumeTime;
-        } else {
-          return this.getDuration();
-        }
+  getTime(): number {
+    if (this.state === PlayState.UNSTARTED) {
+      return 0;
+    } else if (
+      this.state === PlayState.PAUSED ||
+      this.state === PlayState.STOPPED
+    ) {
+      if (this.resumeTime > 0) {
+        return this.resumeTime;
       } else {
-        return this.manager.getTime() - this.playStartTime;
+        return this.getDuration();
       }
+    } else {
+      return this.manager.getTime() - this.playStartTime;
     }
+  }
 
-    getDuration(): number {
-      return this.buffer.duration;
-    }
+  getDuration(): number {
+    return this.buffer.duration;
+  }
 
-    private setState(state: PlayState): void {
-      this.state = state;
-      this.emit(state);
-    }
+  private setState(state: PlayState): void {
+    this.state = state;
+    this.emit(state);
   }
+}
 
-  export class YoutubeTrack extends Track {
-    private timeoutHandle?: number;
+export class YoutubeTrack extends Track {
+  private timeoutHandle?: number;
 
-    constructor(readonly player: YT.Player, readonly id: string) {
-      super();
-    }
+  constructor(readonly player: YT.Player, readonly id: string) {
+    super();
+  }
 
-    preload(): Promise<void> {
-      return new Promise((resolve) => {
-        let loaded = false;
-        const onStateChange: YT.PlayerStateChangeListener = ({ data }) => {
-          if (data === YT.PlayerState.PLAYING) {
-            if (!loaded) {
-              loaded = true;
-              this.player.pauseVideo();
-              this.player.seekTo(0);
-              this.player.unMute();
-              resolve();
-            }
+  preload(): Promise<void> {
+    return new Promise((resolve) => {
+      let loaded = false;
+      const onStateChange: YT.PlayerStateChangeListener = ({ data }) => {
+        if (data === YT.PlayerState.PLAYING) {
+          if (!loaded) {
+            loaded = true;
+            this.player.pauseVideo();
+            this.player.seekTo(0);
+            this.player.unMute();
+            resolve();
           }
-          this.emit(this.mapState(data));
-        };
-        this.player.addEventListener('onStateChange', onStateChange);
-        this.player.mute();
-        this.player.loadVideoById(this.id);
-      });
-    }
+        }
+        this.emit(this.mapState(data));
+      };
+      this.player.addEventListener('onStateChange', onStateChange);
+      this.player.mute();
+      this.player.loadVideoById(this.id);
+    });
+  }
 
-    play(): void {
-      this.clearTimeout();
-      this.player.playVideo();
-    }
+  play(): void {
+    this.clearTimeout();
+    this.player.playVideo();
+  }
 
-    start(fromTime?: number, duration?: number): void {
-      this.clearTimeout();
-      if (duration) {
-        this.timeoutHandle = setTimeout(() => {
-          this.player.pauseVideo();
-        }, duration * 1000);
-      }
-      if (fromTime !== undefined) {
-        this.player.seekTo(fromTime, true);
-      }
-      this.player.playVideo();
+  start(fromTime?: number, duration?: number): void {
+    this.clearTimeout();
+    if (duration) {
+      this.timeoutHandle = setTimeout(() => {
+        this.player.pauseVideo();
+      }, duration * 1000);
     }
-
-    pause(): void {
-      this.clearTimeout();
-      this.player.pauseVideo();
+    if (fromTime !== undefined) {
+      this.player.seekTo(fromTime, true);
     }
+    this.player.playVideo();
+  }
 
-    stop(): void {
-      this.clearTimeout();
-      this.player.stopVideo();
-    }
+  pause(): void {
+    this.clearTimeout();
+    this.player.pauseVideo();
+  }
 
-    exit(): void {
-      // the element gets removed by the background manager and stops that way
-    }
+  stop(): void {
+    this.clearTimeout();
+    this.player.stopVideo();
+  }
 
-    getState(): PlayState {
-      return this.mapState(this.player.getPlayerState());
-    }
+  exit(): void {
+    // the element gets removed by the background manager and stops that way
+  }
 
-    getTime(): number {
-      return this.player.getCurrentTime();
-    }
+  getState(): PlayState {
+    return this.mapState(this.player.getPlayerState());
+  }
 
-    getDuration(): number {
-      return this.player.getDuration();
-    }
+  getTime(): number {
+    return this.player.getCurrentTime();
+  }
 
-    private clearTimeout(): void {
-      if (this.timeoutHandle) {
-        clearTimeout(this.timeoutHandle);
-      }
+  getDuration(): number {
+    return this.player.getDuration();
+  }
+
+  private clearTimeout(): void {
+    if (this.timeoutHandle) {
+      clearTimeout(this.timeoutHandle);
     }
+  }
 
-    private mapState(ytState: YT.PlayerState): PlayState {
-      switch (ytState) {
-        case YT.PlayerState.PLAYING:
-          return PlayState.PLAYING;
-        case YT.PlayerState.ENDED:
-          return PlayState.STOPPED;
-        case YT.PlayerState.UNSTARTED:
-        case YT.PlayerState.CUED:
-          return PlayState.UNSTARTED;
-        case YT.PlayerState.BUFFERING:
-        case YT.PlayerState.PAUSED:
-          return PlayState.PAUSED;
-      }
+  private mapState(ytState: YT.PlayerState): PlayState {
+    switch (ytState) {
+      case YT.PlayerState.PLAYING:
+        return PlayState.PLAYING;
+      case YT.PlayerState.ENDED:
+        return PlayState.STOPPED;
+      case YT.PlayerState.UNSTARTED:
+      case YT.PlayerState.CUED:
+        return PlayState.UNSTARTED;
+      case YT.PlayerState.BUFFERING:
+      case YT.PlayerState.PAUSED:
+        return PlayState.PAUSED;
     }
   }
+}

+ 57 - 51
src/background.ts

@@ -1,65 +1,71 @@
 import * as util from './util';
 
-  export class BackgroundManager {
-    element: HTMLElement;
-    video: HTMLElement;
-    last: HTMLElement | null;
-    next: HTMLElement;
-    fnContext: util.FnContext = new util.FnContext();
+export class BackgroundManager {
+  element: HTMLElement;
+  video: HTMLElement;
+  last: HTMLElement | null;
+  next: HTMLElement;
+  fnContext: util.FnContext = new util.FnContext();
 
-    constructor(element: HTMLElement) {
-      this.element = element;
-      this.last = null;
-      this.video = util.getElement(element, '#video');
-      this.video.addEventListener('transitionend', () => {
-        this.video.classList.add('settled');
-      });
-      this.next = document.createElement('div');
-      this.element.appendChild(this.next);
-    }
+  constructor(element: HTMLElement) {
+    this.element = element;
+    this.last = null;
+    this.video = util.getElement(element, '#video');
+    this.video.addEventListener('transitionend', () => {
+      this.video.classList.add('settled');
+    });
+    this.next = document.createElement('div');
+    this.element.appendChild(this.next);
+  }
 
-    setBackground(background: string) {
-      this.fnContext.invalidate();
-      util.loadBackground(background).then(this.fnContext.wrap(() => {
+  setBackground(background: string) {
+    this.fnContext.invalidate();
+    util.loadBackground(background).then(
+      this.fnContext.wrap(() => {
         this.setBackgroundActual(background);
-      }));
-    }
+      })
+    );
+  }
 
-    showVideo() {
-      this.video.classList.add('show');
-      this.last?.classList.remove('show');
-    }
+  showVideo() {
+    this.video.classList.add('show');
+    this.last?.classList.remove('show');
+  }
 
-    hideVideo() {
-      this.last?.classList.add('show');
-    }
+  hideVideo() {
+    this.last?.classList.add('show');
+  }
 
-    setVideo(element: HTMLElement) {
-      this.video.innerHTML = '';
-      this.video.classList.remove('settled');
-      this.video.appendChild(element);
-    }
+  setVideo(element: HTMLElement) {
+    this.video.innerHTML = '';
+    this.video.classList.remove('settled');
+    this.video.appendChild(element);
+  }
 
-    private setBackgroundActual(background: string) {
-      if (background.indexOf('.') >= 0) {
-        this.next.style.backgroundImage = `url(${background})`;
-        this.next.style.backgroundColor = 'black';
-        this.next.classList.add('image');
-      } else {
-        this.next.style.backgroundColor = background;
-      }
-      this.next.classList.add('show');
-      if (this.last != null) {
-        const toRemove = this.last;
-        this.last.classList.remove('show');
-        this.next.addEventListener('transitionend', () => {
+  private setBackgroundActual(background: string) {
+    if (background.indexOf('.') >= 0) {
+      this.next.style.backgroundImage = `url(${background})`;
+      this.next.style.backgroundColor = 'black';
+      this.next.classList.add('image');
+    } else {
+      this.next.style.backgroundColor = background;
+    }
+    this.next.classList.add('show');
+    if (this.last != null) {
+      const toRemove = this.last;
+      this.last.classList.remove('show');
+      this.next.addEventListener(
+        'transitionend',
+        () => {
           this.element.removeChild(toRemove);
           this.video.classList.remove('show');
           this.video.innerHTML = '';
-        }, { once: true });
-      }
-      this.last = this.next;
-      this.next = document.createElement('div');
-      this.element.appendChild(this.next);
+        },
+        { once: true }
+      );
     }
+    this.last = this.next;
+    this.next = document.createElement('div');
+    this.element.appendChild(this.next);
   }
+}

+ 270 - 261
src/display.ts

@@ -11,324 +11,333 @@ import { TransitionResult } from './state';
 import * as level from './level';
 import * as util from './util';
 
-  class SingleKanaDisplayComponent {
-    element: HTMLElement;
-    finished: boolean;
-
-    constructor(kana: string) {
-      this.element = document.createElement('span');
-      this.element.classList.add('kana');
-      this.element.textContent = kana;
-      this.element.setAttribute('data-text', kana);
-      this.finished = false;
-    }
+class SingleKanaDisplayComponent {
+  element: HTMLElement;
+  finished: boolean;
+
+  constructor(kana: string) {
+    this.element = document.createElement('span');
+    this.element.classList.add('kana');
+    this.element.textContent = kana;
+    this.element.setAttribute('data-text', kana);
+    this.finished = false;
+  }
 
-    setPartial() {
-      if (!this.finished) {
-        this.element.classList.add('half');
-      }
+  setPartial() {
+    if (!this.finished) {
+      this.element.classList.add('half');
     }
+  }
 
-    setFull() {
-      this.finished = true;
-      this.element.classList.remove('half');
-      this.element.classList.add('full');
-    }
+  setFull() {
+    this.finished = true;
+    this.element.classList.remove('half');
+    this.element.classList.add('full');
   }
+}
 
-  class KanaMachineController {
-    state: state.StateMachine;
-    children: SingleKanaDisplayComponent[];
-    current: number;
+class KanaMachineController {
+  state: state.StateMachine;
+  children: SingleKanaDisplayComponent[];
+  current: number;
 
-    get elements() {
-      return this.children.map(kanaComponent => kanaComponent.element);
-    }
+  get elements() {
+    return this.children.map((kanaComponent) => kanaComponent.element);
+  }
 
-    constructor(kana: string, state: state.StateMachine) {
-      this.state = state;
-      this.current = 0;
-      this.state.addObserver(this.observer);
-      this.children = kana.split('').map(c => new SingleKanaDisplayComponent(c));
-    }
+  constructor(kana: string, state: state.StateMachine) {
+    this.state = state;
+    this.current = 0;
+    this.state.addObserver(this.observer);
+    this.children = kana
+      .split('')
+      .map((c) => new SingleKanaDisplayComponent(c));
+  }
 
-    observer: state.Observer = (result, boundary) => {
-      if (boundary) {
-        this.children[this.current].setFull();
-        this.current += 1;
-      } else if (result != TransitionResult.FAILED) {
-        this.children[this.current].setPartial();
-      }
+  observer: state.Observer = (result, boundary) => {
+    if (boundary) {
+      this.children[this.current].setFull();
+      this.current += 1;
+    } else if (result != TransitionResult.FAILED) {
+      this.children[this.current].setPartial();
     }
+  };
 
-    destroy(): void {
-      this.state.removeObserver(this.observer);
-    }
+  destroy(): void {
+    this.state.removeObserver(this.observer);
   }
+}
 
-  export class KanaDisplayController {
-    children: KanaMachineController[];
+export class KanaDisplayController {
+  children: KanaMachineController[];
 
-    constructor(readonly element: HTMLElement) {
-      this.children = [];
-    }
+  constructor(readonly element: HTMLElement) {
+    this.children = [];
+  }
 
-    setInputState(inputState: InputState | null) {
-      this.clearChildren();
-      if (inputState == null) {
-        this.children = [];
-      } else {
-        this.children = inputState.map((kana, machine) => {
-          return new KanaMachineController(kana, machine);
-        });
-        this.children.forEach(child => {
-          child.elements.forEach(kanaElement => {
-            this.element.appendChild(kanaElement);
-          });
+  setInputState(inputState: InputState | null) {
+    this.clearChildren();
+    if (inputState == null) {
+      this.children = [];
+    } else {
+      this.children = inputState.map((kana, machine) => {
+        return new KanaMachineController(kana, machine);
+      });
+      this.children.forEach((child) => {
+        child.elements.forEach((kanaElement) => {
+          this.element.appendChild(kanaElement);
         });
-      }
+      });
     }
+  }
 
-    private clearChildren(): void {
-      this.children.forEach(child => {
-        child.elements.forEach(kanaElement => {
-          this.element.removeChild(kanaElement);
-        });
-        child.destroy();
+  private clearChildren(): void {
+    this.children.forEach((child) => {
+      child.elements.forEach((kanaElement) => {
+        this.element.removeChild(kanaElement);
       });
-    }
+      child.destroy();
+    });
+  }
 
-    destroy(): void {
-      this.clearChildren();
-    }
+  destroy(): void {
+    this.clearChildren();
   }
+}
 
-  export class RomajiDisplayController {
-    inputState: InputState | null;
+export class RomajiDisplayController {
+  inputState: InputState | null;
 
-    constructor(
-      readonly firstElement: HTMLElement,
-      readonly restElement: HTMLElement
-    ) {
-      this.inputState = null;
-    }
+  constructor(
+    readonly firstElement: HTMLElement,
+    readonly restElement: HTMLElement
+  ) {
+    this.inputState = null;
+  }
 
-    setInputState(inputState: InputState | null) {
-      this.clearObservers();
-      this.inputState = inputState;
-      if (this.inputState != null) {
-        this.inputState.map((_, machine) => {
-          machine.addObserver(this.observer);
-        });
-        this.observer(TransitionResult.SUCCESS, false);
-      } else {
-        this.firstElement.textContent = '';
-        this.restElement.textContent = '';
-      }
+  setInputState(inputState: InputState | null) {
+    this.clearObservers();
+    this.inputState = inputState;
+    if (this.inputState != null) {
+      this.inputState.map((_, machine) => {
+        machine.addObserver(this.observer);
+      });
+      this.observer(TransitionResult.SUCCESS, false);
+    } else {
+      this.firstElement.textContent = '';
+      this.restElement.textContent = '';
     }
+  }
 
-    private clearObservers(): void {
-      if (this.inputState != null) {
-        this.inputState.map((_, machine) => {
-          machine.removeObserver(this.observer);
-        });
-      }
+  private clearObservers(): void {
+    if (this.inputState != null) {
+      this.inputState.map((_, machine) => {
+        machine.removeObserver(this.observer);
+      });
     }
+  }
 
-    observer: state.Observer = (result) => {
-      if (result === TransitionResult.FAILED) {
-        this.firstElement.classList.remove('error');
-        this.firstElement.offsetHeight; // trigger reflow
-        this.firstElement.classList.add('error');
-      } else if (this.inputState !== null) {
-        let remaining = this.inputState.getRemainingInput();
-        this.firstElement.textContent = remaining.charAt(0);
-        this.restElement.textContent = remaining.substring(1);
-      } else {
-        this.firstElement.textContent = '';
-        this.restElement.textContent = '';
-      }
+  observer: state.Observer = (result) => {
+    if (result === TransitionResult.FAILED) {
+      this.firstElement.classList.remove('error');
+      this.firstElement.offsetHeight; // trigger reflow
+      this.firstElement.classList.add('error');
+    } else if (this.inputState !== null) {
+      let remaining = this.inputState.getRemainingInput();
+      this.firstElement.textContent = remaining.charAt(0);
+      this.restElement.textContent = remaining.substring(1);
+    } else {
+      this.firstElement.textContent = '';
+      this.restElement.textContent = '';
     }
+  };
 
-    destroy(): void {
-      this.clearObservers();
-    }
+  destroy(): void {
+    this.clearObservers();
   }
+}
 
-  export class TrackProgressController {
-    totalBar: HTMLElement;
-    intervalBar: HTMLElement;
-    listener: ((event: AnimationPlaybackEvent) => void) | null;
+export class TrackProgressController {
+  totalBar: HTMLElement;
+  intervalBar: HTMLElement;
+  listener: ((event: AnimationPlaybackEvent) => void) | null;
 
-    constructor(private element: HTMLElement, private lines: level.Line[]) {
-      this.totalBar = util.getElement(element, '.total .shade');
-      this.intervalBar = util.getElement(element, '.interval .shade');
-      this.listener = null;
-    }
+  constructor(private element: HTMLElement, private lines: level.Line[]) {
+    this.totalBar = util.getElement(element, '.total .shade');
+    this.intervalBar = util.getElement(element, '.interval .shade');
+    this.listener = null;
+  }
 
-    start(start: number = 0): void {
-      this.clearAnimations();
-      const end = this.lines[this.lines.length - 1].end!;
-      const progress = start / end;
-      this.totalBar.animate({ width: [`${progress * 100}%`, '100%'] }, {
-        duration: (end - start) * 1000
-      });
+  start(start: number = 0): void {
+    this.clearAnimations();
+    const end = this.lines[this.lines.length - 1].end!;
+    const progress = start / end;
+    this.totalBar.animate(
+      { width: [`${progress * 100}%`, '100%'] },
+      {
+        duration: (end - start) * 1000,
+      }
+    );
 
-      for (const line of this.lines) {
-        if (line.end! <= start) {
-          continue;
-        }
-        const segmentStart = Math.max(line.start!, start);
-        const segmentLength = line.end! - segmentStart;
-        const fullSegmentLength = line.end! - line.start!;
-        const progress = 1 - segmentLength / fullSegmentLength;
-        const animation = this.intervalBar.animate({ width: [`${progress * 100}%`, '100%'] }, {
+    for (const line of this.lines) {
+      if (line.end! <= start) {
+        continue;
+      }
+      const segmentStart = Math.max(line.start!, start);
+      const segmentLength = line.end! - segmentStart;
+      const fullSegmentLength = line.end! - line.start!;
+      const progress = 1 - segmentLength / fullSegmentLength;
+      const animation = this.intervalBar.animate(
+        { width: [`${progress * 100}%`, '100%'] },
+        {
           delay: (segmentStart - start) * 1000,
           duration: segmentLength * 1000,
-        });
-        if (this.listener) {
-          animation.addEventListener('finish', this.listener);
         }
+      );
+      if (this.listener) {
+        animation.addEventListener('finish', this.listener);
       }
     }
-
-    pause(): void {
-      this.totalBar.getAnimations().forEach(anim => anim.pause());
-      this.intervalBar.getAnimations().forEach(anim => anim.pause());
-    }
-
-    setListener(func: (event: AnimationPlaybackEvent) => void): void {
-      this.listener = func;
-    }
-
-    destroy(): void {
-      this.clearAnimations();
-    }
-
-    private clearAnimations() {
-      this.totalBar.getAnimations().forEach(anim => anim.cancel());
-      this.intervalBar.getAnimations().forEach(anim => anim.cancel());
-    }
   }
 
-  export class Score {
-    combo: number = 0;
-    score: number = 0;
-    maxCombo: number = 0;
-    finished: number = 0;
-    hit: number = 0;
-    missed: number = 0;
-    skipped: number = 0;
-    lastMissed: boolean = false;
-    lastSkipped: boolean = false;
-
-    intervalEnd(finished: boolean): void {
-      if (finished) {
-        this.finished += 1;
-      } else {
-        this.combo = 0;
-      }
-    }
+  pause(): void {
+    this.totalBar.getAnimations().forEach((anim) => anim.pause());
+    this.intervalBar.getAnimations().forEach((anim) => anim.pause());
+  }
 
-    update(result: TransitionResult, boundary: boolean): void {
-      if (result === TransitionResult.FAILED) {
-        this.missed += 1;
-        this.lastMissed = true;
-        this.combo = 0;
-      } else if (result === TransitionResult.SKIPPED) {
-        this.skipped += 1;
-        this.lastSkipped = true;
-        this.combo = 0;
-      }
+  setListener(func: (event: AnimationPlaybackEvent) => void): void {
+    this.listener = func;
+  }
 
-      if (boundary) {
-        if (this.lastSkipped) {
-          // no points if we've skipped
-          this.lastSkipped = false;
-          return;
-        } else if (this.lastMissed) {
-          this.hit += 1;
-          this.score += 50;
-          this.lastMissed = false;
-        } else {
-          this.hit += 1;
-          this.score += 100 + this.combo;
-        }
-        this.combo += 1;
-      }
+  destroy(): void {
+    this.clearAnimations();
+  }
 
-      if (this.combo > this.maxCombo) {
-        this.maxCombo = this.combo;
-      }
+  private clearAnimations() {
+    this.totalBar.getAnimations().forEach((anim) => anim.cancel());
+    this.intervalBar.getAnimations().forEach((anim) => anim.cancel());
+  }
+}
+
+export class Score {
+  combo: number = 0;
+  score: number = 0;
+  maxCombo: number = 0;
+  finished: number = 0;
+  hit: number = 0;
+  missed: number = 0;
+  skipped: number = 0;
+  lastMissed: boolean = false;
+  lastSkipped: boolean = false;
+
+  intervalEnd(finished: boolean): void {
+    if (finished) {
+      this.finished += 1;
+    } else {
+      this.combo = 0;
     }
   }
 
-  export class ScoreController {
-    comboElement: HTMLElement;
-    scoreElement: HTMLElement;
-    maxComboElement: HTMLElement;
-    finishedElement: HTMLElement;
-    hitElement: HTMLElement;
-    missedElement: HTMLElement;
-    skippedElement: HTMLElement;
-
-    inputState: InputState | null = null;
-    score: Score;
-
-    constructor(
-      private scoreContainer: HTMLElement,
-      private statsContainer: HTMLElement
-    ) {
-      this.comboElement = util.getElement(scoreContainer, '.combo');
-      this.scoreElement = util.getElement(scoreContainer, '.score');
-      this.maxComboElement = util.getElement(scoreContainer, '.max-combo');
-      this.finishedElement = util.getElement(scoreContainer, '.finished');
-      this.hitElement = util.getElement(statsContainer, '.hit');
-      this.missedElement = util.getElement(statsContainer, '.missed');
-      this.skippedElement = util.getElement(statsContainer, '.skipped');
-      this.score = new Score();
-      this.setValues();
+  update(result: TransitionResult, boundary: boolean): void {
+    if (result === TransitionResult.FAILED) {
+      this.missed += 1;
+      this.lastMissed = true;
+      this.combo = 0;
+    } else if (result === TransitionResult.SKIPPED) {
+      this.skipped += 1;
+      this.lastSkipped = true;
+      this.combo = 0;
     }
 
-    setInputState(inputState: InputState | null): void {
-      this.clearObservers();
-      this.inputState = inputState;
-      if (this.inputState != null) {
-        this.inputState.map((_, m) => {
-          m.addObserver(this.observer);
-        });
+    if (boundary) {
+      if (this.lastSkipped) {
+        // no points if we've skipped
+        this.lastSkipped = false;
+        return;
+      } else if (this.lastMissed) {
+        this.hit += 1;
+        this.score += 50;
+        this.lastMissed = false;
+      } else {
+        this.hit += 1;
+        this.score += 100 + this.combo;
       }
+      this.combo += 1;
     }
 
-    intervalEnd(finished: boolean): void {
-      this.score.intervalEnd(finished);
-      this.setValues();
+    if (this.combo > this.maxCombo) {
+      this.maxCombo = this.combo;
     }
+  }
+}
+
+export class ScoreController {
+  comboElement: HTMLElement;
+  scoreElement: HTMLElement;
+  maxComboElement: HTMLElement;
+  finishedElement: HTMLElement;
+  hitElement: HTMLElement;
+  missedElement: HTMLElement;
+  skippedElement: HTMLElement;
+
+  inputState: InputState | null = null;
+  score: Score;
+
+  constructor(
+    private scoreContainer: HTMLElement,
+    private statsContainer: HTMLElement
+  ) {
+    this.comboElement = util.getElement(scoreContainer, '.combo');
+    this.scoreElement = util.getElement(scoreContainer, '.score');
+    this.maxComboElement = util.getElement(scoreContainer, '.max-combo');
+    this.finishedElement = util.getElement(scoreContainer, '.finished');
+    this.hitElement = util.getElement(statsContainer, '.hit');
+    this.missedElement = util.getElement(statsContainer, '.missed');
+    this.skippedElement = util.getElement(statsContainer, '.skipped');
+    this.score = new Score();
+    this.setValues();
+  }
 
-    observer: state.Observer = (result, boundary) => {
-      this.score.update(result, boundary);
-      this.setValues();
+  setInputState(inputState: InputState | null): void {
+    this.clearObservers();
+    this.inputState = inputState;
+    if (this.inputState != null) {
+      this.inputState.map((_, m) => {
+        m.addObserver(this.observer);
+      });
     }
+  }
 
-    setValues(): void {
-      this.comboElement.textContent = this.score.combo == 0 ? '' : this.score.combo+' combo';
-      this.scoreElement.textContent = this.score.score+'';
-      this.maxComboElement.textContent = this.score.maxCombo+'';
-      this.finishedElement.textContent = this.score.finished+'';
-      this.hitElement.textContent = this.score.hit+'';
-      this.missedElement.textContent = this.score.missed+'';
-      this.skippedElement.textContent = this.score.skipped+'';
-    }
+  intervalEnd(finished: boolean): void {
+    this.score.intervalEnd(finished);
+    this.setValues();
+  }
 
-    private clearObservers(): void {
-      if (this.inputState != null) {
-        this.inputState.map((_, machine) => {
-          machine.removeObserver(this.observer);
-        });
-      }
-    }
+  observer: state.Observer = (result, boundary) => {
+    this.score.update(result, boundary);
+    this.setValues();
+  };
+
+  setValues(): void {
+    this.comboElement.textContent =
+      this.score.combo == 0 ? '' : this.score.combo + ' combo';
+    this.scoreElement.textContent = this.score.score + '';
+    this.maxComboElement.textContent = this.score.maxCombo + '';
+    this.finishedElement.textContent = this.score.finished + '';
+    this.hitElement.textContent = this.score.hit + '';
+    this.missedElement.textContent = this.score.missed + '';
+    this.skippedElement.textContent = this.score.skipped + '';
+  }
 
-    destroy(): void {
-      this.clearObservers();
+  private clearObservers(): void {
+    if (this.inputState != null) {
+      this.inputState.map((_, machine) => {
+        machine.removeObserver(this.observer);
+      });
     }
   }
+
+  destroy(): void {
+    this.clearObservers();
+  }
+}

+ 8 - 4
src/editor.css

@@ -18,7 +18,8 @@ body {
   height: 60px;
 }
 
-#waveform, #waveform-overlay {
+#waveform,
+#waveform-overlay {
   position: absolute;
   height: 100%;
   width: 100%;
@@ -75,7 +76,9 @@ li.highlight {
   grid-row: 1 / 2;
 }
 
-#intervals, #kana, #kanji {
+#intervals,
+#kana,
+#kanji {
   grid-row: 2 / 3;
 }
 
@@ -92,14 +95,15 @@ li.highlight {
 
 #kanji,
 #kanji-label {
-  grid-columN: 3 / 4;
+  grid-column: 3 / 4;
 }
 
 #intervals input {
   width: 100px;
 }
 
-#kanji, #kana {
+#kanji,
+#kana {
   border: solid 1px lightgrey;
   min-height: 200px;
   white-space: pre;

+ 3 - 4
src/editor.html

@@ -1,4 +1,4 @@
-<!doctype html>
+<!DOCTYPE html>
 <html>
   <head>
     <title>Typing Freaks Editor</title>
@@ -18,8 +18,7 @@
             <div class="bar">
               <div class="bar-overlay"></div>
             </div>
-            <div class="markers">
-            </div>
+            <div class="markers"></div>
           </div>
           <div class="waveform-container">
             <canvas id="waveform"></canvas>
@@ -49,7 +48,7 @@
     </div>
     <template id="interval-template">
       <li>
-        <input class="interval" type="number" step="0.1">
+        <input class="interval" type="number" step="0.1" />
         <button class="play-section">Play</button>
         <button class="remove-section">Remove</button>
       </li>

+ 332 - 305
src/editor.ts

@@ -3,358 +3,385 @@ import * as level from './level';
 import * as util from './util';
 import * as youtube from './youtube';
 
-  export class Editor {
-    audioManager: audio.AudioManager;
-    urlElement: HTMLInputElement;
-    loadElement: HTMLButtonElement;
-    audioElement: HTMLInputElement;
-    barElement: HTMLElement;
-    markerListElement: HTMLElement;
-    intervalListElement: HTMLElement;
-    kanaElement: HTMLTextAreaElement;
-    kanjiElement: HTMLTextAreaElement;
-    displayElement: HTMLElement;
-    jsonElement: HTMLInputElement;
-    waveFormContainer: HTMLDivElement;
-    track: audio.Track | null = null;
-    markers: Marker[] = [];
-    waveForm: WaveForm;
-    currentMarker: Marker | null = null;
-
-    constructor() {
-      this.audioManager = new audio.AudioManager();
-      this.urlElement = util.getElement(document, '#url');
-      this.loadElement = util.getElement(document, '#load');
-      this.loadElement.addEventListener('click', event => {
-        this.loadAudio();
-      });
-      this.audioElement = util.getElement(document, '#audio');
-      this.audioElement.addEventListener('change', event => {
-        this.urlElement.value = '';
-        this.loadAudio();
-      });
-      this.barElement = util.getElement(document, '.bar-overlay');
-      this.markerListElement = util.getElement(document, '.markers');
-      this.intervalListElement = util.getElement(document, '#intervals');
-      this.kanaElement = util.getElement(document, '#kana');
-      this.kanjiElement = util.getElement(document, '#kanji');
-      this.displayElement = util.getElement(document, '#display');
-      this.jsonElement = util.getElement(document, '#json');
-      this.waveFormContainer = util.getElement(document, '.waveform-container');
-      this.waveForm = new WaveForm(
-        util.getElement(document, '#waveform'),
-        util.getElement(document, '#waveform-overlay'),
-        (time: number) => this.play(time)
+export class Editor {
+  audioManager: audio.AudioManager;
+  urlElement: HTMLInputElement;
+  loadElement: HTMLButtonElement;
+  audioElement: HTMLInputElement;
+  barElement: HTMLElement;
+  markerListElement: HTMLElement;
+  intervalListElement: HTMLElement;
+  kanaElement: HTMLTextAreaElement;
+  kanjiElement: HTMLTextAreaElement;
+  displayElement: HTMLElement;
+  jsonElement: HTMLInputElement;
+  waveFormContainer: HTMLDivElement;
+  track: audio.Track | null = null;
+  markers: Marker[] = [];
+  waveForm: WaveForm;
+  currentMarker: Marker | null = null;
+
+  constructor() {
+    this.audioManager = new audio.AudioManager();
+    this.urlElement = util.getElement(document, '#url');
+    this.loadElement = util.getElement(document, '#load');
+    this.loadElement.addEventListener('click', (event) => {
+      this.loadAudio();
+    });
+    this.audioElement = util.getElement(document, '#audio');
+    this.audioElement.addEventListener('change', (event) => {
+      this.urlElement.value = '';
+      this.loadAudio();
+    });
+    this.barElement = util.getElement(document, '.bar-overlay');
+    this.markerListElement = util.getElement(document, '.markers');
+    this.intervalListElement = util.getElement(document, '#intervals');
+    this.kanaElement = util.getElement(document, '#kana');
+    this.kanjiElement = util.getElement(document, '#kanji');
+    this.displayElement = util.getElement(document, '#display');
+    this.jsonElement = util.getElement(document, '#json');
+    this.waveFormContainer = util.getElement(document, '.waveform-container');
+    this.waveForm = new WaveForm(
+      util.getElement(document, '#waveform'),
+      util.getElement(document, '#waveform-overlay'),
+      (time: number) => this.play(time)
+    );
+
+    this.markerListElement.addEventListener('click', (event: MouseEvent) =>
+      this.markersClick(event)
+    );
+    document
+      .querySelector('#play')!
+      .addEventListener('click', () => this.play());
+    document
+      .querySelector('#pause')!
+      .addEventListener('click', () => this.pause());
+    document
+      .querySelector('#insert-marker')!
+      .addEventListener('click', () => this.insertMarker());
+    document
+      .querySelector<HTMLElement>('.bar')!
+      .addEventListener('click', (event: MouseEvent) =>
+        this.scrubberClick(event)
       );
+    document
+      .querySelector('#import')!
+      .addEventListener('click', () => this.import());
+    document
+      .querySelector('#export')!
+      .addEventListener('click', () => this.export());
+
+    this.update();
+  }
 
-      this.markerListElement.addEventListener('click', (event: MouseEvent) => this.markersClick(event));
-      document.querySelector('#play')!.addEventListener('click', () => this.play());
-      document.querySelector('#pause')!.addEventListener('click', () => this.pause());
-      document.querySelector('#insert-marker')!.addEventListener('click', () => this.insertMarker());
-      document.querySelector<HTMLElement>('.bar')!.addEventListener('click', (event: MouseEvent) => this.scrubberClick(event));
-      document.querySelector('#import')!.addEventListener('click', () => this.import());
-      document.querySelector('#export')!.addEventListener('click', () => this.export());
-
-      this.update();
-    }
-
-    loadAudio(): void {
-      const url = this.urlElement.value;
-      if (url != '') {
-        const videoId = youtube.getVideoId(url);
-        if (videoId !== null) {
-          const element = util.getElement(document, '#youtube');
-          this.audioManager.loadTrackFromYoutube(videoId, element, () => {}).then(t => {
+  loadAudio(): void {
+    const url = this.urlElement.value;
+    if (url != '') {
+      const videoId = youtube.getVideoId(url);
+      if (videoId !== null) {
+        const element = util.getElement(document, '#youtube');
+        this.audioManager
+          .loadTrackFromYoutube(videoId, element, () => {})
+          .then((t) => {
             this.track = t;
             this.waveForm.clear();
             this.waveFormContainer.style.display = 'none';
           });
-          return;
-        }
-      }
-
-      let file = this.audioElement.files![0];
-      if (file != null) {
-        if (this.track != null) {
-          this.track.stop();
-        }
-        this.clearMarkers();
-        this.audioManager.loadTrackFromFile(file).then(t => {
-          this.track = t;
-          this.waveForm.setTrack(t);
-          this.waveFormContainer.style.display = 'block';
-        });
+        return;
       }
     }
 
-    update(): void {
+    let file = this.audioElement.files![0];
+    if (file != null) {
       if (this.track != null) {
-        let percentage = this.track.getTime() / this.track.getDuration() * 100;
-        this.barElement.style.width = `${percentage}%`;
-        if (this.track instanceof audio.FileTrack) {
-          this.waveForm.update(this.markers);
-        }
-        if (this.currentMarker) {
-          this.currentMarker.liElement.className = '';
-        }
-        let index = this.markers.findIndex(m => m.time > this.track!.getTime());
-        if (index < 0) index = 0;
-        this.currentMarker = this.markers[index - 1];
-        if (this.currentMarker) {
-          this.currentMarker.liElement.className = 'highlight';
-        }
-        let text = this.kanjiElement.value.split('\n')[index] || '';
-        this.displayElement.textContent = text;
+        this.track.stop();
       }
-      requestAnimationFrame(() => this.update());
-    }
-
-    scrubberClick(event: MouseEvent): void {
-      let pos = event.clientX - 10;
-      console.log(pos);
-      let percentage = pos / this.markerListElement.clientWidth;
-      let targetTime = percentage * this.track!.getDuration();
-      this.play(targetTime);
-    }
-
-    markersClick(event: MouseEvent): void {
-      let pos = event.clientX - 10;
-      let percentage = pos / this.markerListElement.clientWidth;
-      let targetTime = percentage * this.track!.getDuration();
-      this.insertMarker(targetTime);
+      this.clearMarkers();
+      this.audioManager.loadTrackFromFile(file).then((t) => {
+        this.track = t;
+        this.waveForm.setTrack(t);
+        this.waveFormContainer.style.display = 'block';
+      });
     }
+  }
 
-    insertMarker(time?: number): void {
-      let marker = new Marker(
-        this.track!.getDuration(),
-        (marker: Marker) => this.removeMarker(marker),
-        (marker: Marker) => this.playMarker(marker)
-      );
-      if (time !== undefined) {
-        marker.time = time;
-      } else {
-        marker.time = this.track!.getTime();
+  update(): void {
+    if (this.track != null) {
+      let percentage = (this.track.getTime() / this.track.getDuration()) * 100;
+      this.barElement.style.width = `${percentage}%`;
+      if (this.track instanceof audio.FileTrack) {
+        this.waveForm.update(this.markers);
       }
-      let insertIndex = this.markers.findIndex(m => m.time > marker.time);
-      if (insertIndex >= 0) {
-        this.markers.splice(insertIndex, 0, marker);
-      } else {
-        this.markers.push(marker);
+      if (this.currentMarker) {
+        this.currentMarker.liElement.className = '';
       }
-      this.markerListElement.appendChild(marker.markerElement);
-      if (insertIndex >= 0) {
-        this.intervalListElement.insertBefore(marker.liElement, this.markers[insertIndex+1].liElement);
-      } else {
-        this.intervalListElement.appendChild(marker.liElement);
+      let index = this.markers.findIndex((m) => m.time > this.track!.getTime());
+      if (index < 0) index = 0;
+      this.currentMarker = this.markers[index - 1];
+      if (this.currentMarker) {
+        this.currentMarker.liElement.className = 'highlight';
       }
+      let text = this.kanjiElement.value.split('\n')[index] || '';
+      this.displayElement.textContent = text;
     }
+    requestAnimationFrame(() => this.update());
+  }
 
-    play(start?: number, duration?: number): void {
-      this.track!.pause();
-      this.track!.start(start, duration);
-    }
+  scrubberClick(event: MouseEvent): void {
+    let pos = event.clientX - 10;
+    console.log(pos);
+    let percentage = pos / this.markerListElement.clientWidth;
+    let targetTime = percentage * this.track!.getDuration();
+    this.play(targetTime);
+  }
 
-    pause(): void {
-      this.track!.pause();
+  markersClick(event: MouseEvent): void {
+    let pos = event.clientX - 10;
+    let percentage = pos / this.markerListElement.clientWidth;
+    let targetTime = percentage * this.track!.getDuration();
+    this.insertMarker(targetTime);
+  }
+
+  insertMarker(time?: number): void {
+    let marker = new Marker(
+      this.track!.getDuration(),
+      (marker: Marker) => this.removeMarker(marker),
+      (marker: Marker) => this.playMarker(marker)
+    );
+    if (time !== undefined) {
+      marker.time = time;
+    } else {
+      marker.time = this.track!.getTime();
+    }
+    let insertIndex = this.markers.findIndex((m) => m.time > marker.time);
+    if (insertIndex >= 0) {
+      this.markers.splice(insertIndex, 0, marker);
+    } else {
+      this.markers.push(marker);
+    }
+    this.markerListElement.appendChild(marker.markerElement);
+    if (insertIndex >= 0) {
+      this.intervalListElement.insertBefore(
+        marker.liElement,
+        this.markers[insertIndex + 1].liElement
+      );
+    } else {
+      this.intervalListElement.appendChild(marker.liElement);
     }
+  }
 
-    playMarker(marker: Marker): void {
-      let start = marker.time;
-      let end = this.track!.getDuration();
-      let index = this.markers.findIndex(m => m == marker);
-      if (index < this.markers.length - 1) {
-        end = this.markers[index + 1].time;
-      }
-      let duration = end - start;
-      this.play(start, duration);
+  play(start?: number, duration?: number): void {
+    this.track!.pause();
+    this.track!.start(start, duration);
+  }
 
-      this.highlightLine(this.kanjiElement, index + 1);
-    }
+  pause(): void {
+    this.track!.pause();
+  }
 
-    removeMarker(marker: Marker): void {
-      let index = this.markers.findIndex(m => m == marker);
-      this.markers.splice(index, 1);
-      this.markerListElement.removeChild(marker.markerElement);
-      this.intervalListElement.removeChild(marker.liElement);
+  playMarker(marker: Marker): void {
+    let start = marker.time;
+    let end = this.track!.getDuration();
+    let index = this.markers.findIndex((m) => m == marker);
+    if (index < this.markers.length - 1) {
+      end = this.markers[index + 1].time;
     }
+    let duration = end - start;
+    this.play(start, duration);
 
-    clearMarkers(): void {
-      this.markers.forEach(m => {
-        this.markerListElement.removeChild(m.markerElement);
-        this.intervalListElement.removeChild(m.liElement);
-      });
-      this.markers = [];
-    }
+    this.highlightLine(this.kanjiElement, index + 1);
+  }
 
-    highlightLine(element: HTMLTextAreaElement, line: number) {
-      let text = element.value;
-      let index = 0;
-      for (let i = 0; i < line; ++i) {
-        index = text.indexOf('\n', index + 1);
-      }
-      let endIndex = text.indexOf('\n', index + 1);
-      element.focus();
-      element.setSelectionRange(index, endIndex);
-    }
+  removeMarker(marker: Marker): void {
+    let index = this.markers.findIndex((m) => m == marker);
+    this.markers.splice(index, 1);
+    this.markerListElement.removeChild(marker.markerElement);
+    this.intervalListElement.removeChild(marker.liElement);
+  }
 
-    import(): void {
-      this.clearMarkers();
-      let lines: level.Line[] = JSON.parse(this.jsonElement.value);
-      let kanji = '';
-      let kana = '';
-
-      lines.forEach(line => {
-        kanji += line.kanji + '\n';
-        kana += line.kana + '\n';
-        if (line.end != undefined) {
-          this.insertMarker(line.end);
-        }
-      });
+  clearMarkers(): void {
+    this.markers.forEach((m) => {
+      this.markerListElement.removeChild(m.markerElement);
+      this.intervalListElement.removeChild(m.liElement);
+    });
+    this.markers = [];
+  }
 
-      this.kanjiElement.value = kanji;
-      this.kanaElement.value = kana;
+  highlightLine(element: HTMLTextAreaElement, line: number) {
+    let text = element.value;
+    let index = 0;
+    for (let i = 0; i < line; ++i) {
+      index = text.indexOf('\n', index + 1);
     }
+    let endIndex = text.indexOf('\n', index + 1);
+    element.focus();
+    element.setSelectionRange(index, endIndex);
+  }
 
-    export(): void {
-      let kanji = this.kanjiElement.value.split('\n');
-      let kana = this.kanaElement.value.split('\n');
-      let length = Math.max(kanji.length, kana.length, this.markers.length - 1);
-
-      let lines = [];
-      let lastStart = 0;
-      for (let i = 0; i < length; ++i) {
-        let data: level.Line = {
-          kanji: kanji[i] || '@',
-          kana: kana[i] || '@',
-        }
-        if (this.markers[i]) {
-          data.start = lastStart;
-          data.end = this.markers[i].time;
-          lastStart = data.end;
-        }
-        lines.push(data);
+  import(): void {
+    this.clearMarkers();
+    let lines: level.Line[] = JSON.parse(this.jsonElement.value);
+    let kanji = '';
+    let kana = '';
+
+    lines.forEach((line) => {
+      kanji += line.kanji + '\n';
+      kana += line.kana + '\n';
+      if (line.end != undefined) {
+        this.insertMarker(line.end);
       }
+    });
 
-      this.jsonElement.value = JSON.stringify(lines);
-    }
+    this.kanjiElement.value = kanji;
+    this.kanaElement.value = kana;
+  }
 
-    start(): void {
-      this.loadAudio();
+  export(): void {
+    let kanji = this.kanjiElement.value.split('\n');
+    let kana = this.kanaElement.value.split('\n');
+    let length = Math.max(kanji.length, kana.length, this.markers.length - 1);
+
+    let lines = [];
+    let lastStart = 0;
+    for (let i = 0; i < length; ++i) {
+      let data: level.Line = {
+        kanji: kanji[i] || '@',
+        kana: kana[i] || '@',
+      };
+      if (this.markers[i]) {
+        data.start = lastStart;
+        data.end = this.markers[i].time;
+        lastStart = data.end;
+      }
+      lines.push(data);
     }
-  }
 
-  class Marker {
-    markerElement: HTMLElement;
-    liElement: HTMLElement;
-    inputElement: HTMLInputElement;
-
-    constructor(
-      readonly duration: number,
-      readonly remove: (marker: Marker) => void,
-      readonly play: (marker: Marker) => void
-    ) {
-      this.markerElement = document.createElement('div');
-      this.markerElement.className = 'marker';
-
-      let fragment = util.loadTemplate(document, 'interval');
-      this.liElement = util.getElement(fragment, '*');
-      this.inputElement = util.getElement(fragment, '.interval');
-      this.inputElement.addEventListener('change', () => {
-        this.time = parseFloat(this.inputElement.value);
-      });
+    this.jsonElement.value = JSON.stringify(lines);
+  }
 
-      fragment.querySelector('.play-section')!.addEventListener('click', () => play(this));
-      fragment.querySelector('.remove-section')!.addEventListener('click', () => remove(this));
-    }
+  start(): void {
+    this.loadAudio();
+  }
+}
+
+class Marker {
+  markerElement: HTMLElement;
+  liElement: HTMLElement;
+  inputElement: HTMLInputElement;
+
+  constructor(
+    readonly duration: number,
+    readonly remove: (marker: Marker) => void,
+    readonly play: (marker: Marker) => void
+  ) {
+    this.markerElement = document.createElement('div');
+    this.markerElement.className = 'marker';
+
+    let fragment = util.loadTemplate(document, 'interval');
+    this.liElement = util.getElement(fragment, '*');
+    this.inputElement = util.getElement(fragment, '.interval');
+    this.inputElement.addEventListener('change', () => {
+      this.time = parseFloat(this.inputElement.value);
+    });
+
+    fragment
+      .querySelector('.play-section')!
+      .addEventListener('click', () => play(this));
+    fragment
+      .querySelector('.remove-section')!
+      .addEventListener('click', () => remove(this));
+  }
 
-    get time(): number {
-      return parseFloat(this.inputElement.value);
-    }
+  get time(): number {
+    return parseFloat(this.inputElement.value);
+  }
 
-    set time(t: number) {
-      this.inputElement.value = t.toFixed(1);
-      let percentage = t * 100 / this.duration;
-      this.markerElement.style.left = `${percentage}%`;
-    }
+  set time(t: number) {
+    this.inputElement.value = t.toFixed(1);
+    let percentage = (t * 100) / this.duration;
+    this.markerElement.style.left = `${percentage}%`;
+  }
+}
+
+class WaveForm {
+  ctx: CanvasRenderingContext2D;
+  overlayCtx: CanvasRenderingContext2D;
+  track: audio.FileTrack | null = null;
+  data: Float32Array | null = null;
+  stride: number = 0;
+  currentSection: number = -1;
+
+  constructor(
+    readonly canvas: HTMLCanvasElement,
+    readonly overlay: HTMLCanvasElement,
+    readonly setTime: (time: number) => void
+  ) {
+    canvas.height = canvas.clientHeight;
+    canvas.width = canvas.clientWidth;
+    overlay.height = overlay.clientHeight;
+    overlay.width = overlay.clientWidth;
+    this.ctx = canvas.getContext('2d')!;
+    this.overlayCtx = overlay.getContext('2d')!;
+
+    this.overlayCtx.fillStyle = 'rgba(255, 0, 0, 0.5)';
+
+    this.overlay.addEventListener('click', (event: MouseEvent) => {
+      let pos = event.clientX - this.overlay.offsetLeft;
+      let percentage = pos / this.overlay.width;
+      let time = this.currentSection * 5 + percentage * 5;
+      this.setTime(time);
+    });
   }
 
-  class WaveForm {
-    ctx: CanvasRenderingContext2D;
-    overlayCtx: CanvasRenderingContext2D;
-    track: audio.FileTrack | null = null;
-    data: Float32Array | null = null;
-    stride: number = 0;
-    currentSection: number = -1;
-
-    constructor(
-      readonly canvas: HTMLCanvasElement,
-      readonly overlay: HTMLCanvasElement,
-      readonly setTime: (time: number) => void
-    ) {
-      canvas.height = canvas.clientHeight;
-      canvas.width = canvas.clientWidth;
-      overlay.height = overlay.clientHeight;
-      overlay.width = overlay.clientWidth;
-      this.ctx = canvas.getContext('2d')!;
-      this.overlayCtx = overlay.getContext('2d')!;
-
-      this.overlayCtx.fillStyle = 'rgba(255, 0, 0, 0.5)';
-
-      this.overlay.addEventListener('click', (event: MouseEvent) => {
-        let pos = event.clientX - this.overlay.offsetLeft;
-        let percentage = pos / this.overlay.width;
-        let time = this.currentSection * 5 + percentage * 5;
-        this.setTime(time);
-      });
-    }
+  clear(): void {
+    this.track = null;
+  }
 
-    clear(): void {
-      this.track = null;
-    }
+  setTrack(track: audio.FileTrack): void {
+    this.track = track;
+    this.stride = Math.floor(
+      (this.track.buffer.sampleRate / this.canvas.width) * 5
+    );
+    this.currentSection = -1;
+  }
 
-    setTrack(track: audio.FileTrack): void {
-      this.track = track;
-      this.stride = Math.floor(this.track.buffer.sampleRate / this.canvas.width * 5);
-      this.currentSection = -1;
-    }
+  timeToX(time: number): number {
+    return ((time - this.currentSection * 5) / 5) * this.canvas.width;
+  }
 
-    timeToX(time: number): number {
-      return (time - this.currentSection * 5) / 5 * this.canvas.width
+  update(markers: Marker[]): void {
+    let section = Math.floor(this.track!.getTime() / 5);
+
+    let height = this.canvas.height;
+    if (this.currentSection != section) {
+      this.data = this.track!.buffer.getChannelData(0);
+      this.ctx.clearRect(0, 0, this.canvas.width, height);
+      this.ctx.beginPath();
+      this.ctx.moveTo(0, height / 2);
+      let offset = section * this.canvas.width * this.stride;
+      for (let i = 0; i < this.canvas.width; ++i) {
+        let index = offset + i * this.stride;
+        let value = height / 2 + (height / 2) * this.data[index];
+        this.ctx.lineTo(i, value);
+      }
+      this.ctx.stroke();
+      this.currentSection = section;
     }
 
-    update(markers: Marker[]): void {
-      let section = Math.floor(this.track!.getTime() / 5);
-
-      let height = this.canvas.height;
-      if (this.currentSection != section) {
-        this.data = this.track!.buffer.getChannelData(0);
-        this.ctx.clearRect(0, 0, this.canvas.width, height);
-        this.ctx.beginPath();
-        this.ctx.moveTo(0, height / 2);
-        let offset = section * this.canvas.width * this.stride;
-        for (let i = 0; i < this.canvas.width; ++i) {
-          let index = offset + i * this.stride;
-          let value = height / 2 + height / 2 * this.data[index];
-          this.ctx.lineTo(i, value);
-        }
-        this.ctx.stroke();
-        this.currentSection = section;
+    let marker = this.timeToX(this.track!.getTime());
+    this.overlayCtx.clearRect(0, 0, this.canvas.width, height);
+    this.overlayCtx.fillRect(0, 0, marker, height);
+    markers.forEach((m) => {
+      if (m.time > section * 5 && m.time <= section * 5 + 5) {
+        let x = this.timeToX(m.time);
+        this.overlayCtx.beginPath();
+        this.overlayCtx.moveTo(x, 0);
+        this.overlayCtx.lineTo(x, height);
+        this.overlayCtx.stroke();
       }
-
-      let marker = this.timeToX(this.track!.getTime());
-      this.overlayCtx.clearRect(0, 0, this.canvas.width, height);
-      this.overlayCtx.fillRect(0, 0, marker, height);
-      markers.forEach(m => {
-        if (m.time > section * 5 && m.time <= section * 5 + 5) {
-          let x = this.timeToX(m.time);
-          this.overlayCtx.beginPath();
-          this.overlayCtx.moveTo(x, 0);
-          this.overlayCtx.lineTo(x, height);
-          this.overlayCtx.stroke();
-        }
-      })
-    }
+    });
   }
+}
 
 let e = new Editor();
 e.start();

+ 44 - 48
src/game.ts

@@ -3,56 +3,52 @@ import * as background from './background';
 import * as util from './util';
 import * as polyfill from './polyfill';
 
-import {
-  GameContext,
-  Screen,
-  ScreenManager,
-} from './game/common';
+import { GameContext, Screen, ScreenManager } from './game/common';
 import { LoadingScreen } from './game/loading';
 
-  export class MainController extends ScreenManager {
-    loadingScreen: Screen;
-
-    constructor(container: HTMLElement, configUrl: string) {
-      super(container);
-      container.appendChild(util.loadTemplate(container, 'base'));
-
-      let self = this;
-      let bgLayer: HTMLElement = util.getElement(container, '#background');
-      let gameContext: GameContext = {
-        container: container,
-        audioManager: new audio.AudioManager(),
-        bgManager: new background.BackgroundManager(bgLayer),
-        loadTemplate: (id: string) => util.loadTemplate(container, id),
-        assets: null,
-        config: null,
-        switchScreen(screen: Screen): void {
-          self.switchScreen(screen);
-        }
+export class MainController extends ScreenManager {
+  loadingScreen: Screen;
+
+  constructor(container: HTMLElement, configUrl: string) {
+    super(container);
+    container.appendChild(util.loadTemplate(container, 'base'));
+
+    let self = this;
+    let bgLayer: HTMLElement = util.getElement(container, '#background');
+    let gameContext: GameContext = {
+      container: container,
+      audioManager: new audio.AudioManager(),
+      bgManager: new background.BackgroundManager(bgLayer),
+      loadTemplate: (id: string) => util.loadTemplate(container, id),
+      assets: null,
+      config: null,
+      switchScreen(screen: Screen): void {
+        self.switchScreen(screen);
+      },
+    };
+
+    this.loadingScreen = new LoadingScreen(gameContext, configUrl);
+
+    document.addEventListener('keydown', (event) => {
+      if (event.altKey && event.key === 'Enter') {
+        polyfill.fullscreen.request(this.container);
       }
+      if (this.activeScreen !== null && !event.ctrlKey && !event.metaKey) {
+        this.activeScreen.handleInput(event.key);
+      }
+    });
+
+    polyfill.fullscreen.addEventListener(() => {
+      this.onResize();
+    });
+  }
+
+  start(): void {
+    this.switchScreen(this.loadingScreen);
+  }
 
-      this.loadingScreen = new LoadingScreen(gameContext, configUrl);
-
-      document.addEventListener('keydown', (event) => {
-        if (event.altKey && event.key === 'Enter') {
-          polyfill.fullscreen.request(this.container);
-        }
-        if (this.activeScreen !== null && !event.ctrlKey && !event.metaKey) {
-          this.activeScreen.handleInput(event.key);
-        }
-      });
-
-      polyfill.fullscreen.addEventListener(() => {
-        this.onResize();
-      });
-    }
-
-    start(): void {
-      this.switchScreen(this.loadingScreen);
-    }
-
-    onResize(): void {
-      const fontSize = this.container.offsetHeight / 28.125;
-      this.container.style.setProperty('--base-font-size', `${fontSize}px`);
-    }
+  onResize(): void {
+    const fontSize = this.container.offsetHeight / 28.125;
+    this.container.style.setProperty('--base-font-size', `${fontSize}px`);
   }
+}

+ 52 - 49
src/game/common.ts

@@ -2,65 +2,68 @@ import * as audio from '../audio';
 import * as background from '../background';
 import * as level from '../level';
 
-  export interface Screen {
-    readonly name: string;
-    handleInput(key: string): void;
-    enter(): void;
-    exit(): void;
-    transitionExit(): void;
-  }
+export interface Screen {
+  readonly name: string;
+  handleInput(key: string): void;
+  enter(): void;
+  exit(): void;
+  transitionExit(): void;
+}
 
-  export class ScreenManager {
-    activeScreen: Screen | null = null;
-    lastScreen: Screen | null = null;
-    pendingExit: boolean = false;
+export class ScreenManager {
+  activeScreen: Screen | null = null;
+  lastScreen: Screen | null = null;
+  pendingExit: boolean = false;
 
-    constructor(readonly container: HTMLElement) {
-      this.container.addEventListener('transitionend', (event: TransitionEvent) => {
+  constructor(readonly container: HTMLElement) {
+    this.container.addEventListener(
+      'transitionend',
+      (event: TransitionEvent) => {
         if (this.pendingExit && event.propertyName === 'opacity') {
           this.finishExit();
         }
-      });
-    }
-
-    switchScreen(nextScreen: Screen | null): void {
-      if (this.pendingExit) {
-        this.finishExit();
-      }
-      if (this.activeScreen != null) {
-        this.container.classList.remove(this.activeScreen.name);
-        this.pendingExit = true;
-        this.lastScreen = this.activeScreen;
-        this.activeScreen.exit();
       }
-      this.activeScreen = nextScreen;
-      if (nextScreen != null) {
-        nextScreen.enter();
-        this.container.classList.add(nextScreen.name);
-      }
-    }
+    );
+  }
 
-    finishExit() {
-      this.pendingExit = false;
-      if (this.lastScreen !== null) {
-        this.lastScreen.transitionExit();
-        this.lastScreen = null;
-      }
+  switchScreen(nextScreen: Screen | null): void {
+    if (this.pendingExit) {
+      this.finishExit();
+    }
+    if (this.activeScreen != null) {
+      this.container.classList.remove(this.activeScreen.name);
+      this.pendingExit = true;
+      this.lastScreen = this.activeScreen;
+      this.activeScreen.exit();
+    }
+    this.activeScreen = nextScreen;
+    if (nextScreen != null) {
+      nextScreen.enter();
+      this.container.classList.add(nextScreen.name);
     }
   }
 
-  interface GameSounds {
-    selectSound: audio.Track | null,
-    decideSound: audio.Track | null
+  finishExit() {
+    this.pendingExit = false;
+    if (this.lastScreen !== null) {
+      this.lastScreen.transitionExit();
+      this.lastScreen = null;
+    }
   }
+}
 
-  export interface GameContext {
-    container: HTMLElement;
-    audioManager: audio.AudioManager;
-    bgManager: background.BackgroundManager;
-    loadTemplate: (id: string) => DocumentFragment;
-    assets: GameSounds | null;
-    config: level.Config | null;
+interface GameSounds {
+  selectSound: audio.Track | null;
+  decideSound: audio.Track | null;
+}
 
-    switchScreen(screen: Screen): void;
-  }
+export interface GameContext {
+  container: HTMLElement;
+  audioManager: audio.AudioManager;
+  bgManager: background.BackgroundManager;
+  loadTemplate: (id: string) => DocumentFragment;
+  assets: GameSounds | null;
+  config: level.Config | null;
+
+  switchScreen(screen: Screen): void;
+}

+ 61 - 58
src/game/loading.ts

@@ -4,73 +4,76 @@ import * as util from '../util';
 import { GameContext, Screen } from './common';
 import { SelectScreen } from './select';
 
-  export class LoadingScreen implements Screen {
-    readonly name: string = 'loading';
+export class LoadingScreen implements Screen {
+  readonly name: string = 'loading';
 
-    constructor(private context: GameContext, private configUrl: string) {}
+  constructor(private context: GameContext, private configUrl: string) {}
 
-    enter(): void {
-      console.log('Loading assets...');
-      let configPromise;
-      if (this.configUrl.endsWith('.json')) {
-        configPromise = level.loadFromJson(this.configUrl);
-      } else {
-        configPromise = level.loadFromTM(this.configUrl);
-      }
-      configPromise.then(config => {
-        this.context.config = config;
-        this.loadAssets();
-      })
+  enter(): void {
+    console.log('Loading assets...');
+    let configPromise;
+    if (this.configUrl.endsWith('.json')) {
+      configPromise = level.loadFromJson(this.configUrl);
+    } else {
+      configPromise = level.loadFromTM(this.configUrl);
     }
+    configPromise.then((config) => {
+      this.context.config = config;
+      this.loadAssets();
+    });
+  }
 
-    loadAssets(): void {
-      let config = this.context.config!;
-
-      Promise.all([
-        util.loadBackground(config.background),
-        this.loadTrack(config.selectSound),
-        this.loadTrack(config.decideSound)
-      ]).then(v => {
-        console.log('Loaded assets.');
-        let [background, selectSound, decideSound] = v;
-        this.context.assets = {
-          selectSound,
-          decideSound
-        }
-        this.finishLoading();
-      })
-    }
+  loadAssets(): void {
+    let config = this.context.config!;
 
-    finishLoading(): void {
-      let loadingElement: HTMLElement = util.getElement(this.context.container, '#loading');
-      loadingElement.addEventListener('transitionend', (event) => {
-        loadingElement.style.display = 'none';
-        this.switchToSelect()
-      });
-      loadingElement.classList.add('finished');
-    }
+    Promise.all([
+      util.loadBackground(config.background),
+      this.loadTrack(config.selectSound),
+      this.loadTrack(config.decideSound),
+    ]).then((v) => {
+      console.log('Loaded assets.');
+      let [background, selectSound, decideSound] = v;
+      this.context.assets = {
+        selectSound,
+        decideSound,
+      };
+      this.finishLoading();
+    });
+  }
 
-    loadTrack(url: string): Promise<audio.Track | null> {
-      if (url == null) {
-        return Promise.resolve(null);
-      } else {
-        return this.context.audioManager.loadTrack(url);
-      }
-    }
+  finishLoading(): void {
+    let loadingElement: HTMLElement = util.getElement(
+      this.context.container,
+      '#loading'
+    );
+    loadingElement.addEventListener('transitionend', (event) => {
+      loadingElement.style.display = 'none';
+      this.switchToSelect();
+    });
+    loadingElement.classList.add('finished');
+  }
 
-    switchToSelect(): void {
-      let selectScreen = new SelectScreen(this.context);
-      this.context.switchScreen(selectScreen);
+  loadTrack(url: string): Promise<audio.Track | null> {
+    if (url == null) {
+      return Promise.resolve(null);
+    } else {
+      return this.context.audioManager.loadTrack(url);
     }
+  }
 
-    handleInput(key: string): void {}
+  switchToSelect(): void {
+    let selectScreen = new SelectScreen(this.context);
+    this.context.switchScreen(selectScreen);
+  }
 
-    exit(): void {
-      let config = this.context.config!;
-      let containerStyle = this.context.container.style;
-      containerStyle.setProperty('--base-color', config.baseColor);
-      containerStyle.setProperty('--highlight-color', config.highlightColor);
-    }
+  handleInput(key: string): void {}
 
-    transitionExit(): void {}
+  exit(): void {
+    let config = this.context.config!;
+    let containerStyle = this.context.container.style;
+    containerStyle.setProperty('--base-color', config.baseColor);
+    containerStyle.setProperty('--highlight-color', config.highlightColor);
   }
+
+  transitionExit(): void {}
+}

+ 203 - 206
src/game/select.ts

@@ -1,239 +1,236 @@
 import * as level from '../level';
 import * as util from '../util';
-import {
-  GameContext,
-  Screen,
-} from './common';
-import {
-  TypingScreen
-} from './typing';
-
-  export class SelectScreen implements Screen {
-    readonly name: string = 'select';
-    folderInfo: HTMLElement;
-    songInfo: HTMLElement;
-    songList: HTMLElement;
-    currentFolderIndex: number;
-    folderController: FolderSelectController;
-    listControllers: SongListController[];
-    init: boolean;
-
-    get levelSets() {
-      return this.context.config!.levelSets;
-    }
+import { GameContext, Screen } from './common';
+import { TypingScreen } from './typing';
+
+export class SelectScreen implements Screen {
+  readonly name: string = 'select';
+  folderInfo: HTMLElement;
+  songInfo: HTMLElement;
+  songList: HTMLElement;
+  currentFolderIndex: number;
+  folderController: FolderSelectController;
+  listControllers: SongListController[];
+  init: boolean;
+
+  get levelSets() {
+    return this.context.config!.levelSets;
+  }
 
-    get currentLevelSet() {
-      return this.levelSets[this.currentFolderIndex];
-    }
+  get currentLevelSet() {
+    return this.levelSets[this.currentFolderIndex];
+  }
 
-    get activeListController() {
-      return this.listControllers[this.currentFolderIndex];
-    }
+  get activeListController() {
+    return this.listControllers[this.currentFolderIndex];
+  }
 
-    constructor(private context: GameContext) {
-      let container = context.container;
-      this.folderInfo = util.getElement(container, '#folder-info');
-      this.currentFolderIndex = 0;
-      this.songInfo = util.getElement(container, '#song-info');
-      this.songList = util.getElement(container, '#song-list');
-
-      this.listControllers = [];
-      this.levelSets.forEach(levelSet => {
-        let controller = new SongListController(
-          this.context,
-          levelSet.levels,
-          (index) => this.selectSong(index),
-          (index) => this.chooseSong(index)
-        );
-        this.listControllers.push(controller);
-      });
-
-      this.init = true;
-
-      this.folderController = new FolderSelectController(
-        this.folderInfo,
-        this.levelSets,
-        (index) => this.selectLevelSet(index)
+  constructor(private context: GameContext) {
+    let container = context.container;
+    this.folderInfo = util.getElement(container, '#folder-info');
+    this.currentFolderIndex = 0;
+    this.songInfo = util.getElement(container, '#song-info');
+    this.songList = util.getElement(container, '#song-list');
+
+    this.listControllers = [];
+    this.levelSets.forEach((levelSet) => {
+      let controller = new SongListController(
+        this.context,
+        levelSet.levels,
+        (index) => this.selectSong(index),
+        (index) => this.chooseSong(index)
       );
+      this.listControllers.push(controller);
+    });
 
-      this.init = false;
-    }
+    this.init = true;
 
-    enter(): void {
-      this.context.bgManager.setBackground(this.context.config!.background);
-      this.folderController.listeners.attach();
-    }
+    this.folderController = new FolderSelectController(
+      this.folderInfo,
+      this.levelSets,
+      (index) => this.selectLevelSet(index)
+    );
 
-    handleInput(key: string): void {
-      this.activeListController.handleInput(key);
-      this.folderController.handleInput(key);
-    }
+    this.init = false;
+  }
 
-    selectSong(index: number): void {
-      const { selectSound } = this.context.assets!;
-      if (!this.init && selectSound !== null) {
-        selectSound.play();
-      }
-      let level = this.currentLevelSet.levels[index];
-      this.songInfo.querySelector('.genre')!.textContent = level.genre;
-      this.songInfo.querySelector('.creator')!.textContent = level.creator;
-      this.songInfo.querySelector('.title')!.textContent = level.name;
-      const linkContainer = this.songInfo.querySelector('.link')!;
-      linkContainer.innerHTML = '';
-      if (level.songLink) {
-        const link = document.createElement('a');
-        link.href = level.songLink;
-        link.textContent = "More info";
-        linkContainer.appendChild(link);
-      }
-    }
+  enter(): void {
+    this.context.bgManager.setBackground(this.context.config!.background);
+    this.folderController.listeners.attach();
+  }
 
-    chooseSong(index: number): void {
-      const { decideSound } = this.context.assets!;
-      if (decideSound !== null) {
-        decideSound.play();
-      }
-      let level = this.currentLevelSet.levels[index];
-      let gameScreen = new TypingScreen(this.context, level, this);
-      this.context.switchScreen(gameScreen);
-    }
+  handleInput(key: string): void {
+    this.activeListController.handleInput(key);
+    this.folderController.handleInput(key);
+  }
 
-    selectLevelSet(index: number): void {
-      this.currentFolderIndex = index;
-      util.clearChildren(this.songList);
-      this.songList.appendChild(this.activeListController.element);
-      this.selectSong(this.activeListController.currentIndex);
+  selectSong(index: number): void {
+    const { selectSound } = this.context.assets!;
+    if (!this.init && selectSound !== null) {
+      selectSound.play();
+    }
+    let level = this.currentLevelSet.levels[index];
+    this.songInfo.querySelector('.genre')!.textContent = level.genre;
+    this.songInfo.querySelector('.creator')!.textContent = level.creator;
+    this.songInfo.querySelector('.title')!.textContent = level.name;
+    const linkContainer = this.songInfo.querySelector('.link')!;
+    linkContainer.innerHTML = '';
+    if (level.songLink) {
+      const link = document.createElement('a');
+      link.href = level.songLink;
+      link.textContent = 'More info';
+      linkContainer.appendChild(link);
     }
+  }
 
-    exit(): void {
-      this.folderController.listeners.detach();
+  chooseSong(index: number): void {
+    const { decideSound } = this.context.assets!;
+    if (decideSound !== null) {
+      decideSound.play();
     }
+    let level = this.currentLevelSet.levels[index];
+    let gameScreen = new TypingScreen(this.context, level, this);
+    this.context.switchScreen(gameScreen);
+  }
 
-    transitionExit(): void {}
-  }
-
-  class FolderSelectController {
-    labelElement: HTMLElement;
-    levelSets: level.LevelSet[];
-    currentIndex: number;
-    onFolderChange: (index: number) => void;
-    listeners: util.ListenersManager;
-
-    constructor(element: HTMLElement, levelSets: level.LevelSet[], onFolderChange: (index: number) => void) {
-      this.labelElement = util.getElement(element, '.label');
-      this.levelSets = levelSets;
-      this.currentIndex = 0;
-      this.onFolderChange = onFolderChange;
-      this.listeners = new util.ListenersManager();
-      this.listeners.add(
-        element.querySelector('.left')!,
-        'click',
-        () => this.scroll(-1)
-      );
-      this.listeners.add(
-        element.querySelector('.right')!,
-        'click',
-        () => this.scroll(1)
-      );
+  selectLevelSet(index: number): void {
+    this.currentFolderIndex = index;
+    util.clearChildren(this.songList);
+    this.songList.appendChild(this.activeListController.element);
+    this.selectSong(this.activeListController.currentIndex);
+  }
 
-      this.scroll(0);
-    }
+  exit(): void {
+    this.folderController.listeners.detach();
+  }
 
-    handleInput(key: string): void {
-      if (key === 'ArrowLeft' || key === 'h') {
-        this.scroll(-1);
-      } else if (key === 'ArrowRight' || key === 'l') {
-        this.scroll(1);
-      }
-    }
+  transitionExit(): void {}
+}
+
+class FolderSelectController {
+  labelElement: HTMLElement;
+  levelSets: level.LevelSet[];
+  currentIndex: number;
+  onFolderChange: (index: number) => void;
+  listeners: util.ListenersManager;
+
+  constructor(
+    element: HTMLElement,
+    levelSets: level.LevelSet[],
+    onFolderChange: (index: number) => void
+  ) {
+    this.labelElement = util.getElement(element, '.label');
+    this.levelSets = levelSets;
+    this.currentIndex = 0;
+    this.onFolderChange = onFolderChange;
+    this.listeners = new util.ListenersManager();
+    this.listeners.add(element.querySelector('.left')!, 'click', () =>
+      this.scroll(-1)
+    );
+    this.listeners.add(element.querySelector('.right')!, 'click', () =>
+      this.scroll(1)
+    );
+
+    this.scroll(0);
+  }
 
-    scroll(offset: number): void {
-      this.currentIndex += offset;
-      while (this.currentIndex < 0) {
-        this.currentIndex += this.levelSets.length;
-      }
-      this.currentIndex %= this.levelSets.length;
-      this.labelElement.textContent = this.levelSets[this.currentIndex].name;
-      this.onFolderChange(this.currentIndex);
+  handleInput(key: string): void {
+    if (key === 'ArrowLeft' || key === 'h') {
+      this.scroll(-1);
+    } else if (key === 'ArrowRight' || key === 'l') {
+      this.scroll(1);
     }
   }
 
-  class SongListController {
-    element: HTMLElement;
-    levels: level.Level[];
-    currentIndex: number;
-    onSongChange: (index: number) => void;
-    onSongChoose: (index: number) => void;
-
-    constructor(
-      context: GameContext,
-      levels: level.Level[],
-      onSongChange: (index: number) => void,
-      onSongChoose: (index: number) => void
-    ) {
-      this.element = document.createElement('div');
-      this.levels = levels;
-      this.currentIndex = 0;
-      this.onSongChange = onSongChange;
-      this.onSongChoose = onSongChoose;
-
-      this.element.className = 'song-list';
-      this.element.style.marginTop = '12.5em';
-
-      this.levels.forEach((level, index) => {
-        let element = context.loadTemplate('song-item');
-        element.querySelector('.creator')!.textContent = level.creator;
-        element.querySelector('.title')!.textContent = level.name;
-        element.querySelector('.difficulty')!.textContent = level.difficulty;
-        element.querySelector('.song-item')!.addEventListener('click', (event) => this.click(index));
-        this.element.appendChild(element);
-      });
-      this.element.children[0].classList.add('selected');
+  scroll(offset: number): void {
+    this.currentIndex += offset;
+    while (this.currentIndex < 0) {
+      this.currentIndex += this.levelSets.length;
     }
+    this.currentIndex %= this.levelSets.length;
+    this.labelElement.textContent = this.levelSets[this.currentIndex].name;
+    this.onFolderChange(this.currentIndex);
+  }
+}
+
+class SongListController {
+  element: HTMLElement;
+  levels: level.Level[];
+  currentIndex: number;
+  onSongChange: (index: number) => void;
+  onSongChoose: (index: number) => void;
+
+  constructor(
+    context: GameContext,
+    levels: level.Level[],
+    onSongChange: (index: number) => void,
+    onSongChoose: (index: number) => void
+  ) {
+    this.element = document.createElement('div');
+    this.levels = levels;
+    this.currentIndex = 0;
+    this.onSongChange = onSongChange;
+    this.onSongChoose = onSongChoose;
+
+    this.element.className = 'song-list';
+    this.element.style.marginTop = '12.5em';
+
+    this.levels.forEach((level, index) => {
+      let element = context.loadTemplate('song-item');
+      element.querySelector('.creator')!.textContent = level.creator;
+      element.querySelector('.title')!.textContent = level.name;
+      element.querySelector('.difficulty')!.textContent = level.difficulty;
+      element
+        .querySelector('.song-item')!
+        .addEventListener('click', (event) => this.click(index));
+      this.element.appendChild(element);
+    });
+    this.element.children[0].classList.add('selected');
+  }
 
-    handleInput(key: string): void {
-      if (key === 'ArrowUp' || key === 'k') {
-        this.scroll(-1);
-      } else if (key === 'ArrowDown' || key === 'j') {
-        this.scroll(1);
-      } else if (key === 'PageUp') {
-        this.scroll(-5);
-      } else if (key === 'PageDown') {
-        this.scroll(5);
-      } else if (key === ' ' || key === 'Enter') {
-        this.choose();
-      }
+  handleInput(key: string): void {
+    if (key === 'ArrowUp' || key === 'k') {
+      this.scroll(-1);
+    } else if (key === 'ArrowDown' || key === 'j') {
+      this.scroll(1);
+    } else if (key === 'PageUp') {
+      this.scroll(-5);
+    } else if (key === 'PageDown') {
+      this.scroll(5);
+    } else if (key === ' ' || key === 'Enter') {
+      this.choose();
     }
+  }
 
-    scroll(offset: number) {
-      let target = this.currentIndex + offset;
-      target = Math.max(0, Math.min(this.levels.length - 1, target));
-      this.select(target);
-    }
+  scroll(offset: number) {
+    let target = this.currentIndex + offset;
+    target = Math.max(0, Math.min(this.levels.length - 1, target));
+    this.select(target);
+  }
 
-    click(index: number) {
-      if (this.currentIndex === index) {
-        this.choose();
-      } else {
-        this.select(index);
-      }
+  click(index: number) {
+    if (this.currentIndex === index) {
+      this.choose();
+    } else {
+      this.select(index);
     }
+  }
 
-    select(index: number) {
-      if (this.currentIndex === index) return;
+  select(index: number) {
+    if (this.currentIndex === index) return;
 
-      let offset = 12.5 - index * 2.5;
-      this.element.style.marginTop = offset+'em';
+    let offset = 12.5 - index * 2.5;
+    this.element.style.marginTop = offset + 'em';
 
-      let nextElement = this.element.children[index] as HTMLElement;
-      let currElement = this.element.children[this.currentIndex] as HTMLElement;
-      currElement.classList.remove('selected');
-      nextElement.classList.add('selected');
-      this.currentIndex = index;
-      this.onSongChange(index);
-    }
+    let nextElement = this.element.children[index] as HTMLElement;
+    let currElement = this.element.children[this.currentIndex] as HTMLElement;
+    currElement.classList.remove('selected');
+    nextElement.classList.add('selected');
+    this.currentIndex = index;
+    this.onSongChange(index);
+  }
 
-    choose() {
-      this.onSongChoose(this.currentIndex);
-    }
+  choose() {
+    this.onSongChoose(this.currentIndex);
   }
+}

+ 308 - 292
src/game/typing.ts

@@ -5,368 +5,384 @@ import * as kana from '../kana';
 import * as util from '../util';
 import * as youtube from '../youtube';
 
-import {
-  GameContext,
-  Screen,
-  ScreenManager,
-} from './common';
+import { GameContext, Screen, ScreenManager } from './common';
 
-  import Level = level.Level;
+import Level = level.Level;
 
-  class TypingScreenContext {
-    track: audio.Track | null = null;
+class TypingScreenContext {
+  track: audio.Track | null = null;
 
-    constructor(
-      readonly context: GameContext,
-      readonly level: Level,
-      readonly switchClosure: (screen: Screen | null) => void
-    ) {}
+  constructor(
+    readonly context: GameContext,
+    readonly level: Level,
+    readonly switchClosure: (screen: Screen | null) => void
+  ) {}
 
-    get container() {
-      return this.context.container;
-    }
+  get container() {
+    return this.context.container;
+  }
 
-    get audioManager() {
-      return this.context.audioManager;
-    }
+  get audioManager() {
+    return this.context.audioManager;
+  }
 
-    get bgManager() {
-      return this.context.bgManager;
-    }
+  get bgManager() {
+    return this.context.bgManager;
+  }
 
-    switchScreen(screen: Screen | null): void {
-      this.switchClosure(screen);
-    }
+  switchScreen(screen: Screen | null): void {
+    this.switchClosure(screen);
   }
+}
 
-  export class TypingScreen extends ScreenManager implements Screen {
-    readonly name: string = 'game';
+export class TypingScreen extends ScreenManager implements Screen {
+  readonly name: string = 'game';
 
-    constructor(
-      readonly context: GameContext,
-      readonly level: Level,
-      readonly prevScreen: Screen
-    ) {
-      super(context.container);
-    }
+  constructor(
+    readonly context: GameContext,
+    readonly level: Level,
+    readonly prevScreen: Screen
+  ) {
+    super(context.container);
+  }
 
-    enter(): void {
-      if (this.level.background) {
-        this.context.bgManager.setBackground(this.level.background);
-      }
-      let context = new TypingScreenContext(this.context, this.level, (screen) => this.switchScreen(screen));
-      let loadingScreen = new TypingLoadingScreen(context);
-      this.switchScreen(loadingScreen);
+  enter(): void {
+    if (this.level.background) {
+      this.context.bgManager.setBackground(this.level.background);
     }
+    let context = new TypingScreenContext(this.context, this.level, (screen) =>
+      this.switchScreen(screen)
+    );
+    let loadingScreen = new TypingLoadingScreen(context);
+    this.switchScreen(loadingScreen);
+  }
 
-    handleInput(key: string): void {
-      if (this.activeScreen !== null) {
-        this.activeScreen.handleInput(key);
-      }
+  handleInput(key: string): void {
+    if (this.activeScreen !== null) {
+      this.activeScreen.handleInput(key);
     }
+  }
 
-    switchScreen(screen: Screen | null): void {
-      super.switchScreen(screen);
-      if (screen == null) {
-        this.context.switchScreen(this.prevScreen);
-      }
+  switchScreen(screen: Screen | null): void {
+    super.switchScreen(screen);
+    if (screen == null) {
+      this.context.switchScreen(this.prevScreen);
     }
+  }
 
-    exit(): void {}
+  exit(): void {}
+
+  transitionExit(): void {}
+}
+
+class TypingLoadingScreen implements Screen {
+  readonly name: string = 'game-loading';
+  barElement: HTMLElement | null = null;
+  textElement: HTMLElement | null = null;
+  readyElement: HTMLElement | null = null;
+  isReady: boolean = false;
+  fnContext: util.FnContext = new util.FnContext();
+
+  constructor(readonly context: TypingScreenContext) {}
+
+  enter(): void {
+    let loader: HTMLElement = util.getElement(
+      this.context.container,
+      '#loader'
+    );
+    this.readyElement = util.getElement(this.context.container, '#ready');
+    if (this.context.level.audio != null) {
+      loader.style.visibility = 'visible';
+      this.barElement = util.getElement(loader, '.progress-bar .shade');
+      this.textElement = util.getElement(loader, '.label');
+
+      this.barElement.style.width = '0%';
+      this.textElement.textContent = 'music loading';
+      this.readyElement.querySelector('.status')!.textContent = 'Loading';
+      this.readyElement.querySelector('.message')!.textContent = 'please wait';
 
-    transitionExit(): void {}
-  }
+      this.fnContext.invalidate();
 
-  class TypingLoadingScreen implements Screen {
-    readonly name: string = 'game-loading';
-    barElement: HTMLElement | null = null;
-    textElement: HTMLElement | null = null;
-    readyElement: HTMLElement | null = null;
-    isReady: boolean = false;
-    fnContext: util.FnContext = new util.FnContext();
-
-    constructor(readonly context: TypingScreenContext) {}
-
-    enter(): void {
-      let loader: HTMLElement = util.getElement(this.context.container, '#loader');
-      this.readyElement = util.getElement(this.context.container, '#ready');
-      if (this.context.level.audio != null) {
-        loader.style.visibility = 'visible';
-        this.barElement = util.getElement(loader, '.progress-bar .shade');
-        this.textElement = util.getElement(loader, '.label');
-
-        this.barElement.style.width = '0%';
-        this.textElement.textContent = 'music loading';
-        this.readyElement.querySelector('.status')!.textContent = 'Loading';
-        this.readyElement.querySelector('.message')!.textContent = 'please wait';
-
-        this.fnContext.invalidate();
-
-        const videoId = youtube.getVideoId(this.context.level.audio);
-        const progressListener = this.fnContext.wrap((percentage: number) => {
-          this.barElement!.style.width = `${percentage}%`;
-        });
-        let trackPromise: Promise<audio.Track>;
-        if (videoId !== null) {
-          const ytElement = document.createElement('div');
-          trackPromise = this.context.audioManager.loadTrackFromYoutube(
-            videoId,
-            ytElement,
-            progressListener,
-          );
-          this.context.bgManager.setVideo(ytElement);
-          if (this.context.level.background == undefined) {
-            trackPromise.then((track) => {
-              track.addListener((_, state) => {
-                if (state === audio.PlayState.PLAYING) {
-                  this.context.bgManager.showVideo();
-                }
-              });
+      const videoId = youtube.getVideoId(this.context.level.audio);
+      const progressListener = this.fnContext.wrap((percentage: number) => {
+        this.barElement!.style.width = `${percentage}%`;
+      });
+      let trackPromise: Promise<audio.Track>;
+      if (videoId !== null) {
+        const ytElement = document.createElement('div');
+        trackPromise = this.context.audioManager.loadTrackFromYoutube(
+          videoId,
+          ytElement,
+          progressListener
+        );
+        this.context.bgManager.setVideo(ytElement);
+        if (this.context.level.background == undefined) {
+          trackPromise.then((track) => {
+            track.addListener((_, state) => {
+              if (state === audio.PlayState.PLAYING) {
+                this.context.bgManager.showVideo();
+              }
             });
-          }
-        } else {
-          trackPromise = this.context.audioManager.loadTrackWithProgress(
-            this.context.level.audio,
-            progressListener,
-          )
+          });
         }
-        trackPromise.then(this.fnContext.wrap((track: audio.Track) => {
+      } else {
+        trackPromise = this.context.audioManager.loadTrackWithProgress(
+          this.context.level.audio,
+          progressListener
+        );
+      }
+      trackPromise.then(
+        this.fnContext.wrap((track: audio.Track) => {
           this.context.track = track;
           this.barElement!.style.width = '100%';
           this.textElement!.textContent = 'music loaded';
           this.setReady();
-        }));
-      } else {
-        loader.style.visibility = 'hidden';
-        this.setReady();
-      }
+        })
+      );
+    } else {
+      loader.style.visibility = 'hidden';
+      this.setReady();
     }
+  }
 
-    setReady(): void {
-      this.readyElement!.querySelector('.status')!.textContent = 'Ready';
-      this.readyElement!.querySelector('.message')!.textContent = 'press space to start';
-      this.isReady = true;
-    }
+  setReady(): void {
+    this.readyElement!.querySelector('.status')!.textContent = 'Ready';
+    this.readyElement!.querySelector('.message')!.textContent =
+      'press space to start';
+    this.isReady = true;
+  }
 
-    handleInput(key: string): void {
-      if (key === 'Escape' || key === 'Backspace') {
-        this.context.switchScreen(null);
-      } else if (this.isReady && (key === ' ' || key === 'Enter')) {
-        this.context.switchScreen(new TypingPlayingScreen(this.context));
-      }
+  handleInput(key: string): void {
+    if (key === 'Escape' || key === 'Backspace') {
+      this.context.switchScreen(null);
+    } else if (this.isReady && (key === ' ' || key === 'Enter')) {
+      this.context.switchScreen(new TypingPlayingScreen(this.context));
     }
+  }
 
-    exit(): void {
-      this.fnContext.invalidate();
-    }
+  exit(): void {
+    this.fnContext.invalidate();
+  }
 
-    transitionExit(): void {
-      if (this.barElement) {
-        this.barElement.style.width = '0%';
-      }
+  transitionExit(): void {
+    if (this.barElement) {
+      this.barElement.style.width = '0%';
     }
   }
+}
+
+class TypingPlayingScreen implements Screen {
+  readonly name: string = 'game-playing';
+  gameContainer: HTMLElement;
+  currentIndex: number;
+  inputState: kana.KanaInputState | null;
+  isWaiting: boolean;
+  kanjiElement: HTMLElement;
+  kanaController: display.KanaDisplayController;
+  romajiController: display.RomajiDisplayController;
+  progressController: display.TrackProgressController | null;
+  scoreController: display.ScoreController;
+  lines: level.Line[];
+
+  constructor(readonly context: TypingScreenContext) {
+    this.gameContainer = util.getElement(this.context.container, '#game');
+    this.currentIndex = -1;
+    this.inputState = null;
+    this.isWaiting = false;
+    this.kanjiElement = util.getElement(this.gameContainer, '.kanji-line');
+    this.romajiController = new display.RomajiDisplayController(
+      util.getElement(this.gameContainer, '.romaji-first'),
+      util.getElement(this.gameContainer, '.romaji-line')
+    );
+    this.kanaController = new display.KanaDisplayController(
+      util.getElement(this.gameContainer, '.kana-line')
+    );
+    this.progressController = null;
+    this.scoreController = new display.ScoreController(
+      util.getElement(this.gameContainer, '.score-line'),
+      util.getElement(this.gameContainer, '.stats-line')
+    );
+    this.lines = this.context.level.lines;
+  }
 
-  class TypingPlayingScreen implements Screen {
-    readonly name: string = 'game-playing';
-    gameContainer: HTMLElement;
-    currentIndex: number;
-    inputState: kana.KanaInputState | null;
-    isWaiting: boolean;
-    kanjiElement: HTMLElement;
-    kanaController: display.KanaDisplayController;
-    romajiController: display.RomajiDisplayController;
-    progressController: display.TrackProgressController | null;
-    scoreController: display.ScoreController;
-    lines: level.Line[];
-
-    constructor(readonly context: TypingScreenContext) {
-      this.gameContainer = util.getElement(this.context.container, '#game');
-      this.currentIndex = -1;
-      this.inputState = null;
-      this.isWaiting = false;
-      this.kanjiElement = util.getElement(this.gameContainer, '.kanji-line');
-      this.romajiController = new display.RomajiDisplayController(
-        util.getElement(this.gameContainer, '.romaji-first'),
-        util.getElement(this.gameContainer, '.romaji-line')
-      );
-      this.kanaController = new display.KanaDisplayController(
-        util.getElement(this.gameContainer, '.kana-line')
+  enter(): void {
+    let progressElement: HTMLElement = this.gameContainer.querySelector<HTMLElement>(
+      '.track-progress'
+    )!;
+    if (this.context.track == null) {
+      progressElement.style.visibility = 'hidden';
+      this.lines = this.context.level.lines.filter((line) => line.kana != '@');
+    } else {
+      progressElement.style.visibility = 'visible';
+      const progressController = new display.TrackProgressController(
+        progressElement,
+        this.lines
       );
-      this.progressController = null;
-      this.scoreController = new display.ScoreController(
-        util.getElement(this.gameContainer, '.score-line'),
-        util.getElement(this.gameContainer, '.stats-line')
-      );
-      this.lines = this.context.level.lines;
+      progressController.setListener((_) => this.onIntervalEnd());
+      this.context.track.addListener((track, state) => {
+        if (state === audio.PlayState.PLAYING) {
+          progressController.start(track.getTime());
+        } else {
+          progressController.pause();
+        }
+      });
+      this.progressController = progressController;
     }
+    this.onStart();
+  }
 
-    enter(): void {
-      let progressElement: HTMLElement = this.gameContainer.querySelector<HTMLElement>('.track-progress')!;
-      if (this.context.track == null) {
-        progressElement.style.visibility = 'hidden';
-        this.lines = this.context.level.lines.filter(line => line.kana != "@");
-      } else {
-        progressElement.style.visibility = 'visible';
-        const progressController = new display.TrackProgressController(
-          progressElement,
-          this.lines
-        );
-        progressController.setListener(_ => this.onIntervalEnd());
-        this.context.track.addListener((track, state) => {
-          if (state === audio.PlayState.PLAYING) {
-            progressController.start(track.getTime());
-          } else {
-            progressController.pause();
-          }
-        });
-        this.progressController = progressController;
-      }
-      this.onStart();
-    }
+  setWaiting(waiting: boolean): void {
+    this.gameContainer.classList.toggle('waiting', waiting);
+    this.isWaiting = waiting;
+  }
 
-    setWaiting(waiting: boolean): void {
-      this.gameContainer.classList.toggle('waiting', waiting);
-      this.isWaiting = waiting;
+  onStart(): void {
+    this.nextLine();
+    if (this.context.track !== null) {
+      this.context.track.play();
     }
 
-    onStart(): void {
-      this.nextLine();
-      if (this.context.track !== null) {
-        this.context.track.play();
-      }
+    this.setWaiting(false);
+    this.checkComplete();
+  }
 
-      this.setWaiting(false);
-      this.checkComplete();
+  checkComplete(): void {
+    let currentLine = this.lines[this.currentIndex];
+    if (
+      currentLine != null &&
+      currentLine.kana == '@' &&
+      currentLine.kanji == '@'
+    ) {
+      this.onComplete(true);
     }
+  }
 
-    checkComplete(): void {
-      let currentLine = this.lines[this.currentIndex];
-      if (currentLine != null && currentLine.kana == '@' && currentLine.kanji == '@') {
-        this.onComplete(true);
-      }
+  onIntervalEnd(): void {
+    if (this.isWaiting) {
+      this.setWaiting(false);
+    } else {
+      this.nextLine();
+      this.scoreController.intervalEnd(false);
+    }
+    if (this.currentIndex >= this.lines.length) {
+      this.finish();
     }
+    this.checkComplete();
+  }
 
-    onIntervalEnd(): void {
-      if (this.isWaiting) {
-        this.setWaiting(false);
-      } else {
-        this.nextLine();
-        this.scoreController.intervalEnd(false);
-      }
+  onComplete(autoComplete: boolean = false): void {
+    this.nextLine();
+    if (!autoComplete) {
+      this.scoreController.intervalEnd(true);
+    }
+    if (this.context.track !== null) {
+      this.setWaiting(true);
+    } else {
       if (this.currentIndex >= this.lines.length) {
         this.finish();
       }
-      this.checkComplete();
     }
+  }
 
-    onComplete(autoComplete: boolean = false): void {
-      this.nextLine();
-      if (!autoComplete) {
-        this.scoreController.intervalEnd(true);
-      }
-      if (this.context.track !== null) {
-        this.setWaiting(true);
-      } else {
-        if (this.currentIndex >= this.lines.length) {
-          this.finish();
+  handleInput(key: string): void {
+    if (key === 'Escape' || key === 'Backspace') {
+      this.finish();
+    } else if (!this.isWaiting) {
+      if (this.inputState !== null && /^[-_ a-z]$/.test(key)) {
+        if (this.inputState.handleInput(key)) {
+          this.onComplete();
         }
       }
     }
+  }
 
-    handleInput(key: string): void {
-      if (key === 'Escape' || key === 'Backspace') {
-        this.finish();
-      } else if (!this.isWaiting) {
-        if (this.inputState !== null && /^[-_ a-z]$/.test(key)) {
-          if (this.inputState.handleInput(key)) {
-            this.onComplete();
-          }
-        }
-      }
+  nextLine(): void {
+    if (this.currentIndex < this.lines.length) {
+      this.currentIndex += 1;
     }
-
-    nextLine(): void {
-      if (this.currentIndex < this.lines.length) {
-        this.currentIndex += 1;
-      }
-      if (this.currentIndex < this.lines.length) {
-        this.setLine(this.lines[this.currentIndex]);
-      } else {
-        this.setLine({ kanji: '@', kana: '@' });
-      }
+    if (this.currentIndex < this.lines.length) {
+      this.setLine(this.lines[this.currentIndex]);
+    } else {
+      this.setLine({ kanji: '@', kana: '@' });
     }
+  }
 
-    setLine(line: level.Line): void {
-      let kanji, inputState;
-      if (line.kanji === '@') {
-        kanji = '';
-      } else {
-        kanji = line.kanji;
-      }
-
-      if (line.kana === '@') {
-        inputState = null;
-      } else {
-        inputState = new kana.KanaInputState(line.kana);
-      }
-
-      this.inputState = inputState;
-      this.kanjiElement.textContent = kanji;
-      this.kanaController.setInputState(this.inputState);
-      this.romajiController.setInputState(this.inputState);
-      this.scoreController.setInputState(this.inputState);
+  setLine(line: level.Line): void {
+    let kanji, inputState;
+    if (line.kanji === '@') {
+      kanji = '';
+    } else {
+      kanji = line.kanji;
     }
 
-    finish(): void {
-      this.context.switchScreen(new TypingFinishScreen(
-        this.context,
-        this.scoreController.score
-      ));
+    if (line.kana === '@') {
+      inputState = null;
+    } else {
+      inputState = new kana.KanaInputState(line.kana);
     }
 
-    exit(): void {}
+    this.inputState = inputState;
+    this.kanjiElement.textContent = kanji;
+    this.kanaController.setInputState(this.inputState);
+    this.romajiController.setInputState(this.inputState);
+    this.scoreController.setInputState(this.inputState);
+  }
 
-    transitionExit(): void {
-      this.kanaController.destroy();
-      this.romajiController.destroy();
-      if (this.context.track !== null) {
-        this.progressController!.destroy();
-        this.context.track.clearListeners();
-      }
-      this.scoreController.destroy();
-    }
+  finish(): void {
+    this.context.switchScreen(
+      new TypingFinishScreen(this.context, this.scoreController.score)
+    );
   }
 
-  class TypingFinishScreen implements Screen {
-    name: string = 'game-finished';
+  exit(): void {}
 
-    constructor(
-      readonly context: TypingScreenContext,
-      readonly score: display.Score
-    ) {
-      let container = this.context.container.querySelector('#score')!;
-      container.querySelector('.score')!.textContent = this.score.score+'';
-      container.querySelector('.max-combo')!.textContent = this.score.maxCombo+'';
-      container.querySelector('.finished')!.textContent = this.score.finished+'';
-      container.querySelector('.hit')!.textContent = this.score.hit+'';
-      container.querySelector('.missed')!.textContent = this.score.missed+'';
-      container.querySelector('.skipped')!.textContent = this.score.skipped+'';
+  transitionExit(): void {
+    this.kanaController.destroy();
+    this.romajiController.destroy();
+    if (this.context.track !== null) {
+      this.progressController!.destroy();
+      this.context.track.clearListeners();
     }
+    this.scoreController.destroy();
+  }
+}
+
+class TypingFinishScreen implements Screen {
+  name: string = 'game-finished';
+
+  constructor(
+    readonly context: TypingScreenContext,
+    readonly score: display.Score
+  ) {
+    let container = this.context.container.querySelector('#score')!;
+    container.querySelector('.score')!.textContent = this.score.score + '';
+    container.querySelector('.max-combo')!.textContent =
+      this.score.maxCombo + '';
+    container.querySelector('.finished')!.textContent =
+      this.score.finished + '';
+    container.querySelector('.hit')!.textContent = this.score.hit + '';
+    container.querySelector('.missed')!.textContent = this.score.missed + '';
+    container.querySelector('.skipped')!.textContent = this.score.skipped + '';
+  }
 
-    enter(): void {}
+  enter(): void {}
 
-    handleInput(key: string): void {
-      if (key === ' ' || key === 'Enter' || key === 'Escape' || key === 'Backspace') {
-        this.context.switchScreen(null);
-      }
+  handleInput(key: string): void {
+    if (
+      key === ' ' ||
+      key === 'Enter' ||
+      key === 'Escape' ||
+      key === 'Backspace'
+    ) {
+      this.context.switchScreen(null);
     }
+  }
 
-    exit(): void {}
+  exit(): void {}
 
-    transitionExit(): void {
-      if (this.context.track !== null) {
-        this.context.track.exit();
-      }
+  transitionExit(): void {
+    if (this.context.track !== null) {
+      this.context.track.exit();
     }
   }
+}

+ 12 - 9
src/global.d.ts

@@ -33,18 +33,18 @@ declare namespace YT {
 
   enum PlayerState {
     UNSTARTED = -1,
-    ENDED     = 0,
-    PLAYING   = 1,
-    PAUSED    = 2,
+    ENDED = 0,
+    PLAYING = 1,
+    PAUSED = 2,
     BUFFERING = 3,
-    CUED      = 5,
+    CUED = 5,
   }
 
   enum PlayerError {
-    INVALID_PARAM    = 2,
-    PLAYBACK_ERROR   = 5,
-    NOT_FOUND        = 100,
-    CANNOT_EMBED     = 101,
+    INVALID_PARAM = 2,
+    PLAYBACK_ERROR = 5,
+    NOT_FOUND = 100,
+    CANNOT_EMBED = 101,
     CANNOT_EMBED_ALT = 150,
   }
 
@@ -70,7 +70,10 @@ declare namespace YT {
     getVolume(): number;
 
     addEventListener(event: 'onReady', listener: PlayerReadyListener): void;
-    addEventListener(event: 'onStateChange', listener: PlayerStateChangeListener): void;
+    addEventListener(
+      event: 'onStateChange',
+      listener: PlayerStateChangeListener
+    ): void;
     addEventListener(event: 'onError', listener: PlayerErrorListener): void;
   }
 }

+ 17 - 8
src/index.html

@@ -1,10 +1,13 @@
-<!doctype html>
+<!DOCTYPE html>
 <html>
   <head>
     <title>Typing Freaks</title>
     <meta charset="utf-8" />
     <link rel="stylesheet" href="style.css" />
-    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons"
+      rel="stylesheet"
+    />
   </head>
   <body>
     <div id="container" class="loading">
@@ -71,12 +74,18 @@
           </div>
         </div>
         <div id="score">
-          <span>Score</span><div class="score"></div>
-          <span>Max Combo</span><div class="max-combo"></div>
-          <span>Finished</span><div class="finished"></div>
-          <span>Hit</span><div class="hit"></div>
-          <span>Missed</span><div class="missed"></div>
-          <span>Skipped</span><div class="skipped"></div>
+          <span>Score</span>
+          <div class="score"></div>
+          <span>Max Combo</span>
+          <div class="max-combo"></div>
+          <span>Finished</span>
+          <div class="finished"></div>
+          <span>Hit</span>
+          <div class="hit"></div>
+          <span>Missed</span>
+          <div class="missed"></div>
+          <span>Skipped</span>
+          <div class="skipped"></div>
         </div>
         <div id="loader">
           <template name="progress-bar"></template>

+ 519 - 461
src/kana.ts

@@ -16,407 +16,464 @@
  */
 
 import * as state from './state';
-import {
-  State,
-  StateMachine,
-  makeTransition as t,
-} from './state';
-
-  function literal(source: string, ...extraBoundaries: number[]): StateMachine {
-    let transitions: state.Transition[] = [];
-    for (let i = 0; i < source.length; ++i) {
-      let from = source.substring(i);
-      let input = source.charAt(i);
-      let to = source.substring(i+1);
-      let boundary = i === (source.length - 1) || extraBoundaries.indexOf(i) >= 0;
-      transitions.push(t(from, input, to, boundary));
-    }
-    return state.buildFromTransitions(source, transitions);
-  }
-
-  function shi(): StateMachine {
-    return state.buildFromTransitions('shi', [
-      t('shi', 's', 'hi'),
-      t('hi', 'h', 'i'),
-      t('hi', 'i', '', true),
-      t('i', 'i', '', true)
-    ]);
-  }
-
-  function chi(): StateMachine {
-    return state.buildFromTransitions('chi', [
-      t('chi', 'c', 'hi'),
-      t('chi', 't', 'i'),
-      t('hi', 'h', 'i'),
-      t('i', 'i', '', true)
-    ]);
-  }
-
-  function tsu(): StateMachine {
-    return state.buildFromTransitions('tsu', [
-      t('tsu', 't', 'su'),
-      t('su', 's', 'u'),
-      t('su', 'u', '', true),
-      t('u', 'u', '', true)
-    ]);
-  }
-
-  function fu(): StateMachine {
-    return state.buildFromTransitions('fu', [
-      t('fu', 'f', 'u'),
-      t('fu', 'h', 'u'),
-      t('u', 'u', '', true)
-    ]);
-  }
-
-  function ji(): StateMachine {
-    return state.buildFromTransitions('ji', [
-      t('ji', 'j', 'i'),
-      t('ji', 'z', 'i'),
-      t('i', 'i', '', true)
-    ]);
-  }
-
-  function sh(end: string): StateMachine {
-    let source = 'sh' + end;
-    let middle = 'h' + end;
-    return state.buildFromTransitions(source, [
-      t(source, 's', middle, true),
-      t(middle, 'h', end),
-      t(middle, 'y', end),
-      t(end, end, '', true)
-    ]);
-  }
-
-  function ch(end: string): StateMachine {
-    let source = 'ch' + end;
-    let middle = 'h' + end;
-    let altMiddle = 'y' + end;
-
-    return state.buildFromTransitions(source, [
-      t(source, 'c', middle),
-      t(middle, 'h', end, true),
-      t(source, 't', altMiddle, true),
-      t(altMiddle, 'y', end),
-      t(end, end, '', true)
-    ]);
-  }
-
-  function j(end: string): StateMachine {
-    let source = 'j' + end;
-    let altMiddle = 'y' + end;
-
-    return state.buildFromTransitions(source, [
-      t(source, 'j', end, true),
-      t(source, 'z', altMiddle),
-      t(end, 'y', end),
-      t(altMiddle, 'y', end, true),
-      t(end, end, '', true)
-    ]);
-  }
-
-  function smallTsu(base: StateMachine): StateMachine {
-    let { display, transitions } = base.initialState;
-
-    let newState = new State(display.charAt(0) + display);
-    Object.keys(transitions).forEach(k => {
-      let [nextState, _] = transitions[k];
-      let intermediateDisplay = k + nextState.display;
-      let intermediateState = new State(intermediateDisplay);
-      intermediateState.addTransition(k, nextState);
-      newState.addTransition(k, intermediateState, true);
-    })
-
-    return new StateMachine(newState, base.finalState);
-  }
-
-  function smallKana(base: StateMachine): StateMachine {
-    let newState = base.initialState.clone();
-    newState.addTransition('l', base.initialState);
-    newState.addTransition('x', base.initialState);
-    return new StateMachine(newState, base.finalState);
-  }
-
-  interface KanaMapping {
-    [index: string]: StateMachine
-  }
-
-  interface StringMapping {
-    [index: string]: string
+import { State, StateMachine, makeTransition as t } from './state';
+
+function literal(source: string, ...extraBoundaries: number[]): StateMachine {
+  let transitions: state.Transition[] = [];
+  for (let i = 0; i < source.length; ++i) {
+    let from = source.substring(i);
+    let input = source.charAt(i);
+    let to = source.substring(i + 1);
+    let boundary = i === source.length - 1 || extraBoundaries.indexOf(i) >= 0;
+    transitions.push(t(from, input, to, boundary));
   }
-
-  const WHITESPACE = state.buildFromTransitions('_', [
-    t('_', '_', ''),
-    t('_', ' ', '')
+  return state.buildFromTransitions(source, transitions);
+}
+
+function shi(): StateMachine {
+  return state.buildFromTransitions('shi', [
+    t('shi', 's', 'hi'),
+    t('hi', 'h', 'i'),
+    t('hi', 'i', '', true),
+    t('i', 'i', '', true),
   ]);
+}
+
+function chi(): StateMachine {
+  return state.buildFromTransitions('chi', [
+    t('chi', 'c', 'hi'),
+    t('chi', 't', 'i'),
+    t('hi', 'h', 'i'),
+    t('i', 'i', '', true),
+  ]);
+}
+
+function tsu(): StateMachine {
+  return state.buildFromTransitions('tsu', [
+    t('tsu', 't', 'su'),
+    t('su', 's', 'u'),
+    t('su', 'u', '', true),
+    t('u', 'u', '', true),
+  ]);
+}
 
-  const KATAKANA_MAPPING: StringMapping = {
-    "ア": "あ",
-    "イ": "い",
-    "ウ": "う",
-    "エ": "え",
-    "オ": "お",
-    "カ": "か",
-    "キ": "き",
-    "ク": "く",
-    "ケ": "け",
-    "コ": "こ",
-    "サ": "さ",
-    "シ": "し",
-    "ス": "す",
-    "セ": "せ",
-    "ソ": "そ",
-    "タ": "た",
-    "チ": "ち",
-    "ツ": "つ",
-    "テ": "て",
-    "ト": "と",
-    "ナ": "な",
-    "ニ": "に",
-    "ヌ": "ぬ",
-    "ネ": "ね",
-    "ノ": "の",
-    "ハ": "は",
-    "ヒ": "ひ",
-    "フ": "ふ",
-    "ヘ": "へ",
-    "ホ": "ほ",
-    "マ": "ま",
-    "ミ": "み",
-    "ム": "む",
-    "メ": "め",
-    "モ": "も",
-    "ヤ": "や",
-    "ユ": "ゆ",
-    "ヨ": "よ",
-    "ラ": "ら",
-    "リ": "り",
-    "ル": "る",
-    "レ": "れ",
-    "ロ": "ろ",
-    "ワ": "わ",
-    "ヰ": "ゐ",
-    "ヱ": "ゑ",
-    "ヲ": "を",
-    "ン": "ん",
-    "ガ": "が",
-    "ギ": "ぎ",
-    "グ": "ぐ",
-    "ゲ": "げ",
-    "ゴ": "ご",
-    "ザ": "ざ",
-    "ジ": "じ",
-    "ズ": "ず",
-    "ゼ": "ぜ",
-    "ゾ": "ぞ",
-    "ダ": "だ",
-    "ヂ": "ぢ",
-    "ヅ": "づ",
-    "デ": "で",
-    "ド": "ど",
-    "バ": "ば",
-    "ビ": "び",
-    "ブ": "ぶ",
-    "ベ": "べ",
-    "ボ": "ぼ",
-    "パ": "ぱ",
-    "ピ": "ぴ",
-    "プ": "ぷ",
-    "ペ": "ぺ",
-    "ポ": "ぽ",
-    "ヴ": "ゔ",
-    "ァ": "ぁ",
-    "ィ": "ぃ",
-    "ゥ": "ぅ",
-    "ェ": "ぇ",
-    "ォ": "ぉ",
-    "ャ": "ゃ",
-    "ュ": "ゅ",
-    "ョ": "ょ",
-    "ッ": "っ"
-  }
-
-  const SINGLE_KANA_MAPPING: KanaMapping = {
-    "あ": literal('a'),
-    "い": literal('i'),
-    "う": literal('u'),
-    "え": literal('e'),
-    "お": literal('o'),
-    "か": literal('ka'),
-    "き": literal('ki'),
-    "く": literal('ku'),
-    "け": literal('ke'),
-    "こ": literal('ko'),
-    "さ": literal('sa'),
-    "し": shi(),
-    "す": literal('su'),
-    "せ": literal('se'),
-    "そ": literal('so'),
-    "た": literal('ta'),
-    "ち": chi(),
-    "つ": tsu(),
-    "て": literal('te'),
-    "と": literal('to'),
-    "な": literal('na'),
-    "に": literal('ni'),
-    "ぬ": literal('nu'),
-    "ね": literal('ne'),
-    "の": literal('no'),
-    "は": literal('ha'),
-    "ひ": literal('hi'),
-    "ふ": fu(),
-    "へ": literal('he'),
-    "ほ": literal('ho'),
-    "ま": literal('ma'),
-    "み": literal('mi'),
-    "む": literal('mu'),
-    "め": literal('me'),
-    "も": literal('mo'),
-    "や": literal('ya'),
-    "ゆ": literal('yu'),
-    "よ": literal('yo'),
-    "ら": literal('ra'),
-    "り": literal('ri'),
-    "る": literal('ru'),
-    "れ": literal('re'),
-    "ろ": literal('ro'),
-    "わ": literal('wa'),
-    "ゐ": literal('i'),
-    "ゑ": literal('e'),
-    "を": literal('wo'),
-    "ん": literal('n'),
-    "が": literal('ga'),
-    "ぎ": literal('gi'),
-    "ぐ": literal('gu'),
-    "げ": literal('ge'),
-    "ご": literal('go'),
-    "ざ": literal('za'),
-    "じ": ji(),
-    "ず": literal('zu'),
-    "ぜ": literal('ze'),
-    "ぞ": literal('zo'),
-    "だ": literal('da'),
-    "ぢ": literal('di'),
-    "づ": literal('du'),
-    "で": literal('de'),
-    "ど": literal('do'),
-    "ば": literal('ba'),
-    "び": literal('bi'),
-    "ぶ": literal('bu'),
-    "べ": literal('be'),
-    "ぼ": literal('bo'),
-    "ぱ": literal('pa'),
-    "ぴ": literal('pi'),
-    "ぷ": literal('pu'),
-    "ぺ": literal('pe'),
-    "ぽ": literal('po'),
-    "ゔ": literal('vu'),
-    "ー": literal('-'),
-    " ": WHITESPACE
-  };
-
-  'abcdefghijklmnopqrstuvwxyz'.split('').forEach(letter => {
-    SINGLE_KANA_MAPPING[letter] = literal(letter);
-  });
+function fu(): StateMachine {
+  return state.buildFromTransitions('fu', [
+    t('fu', 'f', 'u'),
+    t('fu', 'h', 'u'),
+    t('u', 'u', '', true),
+  ]);
+}
 
-  [
-    ['ぁ', 'あ'],
-    ['ぃ', 'い'],
-    ['ぅ', 'う'],
-    ['ぇ', 'え'],
-    ['ぉ', 'お'],
-    ['ヵ', 'か']
-  ].forEach(pair => {
-    let [ small, big ] = pair;
-    SINGLE_KANA_MAPPING[small] = smallKana(SINGLE_KANA_MAPPING[big]);
+function ji(): StateMachine {
+  return state.buildFromTransitions('ji', [
+    t('ji', 'j', 'i'),
+    t('ji', 'z', 'i'),
+    t('i', 'i', '', true),
+  ]);
+}
+
+function sh(end: string): StateMachine {
+  let source = 'sh' + end;
+  let middle = 'h' + end;
+  return state.buildFromTransitions(source, [
+    t(source, 's', middle, true),
+    t(middle, 'h', end),
+    t(middle, 'y', end),
+    t(end, end, '', true),
+  ]);
+}
+
+function ch(end: string): StateMachine {
+  let source = 'ch' + end;
+  let middle = 'h' + end;
+  let altMiddle = 'y' + end;
+
+  return state.buildFromTransitions(source, [
+    t(source, 'c', middle),
+    t(middle, 'h', end, true),
+    t(source, 't', altMiddle, true),
+    t(altMiddle, 'y', end),
+    t(end, end, '', true),
+  ]);
+}
+
+function j(end: string): StateMachine {
+  let source = 'j' + end;
+  let altMiddle = 'y' + end;
+
+  return state.buildFromTransitions(source, [
+    t(source, 'j', end, true),
+    t(source, 'z', altMiddle),
+    t(end, 'y', end),
+    t(altMiddle, 'y', end, true),
+    t(end, end, '', true),
+  ]);
+}
+
+function smallTsu(base: StateMachine): StateMachine {
+  let { display, transitions } = base.initialState;
+
+  let newState = new State(display.charAt(0) + display);
+  Object.keys(transitions).forEach((k) => {
+    let [nextState, _] = transitions[k];
+    let intermediateDisplay = k + nextState.display;
+    let intermediateState = new State(intermediateDisplay);
+    intermediateState.addTransition(k, nextState);
+    newState.addTransition(k, intermediateState, true);
   });
 
-  const DOUBLE_KANA_MAPPING: KanaMapping = {
-    "きゃ": literal('kya', 0),
-    "きゅ": literal('kyu', 0),
-    "きょ": literal('kyo', 0),
-    "しゃ": sh('a'),
-    "しゅ": sh('u'),
-    "しょ": sh('o'),
-    "ちゃ": ch('a'),
-    "ちゅ": ch('u'),
-    "ちょ": ch('o'),
-    "にゃ": literal('nya', 0),
-    "にゅ": literal('nyu', 0),
-    "にょ": literal('nyo', 0),
-    "ひゃ": literal('hya', 0),
-    "ひゅ": literal('hyu', 0),
-    "ひょ": literal('hyo', 0),
-    "みゃ": literal('mya', 0),
-    "みゅ": literal('myu', 0),
-    "みょ": literal('myo', 0),
-    "りゃ": literal('rya', 0),
-    "りゅ": literal('ryu', 0),
-    "りょ": literal('ryo', 0),
-    "ぎゃ": literal('gya', 0),
-    "ぎゅ": literal('gyu', 0),
-    "ぎょ": literal('gyo', 0),
-    "じゃ": j('a'),
-    "じゅ": j('u'),
-    "じょ": j('o'),
-    "ぢゃ": literal('dya', 0),
-    "ぢゅ": literal('dyu', 0),
-    "ぢょ": literal('dyo', 0),
-    "びゃ": literal('bya', 0),
-    "びゅ": literal('byu', 0),
-    "びょ": literal('byo', 0),
-    "ぴゃ": literal('pya', 0),
-    "ぴゅ": literal('pyu', 0),
-    "ぴょ": literal('pyo', 0),
-    "ふぁ": literal('fa', 0),
-    "ふぃ": literal('fi', 0),
-    "ふぇ": literal('fe', 0),
-    "ふぉ": literal('fo', 0),
-    "ゔぁ": literal('va', 0),
-    "ゔぃ": literal('vi', 0),
-    "ゔぇ": literal('ve', 0),
-    "ゔぉ": literal('vo', 0)
-  }
-
-  const TRIPLE_KANA_MAPPING: KanaMapping = {};
-
-  [
-    "か", "き", "く", "け", "こ",
-    "さ", "し", "す", "せ", "そ",
-    "た", "ち", "つ", "て", "と",
-    "は", "ひ", "ふ", "へ", "ほ",
-    "が", "ぎ", "ぐ", "げ", "ご",
-    "ざ", "じ", "ず", "ぜ", "ぞ",
-    "だ", "ぢ", "づ", "で", "ど",
-    "ば", "び", "ぶ", "べ", "ぼ",
-    "ぱ", "ぴ", "ぷ", "ぺ", "ぽ",
-    "ゔ"
-  ].forEach(kana => {
-    DOUBLE_KANA_MAPPING['っ' + kana] = smallTsu(SINGLE_KANA_MAPPING[kana]);
-  });
-  [
-    "きゃ", "きゅ", "きょ",
-    "しゃ", "しゅ", "しょ",
-    "ちゃ", "ちゅ", "ちょ",
-    "ぎゃ", "ぎゅ", "ぎょ",
-    "じゃ", "じゅ", "じょ",
-    "ぢゃ", "ぢゅ", "ぢょ",
-    "びゃ", "びゅ", "びょ",
-    "ぴゃ", "ぴゅ", "ぴょ",
-    "ふぁ", "ふぃ", "ふぇ", "ふぉ",
-    "ゔぁ", "ゔぃ", "ゔぇ", "ゔぉ"
-  ].forEach(kana => {
-    TRIPLE_KANA_MAPPING['っ' + kana] = smallTsu(DOUBLE_KANA_MAPPING[kana]);
-  });
+  return new StateMachine(newState, base.finalState);
+}
+
+function smallKana(base: StateMachine): StateMachine {
+  let newState = base.initialState.clone();
+  newState.addTransition('l', base.initialState);
+  newState.addTransition('x', base.initialState);
+  return new StateMachine(newState, base.finalState);
+}
+
+interface KanaMapping {
+  [index: string]: StateMachine;
+}
+
+interface StringMapping {
+  [index: string]: string;
+}
+
+const WHITESPACE = state.buildFromTransitions('_', [
+  t('_', '_', ''),
+  t('_', ' ', ''),
+]);
+
+const KATAKANA_MAPPING: StringMapping = {
+  ア: 'あ',
+  イ: 'い',
+  ウ: 'う',
+  エ: 'え',
+  オ: 'お',
+  カ: 'か',
+  キ: 'き',
+  ク: 'く',
+  ケ: 'け',
+  コ: 'こ',
+  サ: 'さ',
+  シ: 'し',
+  ス: 'す',
+  セ: 'せ',
+  ソ: 'そ',
+  タ: 'た',
+  チ: 'ち',
+  ツ: 'つ',
+  テ: 'て',
+  ト: 'と',
+  ナ: 'な',
+  ニ: 'に',
+  ヌ: 'ぬ',
+  ネ: 'ね',
+  ノ: 'の',
+  ハ: 'は',
+  ヒ: 'ひ',
+  フ: 'ふ',
+  ヘ: 'へ',
+  ホ: 'ほ',
+  マ: 'ま',
+  ミ: 'み',
+  ム: 'む',
+  メ: 'め',
+  モ: 'も',
+  ヤ: 'や',
+  ユ: 'ゆ',
+  ヨ: 'よ',
+  ラ: 'ら',
+  リ: 'り',
+  ル: 'る',
+  レ: 'れ',
+  ロ: 'ろ',
+  ワ: 'わ',
+  ヰ: 'ゐ',
+  ヱ: 'ゑ',
+  ヲ: 'を',
+  ン: 'ん',
+  ガ: 'が',
+  ギ: 'ぎ',
+  グ: 'ぐ',
+  ゲ: 'げ',
+  ゴ: 'ご',
+  ザ: 'ざ',
+  ジ: 'じ',
+  ズ: 'ず',
+  ゼ: 'ぜ',
+  ゾ: 'ぞ',
+  ダ: 'だ',
+  ヂ: 'ぢ',
+  ヅ: 'づ',
+  デ: 'で',
+  ド: 'ど',
+  バ: 'ば',
+  ビ: 'び',
+  ブ: 'ぶ',
+  ベ: 'べ',
+  ボ: 'ぼ',
+  パ: 'ぱ',
+  ピ: 'ぴ',
+  プ: 'ぷ',
+  ペ: 'ぺ',
+  ポ: 'ぽ',
+  ヴ: 'ゔ',
+  ァ: 'ぁ',
+  ィ: 'ぃ',
+  ゥ: 'ぅ',
+  ェ: 'ぇ',
+  ォ: 'ぉ',
+  ャ: 'ゃ',
+  ュ: 'ゅ',
+  ョ: 'ょ',
+  ッ: 'っ',
+};
+
+const SINGLE_KANA_MAPPING: KanaMapping = {
+  あ: literal('a'),
+  い: literal('i'),
+  う: literal('u'),
+  え: literal('e'),
+  お: literal('o'),
+  か: literal('ka'),
+  き: literal('ki'),
+  く: literal('ku'),
+  け: literal('ke'),
+  こ: literal('ko'),
+  さ: literal('sa'),
+  し: shi(),
+  す: literal('su'),
+  せ: literal('se'),
+  そ: literal('so'),
+  た: literal('ta'),
+  ち: chi(),
+  つ: tsu(),
+  て: literal('te'),
+  と: literal('to'),
+  な: literal('na'),
+  に: literal('ni'),
+  ぬ: literal('nu'),
+  ね: literal('ne'),
+  の: literal('no'),
+  は: literal('ha'),
+  ひ: literal('hi'),
+  ふ: fu(),
+  へ: literal('he'),
+  ほ: literal('ho'),
+  ま: literal('ma'),
+  み: literal('mi'),
+  む: literal('mu'),
+  め: literal('me'),
+  も: literal('mo'),
+  や: literal('ya'),
+  ゆ: literal('yu'),
+  よ: literal('yo'),
+  ら: literal('ra'),
+  り: literal('ri'),
+  る: literal('ru'),
+  れ: literal('re'),
+  ろ: literal('ro'),
+  わ: literal('wa'),
+  ゐ: literal('i'),
+  ゑ: literal('e'),
+  を: literal('wo'),
+  ん: literal('n'),
+  が: literal('ga'),
+  ぎ: literal('gi'),
+  ぐ: literal('gu'),
+  げ: literal('ge'),
+  ご: literal('go'),
+  ざ: literal('za'),
+  じ: ji(),
+  ず: literal('zu'),
+  ぜ: literal('ze'),
+  ぞ: literal('zo'),
+  だ: literal('da'),
+  ぢ: literal('di'),
+  づ: literal('du'),
+  で: literal('de'),
+  ど: literal('do'),
+  ば: literal('ba'),
+  び: literal('bi'),
+  ぶ: literal('bu'),
+  べ: literal('be'),
+  ぼ: literal('bo'),
+  ぱ: literal('pa'),
+  ぴ: literal('pi'),
+  ぷ: literal('pu'),
+  ぺ: literal('pe'),
+  ぽ: literal('po'),
+  ゔ: literal('vu'),
+  ー: literal('-'),
+  ' ': WHITESPACE,
+};
+
+'abcdefghijklmnopqrstuvwxyz'.split('').forEach((letter) => {
+  SINGLE_KANA_MAPPING[letter] = literal(letter);
+});
+
+[
+  ['ぁ', 'あ'],
+  ['ぃ', 'い'],
+  ['ぅ', 'う'],
+  ['ぇ', 'え'],
+  ['ぉ', 'お'],
+  ['ヵ', 'か'],
+].forEach((pair) => {
+  let [small, big] = pair;
+  SINGLE_KANA_MAPPING[small] = smallKana(SINGLE_KANA_MAPPING[big]);
+});
+
+const DOUBLE_KANA_MAPPING: KanaMapping = {
+  きゃ: literal('kya', 0),
+  きゅ: literal('kyu', 0),
+  きょ: literal('kyo', 0),
+  しゃ: sh('a'),
+  しゅ: sh('u'),
+  しょ: sh('o'),
+  ちゃ: ch('a'),
+  ちゅ: ch('u'),
+  ちょ: ch('o'),
+  にゃ: literal('nya', 0),
+  にゅ: literal('nyu', 0),
+  にょ: literal('nyo', 0),
+  ひゃ: literal('hya', 0),
+  ひゅ: literal('hyu', 0),
+  ひょ: literal('hyo', 0),
+  みゃ: literal('mya', 0),
+  みゅ: literal('myu', 0),
+  みょ: literal('myo', 0),
+  りゃ: literal('rya', 0),
+  りゅ: literal('ryu', 0),
+  りょ: literal('ryo', 0),
+  ぎゃ: literal('gya', 0),
+  ぎゅ: literal('gyu', 0),
+  ぎょ: literal('gyo', 0),
+  じゃ: j('a'),
+  じゅ: j('u'),
+  じょ: j('o'),
+  ぢゃ: literal('dya', 0),
+  ぢゅ: literal('dyu', 0),
+  ぢょ: literal('dyo', 0),
+  びゃ: literal('bya', 0),
+  びゅ: literal('byu', 0),
+  びょ: literal('byo', 0),
+  ぴゃ: literal('pya', 0),
+  ぴゅ: literal('pyu', 0),
+  ぴょ: literal('pyo', 0),
+  ふぁ: literal('fa', 0),
+  ふぃ: literal('fi', 0),
+  ふぇ: literal('fe', 0),
+  ふぉ: literal('fo', 0),
+  ゔぁ: literal('va', 0),
+  ゔぃ: literal('vi', 0),
+  ゔぇ: literal('ve', 0),
+  ゔぉ: literal('vo', 0),
+};
+
+const TRIPLE_KANA_MAPPING: KanaMapping = {};
+
+[
+  'か',
+  'き',
+  'く',
+  'け',
+  'こ',
+  'さ',
+  'し',
+  'す',
+  'せ',
+  'そ',
+  'た',
+  'ち',
+  'つ',
+  'て',
+  'と',
+  'は',
+  'ひ',
+  'ふ',
+  'へ',
+  'ほ',
+  'が',
+  'ぎ',
+  'ぐ',
+  'げ',
+  'ご',
+  'ざ',
+  'じ',
+  'ず',
+  'ぜ',
+  'ぞ',
+  'だ',
+  'ぢ',
+  'づ',
+  'で',
+  'ど',
+  'ば',
+  'び',
+  'ぶ',
+  'べ',
+  'ぼ',
+  'ぱ',
+  'ぴ',
+  'ぷ',
+  'ぺ',
+  'ぽ',
+  'ゔ',
+].forEach((kana) => {
+  DOUBLE_KANA_MAPPING['っ' + kana] = smallTsu(SINGLE_KANA_MAPPING[kana]);
+});
+[
+  'きゃ',
+  'きゅ',
+  'きょ',
+  'しゃ',
+  'しゅ',
+  'しょ',
+  'ちゃ',
+  'ちゅ',
+  'ちょ',
+  'ぎゃ',
+  'ぎゅ',
+  'ぎょ',
+  'じゃ',
+  'じゅ',
+  'じょ',
+  'ぢゃ',
+  'ぢゅ',
+  'ぢょ',
+  'びゃ',
+  'びゅ',
+  'びょ',
+  'ぴゃ',
+  'ぴゅ',
+  'ぴょ',
+  'ふぁ',
+  'ふぃ',
+  'ふぇ',
+  'ふぉ',
+  'ゔぁ',
+  'ゔぃ',
+  'ゔぇ',
+  'ゔぉ',
+].forEach((kana) => {
+  TRIPLE_KANA_MAPPING['っ' + kana] = smallTsu(DOUBLE_KANA_MAPPING[kana]);
+});
 
-  /**
-   * This normalizes input for matching. All alphabet is lower-cased, katakana
-   * is transformed to hiragana. All whitespace is now just a space. We take
-   * care to not change the length of the string as we have to match it
-   * one-for-one so we can display the original source kana.
-   */
-  export function normalizeInput(input: string): string {
-    return input.toLowerCase().split('').map(letter => {
+/**
+ * This normalizes input for matching. All alphabet is lower-cased, katakana
+ * is transformed to hiragana. All whitespace is now just a space. We take
+ * care to not change the length of the string as we have to match it
+ * one-for-one so we can display the original source kana.
+ */
+export function normalizeInput(input: string): string {
+  return input
+    .toLowerCase()
+    .split('')
+    .map((letter) => {
       let transform = KATAKANA_MAPPING[letter];
       if (transform !== undefined) {
         return transform;
@@ -425,83 +482,84 @@ import {
       } else {
         return letter;
       }
-    }).join('');
-  }
-
-  export class KanaInputState {
-    kana: string[];
-    stateMachines: StateMachine[];
-    currentIndex: number;
-
-    constructor(input: string) {
-      let kana: string[] = [];
-      let machines: StateMachine[] = [];
-      let position = 0;
-
-      let mappings = [
-        SINGLE_KANA_MAPPING,
-        DOUBLE_KANA_MAPPING,
-        TRIPLE_KANA_MAPPING
-      ]
-
-      // we pad the input so checking 3 at a time is simpler
-      let normalized = normalizeInput(input) + '  ';
-      while (position < input.length) {
-        // we check substrings of length 3, 2, then 1
-        for (let i = 3; i > 0; --i) {
-          let original = input.substr(position, i);
-          let segment = normalized.substr(position, i);
-          let machine = mappings[i - 1][segment];
-          if (machine != undefined) {
-            kana.push(original);
-            let nextMachine = machine.clone();
-            if (machines.length > 0) {
-              let prevMachine = machines[machines.length - 1];
-              prevMachine.nextMachine = nextMachine;
-            }
-            machines.push(nextMachine);
-            position += i - 1;
-            break;
+    })
+    .join('');
+}
+
+export class KanaInputState {
+  kana: string[];
+  stateMachines: StateMachine[];
+  currentIndex: number;
+
+  constructor(input: string) {
+    let kana: string[] = [];
+    let machines: StateMachine[] = [];
+    let position = 0;
+
+    let mappings = [
+      SINGLE_KANA_MAPPING,
+      DOUBLE_KANA_MAPPING,
+      TRIPLE_KANA_MAPPING,
+    ];
+
+    // we pad the input so checking 3 at a time is simpler
+    let normalized = normalizeInput(input) + '  ';
+    while (position < input.length) {
+      // we check substrings of length 3, 2, then 1
+      for (let i = 3; i > 0; --i) {
+        let original = input.substr(position, i);
+        let segment = normalized.substr(position, i);
+        let machine = mappings[i - 1][segment];
+        if (machine != undefined) {
+          kana.push(original);
+          let nextMachine = machine.clone();
+          if (machines.length > 0) {
+            let prevMachine = machines[machines.length - 1];
+            prevMachine.nextMachine = nextMachine;
           }
+          machines.push(nextMachine);
+          position += i - 1;
+          break;
         }
-        // even if we don't find a match, keep progressing
-        // unmapped characters will be ignored
-        position += 1;
       }
-
-      this.kana = kana;
-      this.stateMachines = machines;
-      this.currentIndex = 0;
+      // even if we don't find a match, keep progressing
+      // unmapped characters will be ignored
+      position += 1;
     }
 
-    map<T>(func: (s: string, m: StateMachine) => T): T[] {
-      let result: T[] = [];
-      for (let i = 0; i < this.kana.length; ++i) {
-        result.push(func(this.kana[i], this.stateMachines[i]));
-      }
-      return result;
+    this.kana = kana;
+    this.stateMachines = machines;
+    this.currentIndex = 0;
+  }
+
+  map<T>(func: (s: string, m: StateMachine) => T): T[] {
+    let result: T[] = [];
+    for (let i = 0; i < this.kana.length; ++i) {
+      result.push(func(this.kana[i], this.stateMachines[i]));
     }
+    return result;
+  }
 
-    handleInput(input: string): boolean {
-      if (this.currentIndex >= this.stateMachines.length) return false;
+  handleInput(input: string): boolean {
+    if (this.currentIndex >= this.stateMachines.length) return false;
 
-      let currentMachine = this.stateMachines[this.currentIndex];
-      currentMachine.transition(input);
-      while (currentMachine.isFinished()) {
-        this.currentIndex += 1;
-        currentMachine = this.stateMachines[this.currentIndex];
-        if (currentMachine == null) {
-          return true;
-        }
+    let currentMachine = this.stateMachines[this.currentIndex];
+    currentMachine.transition(input);
+    while (currentMachine.isFinished()) {
+      this.currentIndex += 1;
+      currentMachine = this.stateMachines[this.currentIndex];
+      if (currentMachine == null) {
+        return true;
       }
-      return this.currentIndex >= this.stateMachines.length;
     }
+    return this.currentIndex >= this.stateMachines.length;
+  }
 
-    getRemainingInput(): string {
-      let remaining = '';
-      for (let i = this.currentIndex; i < this.stateMachines.length; ++i) {
-        remaining += this.stateMachines[i].getDisplay();
-      }
-      return remaining;
+  getRemainingInput(): string {
+    let remaining = '';
+    for (let i = this.currentIndex; i < this.stateMachines.length; ++i) {
+      remaining += this.stateMachines[i].getDisplay();
     }
+    return remaining;
   }
+}

+ 168 - 157
src/level.ts

@@ -4,183 +4,194 @@
  * solely for display and the kana of the line which the input is based.
  */
 
-  export interface Line {
-    kanji: string,
-    kana: string,
-    start?: number,
-    end?: number
+export interface Line {
+  kanji: string;
+  kana: string;
+  start?: number;
+  end?: number;
+}
+
+export interface Level {
+  name: string;
+  creator: string | null;
+  genre: string | null;
+  difficulty: string | null;
+  audio: string | null;
+  background?: string | null;
+  songLink?: string;
+  lines: Line[];
+}
+
+export interface LevelSet {
+  name: string;
+  levels: Level[];
+}
+
+export interface Config {
+  background: string;
+  selectMusic: string | null;
+  selectSound: string;
+  decideSound: string;
+  baseColor: string;
+  highlightColor: string;
+  levelSets: LevelSet[];
+}
+
+export async function loadFromJson(url: string): Promise<Config> {
+  const response = await window.fetch(url);
+  return await response.json();
+}
+
+let parser = new DOMParser();
+
+async function parseXML(response: Response): Promise<Document> {
+  const text = await response.text();
+  let normalized = text.replace(/[“”]/g, '"');
+  return parser.parseFromString(normalized, 'text/xml');
+}
+
+export async function loadFromTM(base: string): Promise<Config> {
+  let settingsXML = window.fetch(base + '/settings.xml').then(parseXML);
+  let levelSets = window
+    .fetch(base + '/folderlist.xml')
+    .then(parseXML)
+    .then((dom) => parseTMFolderList(base, dom));
+
+  const [settings, levels] = await Promise.all([settingsXML, levelSets]);
+  return parseTMSettings(base, levels, settings);
+}
+
+function parseTMSettings(
+  base: string,
+  levelSets: LevelSet[],
+  dom: Document
+): Config {
+  function getData(tag: string): string | null {
+    let elem = dom.querySelector(tag);
+    if (elem === null) {
+      return null;
+    } else {
+      return base + '/' + elem.getAttribute('src');
+    }
   }
 
-  export interface Level {
-    name: string,
-    creator: string | null,
-    genre: string | null,
-    difficulty: string | null,
-    audio: string | null,
-    background?: string | null,
-    songLink?: string,
-    lines: Line[]
-  }
+  let background = getData('background');
+  let selectMusic = getData('selectmusic');
+  let selectSound = getData('selectsound');
+  let decideSound = getData('decidesound');
 
-  export interface LevelSet {
-    name: string,
-    levels: Level[]
+  if (background === null) {
+    throw new Error('background is not set');
   }
-
-  export interface Config {
-    background: string,
-    selectMusic: string | null,
-    selectSound: string,
-    decideSound: string,
-    baseColor: string,
-    highlightColor: string,
-    levelSets: LevelSet[]
+  if (decideSound === null) {
+    throw new Error('decidesound is not set');
   }
-
-  export async function loadFromJson(url: string): Promise<Config> {
-    const response = await window.fetch(url);
-    return await response.json();
+  if (selectSound === null) {
+    throw new Error('selectsound is not set');
   }
 
-  let parser = new DOMParser();
-
-  async function parseXML(response: Response): Promise<Document> {
-    const text = await response.text();
-    let normalized = text.replace(/[“”]/g, '"');
-    return parser.parseFromString(normalized, "text/xml");
-  }
+  return {
+    background,
+    baseColor: 'white',
+    highlightColor: 'blue',
+    selectMusic,
+    selectSound,
+    decideSound,
+    levelSets,
+  };
+}
+
+function parseTMFolderList(base: string, dom: Document): Promise<LevelSet[]> {
+  let folderList = dom.querySelectorAll('folder');
+  let promises = [];
+  for (let i = 0; i < folderList.length; ++i) {
+    let folder = folderList[i];
+    let name = folder.getAttribute('name');
+    let path = folder.getAttribute('path');
+
+    if (name === null || path === null) {
+      console.warn(`Invalid folder entry ${name} with path ${path}`);
+      continue;
+    }
 
-  export async function loadFromTM(base: string): Promise<Config> {
-    let settingsXML = window.fetch(base+'/settings.xml').then(parseXML);
-    let levelSets = window.fetch(base+'/folderlist.xml')
+    let promise = window
+      .fetch(base + '/' + path)
       .then(parseXML)
-      .then(dom => parseTMFolderList(base, dom));
+      .then((dom) => parseTMFolder(base, name!, dom));
 
-    const [settings, levels] = await Promise.all([settingsXML, levelSets]);
-    return parseTMSettings(base, levels, settings);
+    promises.push(promise);
   }
+  return Promise.all(promises);
+}
+
+async function parseTMFolder(
+  base: string,
+  name: string,
+  dom: Document
+): Promise<LevelSet> {
+  let musicList = dom.querySelectorAll('musicinfo');
+  let promises = [];
+  for (let i = 0; i < musicList.length; ++i) {
+    let musicInfo = musicList[i];
+    let xmlPath = base + '/' + musicInfo.getAttribute('xmlpath');
+    let audioPath = base + '/' + musicInfo.getAttribute('musicpath');
 
-  function parseTMSettings(base: string, levelSets: LevelSet[], dom: Document): Config {
     function getData(tag: string): string | null {
-      let elem = dom.querySelector(tag);
+      let elem = musicInfo.querySelector(tag);
       if (elem === null) {
         return null;
       } else {
-        return base+'/'+elem.getAttribute('src');
-      }
-    }
-
-    let background = getData('background');
-    let selectMusic = getData('selectmusic');
-    let selectSound = getData('selectsound');
-    let decideSound = getData('decidesound');
-
-    if (background === null) {
-      throw new Error('background is not set');
-    }
-    if (decideSound === null) {
-      throw new Error('decidesound is not set');
-    }
-    if (selectSound === null) {
-      throw new Error('selectsound is not set');
-    }
-
-    return {
-      background,
-      baseColor: 'white',
-      highlightColor: 'blue',
-      selectMusic,
-      selectSound,
-      decideSound,
-      levelSets
-    }
-  }
-
-  function parseTMFolderList(base: string, dom: Document): Promise<LevelSet[]> {
-    let folderList = dom.querySelectorAll('folder');
-    let promises = [];
-    for (let i = 0; i < folderList.length; ++i) {
-      let folder = folderList[i];
-      let name = folder.getAttribute('name');
-      let path = folder.getAttribute('path');
-
-      if (name === null || path === null) {
-        console.warn(`Invalid folder entry ${name} with path ${path}`);
-        continue;
+        return elem.textContent;
       }
-
-      let promise = window.fetch(base+'/'+path)
-        .then(parseXML)
-        .then(dom => parseTMFolder(base, name!, dom))
-
-      promises.push(promise);
     }
-    return Promise.all(promises);
-  }
 
-  async function parseTMFolder(base: string, name: string, dom: Document): Promise<LevelSet> {
-    let musicList = dom.querySelectorAll('musicinfo');
-    let promises = [];
-    for (let i = 0; i < musicList.length; ++i) {
-      let musicInfo = musicList[i];
-      let xmlPath = base+'/'+musicInfo.getAttribute('xmlpath');
-      let audioPath = base+'/'+musicInfo.getAttribute('musicpath');
-
-      function getData(tag: string): string | null {
-        let elem = musicInfo.querySelector(tag);
-        if (elem === null) {
-          return null;
-        } else {
-          return elem.textContent;
-        }
-      }
+    let name = getData('musicname') || '[Unknown]';
+    let creator = getData('artist');
+    let genre = getData('genre');
+    let difficulty = getData('level');
 
-      let name = getData('musicname') || '[Unknown]';
-      let creator = getData('artist');
-      let genre = getData('genre');
-      let difficulty = getData('level');
-
-      let promise = window.fetch(xmlPath)
-        .then(parseXML)
-        .then(parseTMSong)
-        .then(lines => {
-          return {
-            name,
-            creator,
-            genre,
-            difficulty,
-            audio: audioPath,
-            lines
-          }
-        })
-
-      promises.push(promise);
-    }
-    const levels = await Promise.all(promises);
-    return { name, levels }
+    let promise = window
+      .fetch(xmlPath)
+      .then(parseXML)
+      .then(parseTMSong)
+      .then((lines) => {
+        return {
+          name,
+          creator,
+          genre,
+          difficulty,
+          audio: audioPath,
+          lines,
+        };
+      });
+
+    promises.push(promise);
   }
-
-  function parseTMSong(dom: Document): Line[] {
-    let kanjiList = dom.querySelectorAll('nihongoword');
-    let kanaList = dom.querySelectorAll('word');
-    let intervalList = dom.querySelectorAll('interval');
-
-    let lines: Line[] = [];
-    let time = 0;
-    for (let i = 0; i < intervalList.length; ++i) {
-      let start = time;
-      const interval = intervalList[i].textContent;
-      if (interval === null) {
-        throw new Error(`Invalid interval: ${interval}`);
-      }
-      time += parseInt(interval) / 1000
-
-      lines.push({
-        kanji: kanjiList[i].textContent || '',
-        kana: kanaList[i].textContent || '',
-        start: start,
-        end: time
-      })
+  const levels = await Promise.all(promises);
+  return { name, levels };
+}
+
+function parseTMSong(dom: Document): Line[] {
+  let kanjiList = dom.querySelectorAll('nihongoword');
+  let kanaList = dom.querySelectorAll('word');
+  let intervalList = dom.querySelectorAll('interval');
+
+  let lines: Line[] = [];
+  let time = 0;
+  for (let i = 0; i < intervalList.length; ++i) {
+    let start = time;
+    const interval = intervalList[i].textContent;
+    if (interval === null) {
+      throw new Error(`Invalid interval: ${interval}`);
     }
-    return lines;
+    time += parseInt(interval) / 1000;
+
+    lines.push({
+      kanji: kanjiList[i].textContent || '',
+      kana: kanaList[i].textContent || '',
+      start: start,
+      end: time,
+    });
   }
+  return lines;
+}

+ 39 - 38
src/polyfill.ts

@@ -1,49 +1,50 @@
-  interface API {
-    request: string;
-    changeEvent: string;
-  }
+interface API {
+  request: string;
+  changeEvent: string;
+}
 
-  const VARIANTS = [
-    {
-      request: 'requestFullscreen',
-      changeEvent: 'fullscreenchange'
-    },
-    {
-      request: 'mozRequestFullScreen',
-      changeEvent: 'mozfullscreenchange',
-    },
-    {
-      request: 'webkitRequestFullscreen',
-      changeEvent: 'webkitfullscreenchange',
-    },
-    {
-      request: 'msRequestFullscreen',
-      changeEvent: 'MSFullscreenChange',
-    },
-  ];
+const VARIANTS = [
+  {
+    request: 'requestFullscreen',
+    changeEvent: 'fullscreenchange',
+  },
+  {
+    request: 'mozRequestFullScreen',
+    changeEvent: 'mozfullscreenchange',
+  },
+  {
+    request: 'webkitRequestFullscreen',
+    changeEvent: 'webkitfullscreenchange',
+  },
+  {
+    request: 'msRequestFullscreen',
+    changeEvent: 'MSFullscreenChange',
+  },
+];
 
-  class FullscreenPolyfill {
-    private api: API | undefined;
+class FullscreenPolyfill {
+  private api: API | undefined;
 
-    constructor() {
-      this.api = VARIANTS.find((variant) =>
+  constructor() {
+    this.api = VARIANTS.find(
+      (variant) =>
         // @ts-ignore
         document.firstChild[variant.request] !== undefined
-      );
-    }
+    );
+  }
 
-    request(element: HTMLElement) {
-      if (this.api !== undefined) {
-        // @ts-ignore
-        element[this.api.request]();
-      }
+  request(element: HTMLElement) {
+    if (this.api !== undefined) {
+      // @ts-ignore
+      element[this.api.request]();
     }
+  }
 
-    addEventListener(listener: () => void) {
-      if (this.api !== undefined) {
-        document.addEventListener(this.api.changeEvent, listener);
-      }
+  addEventListener(listener: () => void) {
+    if (this.api !== undefined) {
+      document.addEventListener(this.api.changeEvent, listener);
     }
   }
+}
 
-  export const fullscreen = new FullscreenPolyfill();
+export const fullscreen = new FullscreenPolyfill();

+ 145 - 136
src/state.ts

@@ -6,168 +6,177 @@
  * that particular state.
  */
 
-  export enum TransitionResult { FAILED, SUCCESS, SKIPPED }
-
-  interface StateMap {
-    [index: string]: State
+export enum TransitionResult {
+  FAILED,
+  SUCCESS,
+  SKIPPED,
+}
+
+interface StateMap {
+  [index: string]: State;
+}
+
+interface StateTransitionList {
+  [index: string]: [State, boolean];
+}
+
+export interface Observer {
+  (result: TransitionResult, boundary: boolean): void;
+}
+
+export class State {
+  display: string;
+  transitions: StateTransitionList;
+
+  constructor(display: string) {
+    this.display = display;
+    this.transitions = {};
   }
 
-  interface StateTransitionList {
-    [index: string]: [State, boolean]
+  addTransition(input: string, state: State, boundary: boolean = false): void {
+    this.transitions[input] = [state, boundary];
   }
 
-  export interface Observer {
-    (result: TransitionResult, boundary: boolean): void;
+  transition(input: string): [State, boolean] | null {
+    return this.transitions[input];
   }
 
-  export class State {
-    display: string;
-    transitions: StateTransitionList;
-
-    constructor(display: string) {
-      this.display = display;
-      this.transitions = {};
-    }
-
-    addTransition(input: string, state: State, boundary: boolean = false): void {
-      this.transitions[input] = [state, boundary];
-    }
-
-    transition(input: string): [State, boolean] | null {
-      return this.transitions[input];
-    }
-
-    clone(): State {
-      let state = new State(this.display);
-      state.transitions = {...this.transitions};
-      return state;
-    }
+  clone(): State {
+    let state = new State(this.display);
+    state.transitions = { ...this.transitions };
+    return state;
+  }
+}
+
+export class StateMachine {
+  initialState: State;
+  finalState: State;
+  currentState: State;
+  observers: Set<Observer>;
+  nextMachine: StateMachine | null;
+
+  constructor(initialState: State, finalState: State) {
+    this.initialState = initialState;
+    this.currentState = initialState;
+    this.finalState = finalState;
+    this.observers = new Set();
+    this.nextMachine = null;
   }
 
-  export class StateMachine {
-    initialState: State;
-    finalState: State;
-    currentState: State;
-    observers: Set<Observer>;
-    nextMachine: StateMachine | null;
-
-    constructor(initialState: State, finalState: State) {
-      this.initialState = initialState;
-      this.currentState = initialState;
-      this.finalState = finalState;
-      this.observers = new Set();
-      this.nextMachine = null;
-    }
-
-    transition(input: string) {
-      let result = this.currentState.transition(input);
-      if (result == null) {
-        this.skipTransition(input);
-      } else {
-        let [newState, boundary] = result;
-        this.currentState = newState;
-        this.notifyResult(TransitionResult.SUCCESS, boundary);
-      }
+  transition(input: string) {
+    let result = this.currentState.transition(input);
+    if (result == null) {
+      this.skipTransition(input);
+    } else {
+      let [newState, boundary] = result;
+      this.currentState = newState;
+      this.notifyResult(TransitionResult.SUCCESS, boundary);
     }
+  }
 
-    private skipTransition(input: string): boolean {
-      let potentialNextStates: Array<[State, boolean]> = Object.keys(this.currentState.transitions).map(k => this.currentState.transitions[k]);
-      for (let i = 0; i < potentialNextStates.length; ++i) {
-        let [state, skippedBoundary] = potentialNextStates[i];
-        if (state === this.finalState) {
-          if (this.nextMachine != null) {
-            let result = this.nextMachine.initialState.transition(input);
-            if (result != null) {
-              const [newState, boundary] = result;
-              this.currentState = state;
-              this.nextMachine.currentState = newState;
-              this.notifyResult(TransitionResult.SKIPPED, skippedBoundary);
-              this.nextMachine.notifyResult(TransitionResult.SUCCESS, boundary);
-              return true;
-            }
-          }
-        } else {
-          let result = state.transition(input);
+  private skipTransition(input: string): boolean {
+    let potentialNextStates: Array<[State, boolean]> = Object.keys(
+      this.currentState.transitions
+    ).map((k) => this.currentState.transitions[k]);
+    for (let i = 0; i < potentialNextStates.length; ++i) {
+      let [state, skippedBoundary] = potentialNextStates[i];
+      if (state === this.finalState) {
+        if (this.nextMachine != null) {
+          let result = this.nextMachine.initialState.transition(input);
           if (result != null) {
-            let [newState, boundary] = result;
-            this.currentState = newState;
+            const [newState, boundary] = result;
+            this.currentState = state;
+            this.nextMachine.currentState = newState;
             this.notifyResult(TransitionResult.SKIPPED, skippedBoundary);
-            this.notifyResult(TransitionResult.SUCCESS, boundary);
+            this.nextMachine.notifyResult(TransitionResult.SUCCESS, boundary);
             return true;
           }
         }
+      } else {
+        let result = state.transition(input);
+        if (result != null) {
+          let [newState, boundary] = result;
+          this.currentState = newState;
+          this.notifyResult(TransitionResult.SKIPPED, skippedBoundary);
+          this.notifyResult(TransitionResult.SUCCESS, boundary);
+          return true;
+        }
       }
-      this.notifyResult(TransitionResult.FAILED, false);
-      return false;
-    }
-
-    isNew(): boolean {
-      return this.currentState === this.initialState;
-    }
-
-    isFinished(): boolean {
-      return this.currentState === this.finalState;
     }
+    this.notifyResult(TransitionResult.FAILED, false);
+    return false;
+  }
 
-    reset(): void {
-      this.currentState = this.initialState;
-    }
+  isNew(): boolean {
+    return this.currentState === this.initialState;
+  }
 
-    clone(): StateMachine {
-      return new StateMachine(this.initialState, this.finalState);
-    }
+  isFinished(): boolean {
+    return this.currentState === this.finalState;
+  }
 
-    getWord(): string {
-      return this.initialState.display;
-    }
+  reset(): void {
+    this.currentState = this.initialState;
+  }
 
-    getDisplay(): string {
-      return this.currentState.display;
-    }
+  clone(): StateMachine {
+    return new StateMachine(this.initialState, this.finalState);
+  }
 
-    addObserver(observer: Observer): void {
-      this.observers.add(observer);
-    }
+  getWord(): string {
+    return this.initialState.display;
+  }
 
-    removeObserver(observer: Observer): void {
-      this.observers.delete(observer);
-    }
+  getDisplay(): string {
+    return this.currentState.display;
+  }
 
-    notifyResult(result: TransitionResult, boundary: boolean): void {
-      this.observers.forEach(o => o(result, boundary));
-    }
+  addObserver(observer: Observer): void {
+    this.observers.add(observer);
   }
 
-  export interface Transition {
-    from: string,
-    input: string,
-    to: string,
-    boundary: boolean
+  removeObserver(observer: Observer): void {
+    this.observers.delete(observer);
   }
 
-  export function buildFromTransitions(initial: string, transitions: Transition[]): StateMachine {
-    let states: StateMap = {};
-    function getState(name: string): State {
-      if (states[name] === undefined) {
-        states[name] = new State(name);
-      }
-      return states[name];
-    }
-    transitions.forEach(t => {
-      let fromState = getState(t.from);
-      let toState = getState(t.to);
-      fromState.addTransition(t.input, toState, t.boundary);
-    })
-    let initialState = getState(initial);
-    let finalState = getState('');
-    return new StateMachine(initialState, finalState);
-  }
-
-  export function makeTransition(
-    from: string,
-    input: string,
-    to: string,
-    boundary: boolean = false
-  ): Transition {
-    return { from, input, to, boundary };
+  notifyResult(result: TransitionResult, boundary: boolean): void {
+    this.observers.forEach((o) => o(result, boundary));
+  }
+}
+
+export interface Transition {
+  from: string;
+  input: string;
+  to: string;
+  boundary: boolean;
+}
+
+export function buildFromTransitions(
+  initial: string,
+  transitions: Transition[]
+): StateMachine {
+  let states: StateMap = {};
+  function getState(name: string): State {
+    if (states[name] === undefined) {
+      states[name] = new State(name);
+    }
+    return states[name];
   }
+  transitions.forEach((t) => {
+    let fromState = getState(t.from);
+    let toState = getState(t.to);
+    fromState.addTransition(t.input, toState, t.boundary);
+  });
+  let initialState = getState(initial);
+  let finalState = getState('');
+  return new StateMachine(initialState, finalState);
+}
+
+export function makeTransition(
+  from: string,
+  input: string,
+  to: string,
+  boundary: boolean = false
+): Transition {
+  return { from, input, to, boundary };
+}

+ 20 - 14
src/style.css

@@ -328,8 +328,8 @@
   grid-template-columns: 2.5em auto;
   grid-template-rows: 1em 1.5em;
   grid-template-areas:
-    "diff creator"
-    "diff title";
+    'diff creator'
+    'diff title';
   transition: margin-left 0.2s;
 }
 
@@ -362,14 +362,22 @@
   justify-self: center;
   height: 1.875em;
   width: 1.875em;
-  background: radial-gradient(circle at 10% 10%, rgba(0, 255, 255, 1), transparent);
+  background: radial-gradient(
+    circle at 10% 10%,
+    rgba(0, 255, 255, 1),
+    transparent
+  );
   border-radius: 20%;
   opacity: 0;
   transition: opacity 0.2s ease-in-out;
 }
 
 .song-item .difficulty-bg.normal {
-  background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.6), transparent);
+  background: radial-gradient(
+    circle at 10% 10%,
+    rgba(255, 255, 255, 0.6),
+    transparent
+  );
   opacity: 1;
 }
 
@@ -447,8 +455,8 @@
   grid-template-columns: max-content auto;
   grid-template-rows: 50% 50%;
   grid-template-areas:
-    "tl tb"
-    "il ib";
+    'tl tb'
+    'il ib';
   grid-gap: 0em 0.625em;
   align-items: center;
   align-self: end;
@@ -478,7 +486,6 @@
   grid-area: ib;
 }
 
-
 /* }}} */
 
 /* typing area {{{ */
@@ -488,12 +495,12 @@
   grid-template-columns: 3.125em 3.125em auto;
   grid-template-rows: 2.5em 1.5em 0.75em 1.875em auto 1.375em;
   grid-template-areas:
-    ". . track"
-    "score score score"
-    ". . kana"
-    ". . kanji"
-    "romaji-first romaji romaji"
-    ". . stats";
+    '. . track'
+    'score score score'
+    '. . kana'
+    '. . kanji'
+    'romaji-first romaji romaji'
+    '. . stats';
   grid-row-gap: 0.125em;
   padding: 0.125em 1.25em;
   background: linear-gradient(transparent, rgba(0, 0, 0, 0.5));
@@ -547,7 +554,6 @@
   animation: pulse 0.2s;
 }
 
-
 /* }}} */
 
 /* score area {{{ */

+ 108 - 97
src/util.ts

@@ -1,121 +1,132 @@
-  export function loadTemplate(element: ParentNode, id: string): DocumentFragment {
-    let template = element.querySelector(`#${id}-template`);
-    if (template !== null && template instanceof HTMLTemplateElement) {
-      const fragment = document.importNode(template.content, true);
-      fragment.querySelectorAll('template').forEach(t => {
-        let parent = t.parentNode!;
-        const templateName = t.getAttribute('name');
-        if (templateName === null) {
-          return;
-        }
-        let template = loadTemplate(fragment, templateName);
-        let firstElement = template.querySelector('*');
-        if (firstElement !== null) {
-          for (let i = 0; i < t.classList.length; ++i) {
-            firstElement.classList.add(t.classList[i]);
-          }
+export function loadTemplate(
+  element: ParentNode,
+  id: string
+): DocumentFragment {
+  let template = element.querySelector(`#${id}-template`);
+  if (template !== null && template instanceof HTMLTemplateElement) {
+    const fragment = document.importNode(template.content, true);
+    fragment.querySelectorAll('template').forEach((t) => {
+      let parent = t.parentNode!;
+      const templateName = t.getAttribute('name');
+      if (templateName === null) {
+        return;
+      }
+      let template = loadTemplate(fragment, templateName);
+      let firstElement = template.querySelector('*');
+      if (firstElement !== null) {
+        for (let i = 0; i < t.classList.length; ++i) {
+          firstElement.classList.add(t.classList[i]);
         }
-        parent.insertBefore(template, t);
-        parent.removeChild(t);
-      });
-      return fragment;
-    } else {
-      throw new Error(`#${id}-template is not a template`);
-    }
+      }
+      parent.insertBefore(template, t);
+      parent.removeChild(t);
+    });
+    return fragment;
+  } else {
+    throw new Error(`#${id}-template is not a template`);
   }
+}
 
-  export function clearChildren(node: Node): void {
-    while (node.lastChild !== null) {
-      node.removeChild(node.lastChild);
-    }
+export function clearChildren(node: Node): void {
+  while (node.lastChild !== null) {
+    node.removeChild(node.lastChild);
   }
+}
 
-  export function getElement<E extends HTMLElement>(element: ParentNode, selector: string): E {
-    const e = element.querySelector(selector);
-    if (e === null) {
-      throw new Error(`Could not find required element ${selector}`);
-    }
-    return e as E;
+export function getElement<E extends HTMLElement>(
+  element: ParentNode,
+  selector: string
+): E {
+  const e = element.querySelector(selector);
+  if (e === null) {
+    throw new Error(`Could not find required element ${selector}`);
   }
+  return e as E;
+}
 
-  export function loadBackground(url: string): Promise<void> {
-    if (url.includes('.')) {
-      return new Promise((resolve, reject) => {
-        let image = new Image();
-        image.onload = (event) => resolve();
-        image.src = url;
-      });
-    } else {
-      return Promise.resolve();
-    }
+export function loadBackground(url: string): Promise<void> {
+  if (url.includes('.')) {
+    return new Promise((resolve, reject) => {
+      let image = new Image();
+      image.onload = (event) => resolve();
+      image.src = url;
+    });
+  } else {
+    return Promise.resolve();
   }
+}
 
-  class ListenerManager {
-    constructor(
-      private target: EventTarget,
-      private event: string,
-      private handler: EventListener
-    ) {}
-
-    attach(): void {
-      this.target.addEventListener(this.event, this.handler);
-    }
+class ListenerManager {
+  constructor(
+    private target: EventTarget,
+    private event: string,
+    private handler: EventListener
+  ) {}
 
-    detach(): void {
-      this.target.removeEventListener(this.event, this.handler);
-    }
+  attach(): void {
+    this.target.addEventListener(this.event, this.handler);
   }
 
-  export class ListenersManager {
-    private listeners: ListenerManager[] = [];
+  detach(): void {
+    this.target.removeEventListener(this.event, this.handler);
+  }
+}
 
-    add(target: EventTarget, event: string, handler: EventListener, attach: boolean = true): void {
-      let listener = new ListenerManager(target, event, handler);
-      this.listeners.push(listener);
-      if (attach) {
-        listener.attach();
-      }
-    }
+export class ListenersManager {
+  private listeners: ListenerManager[] = [];
 
-    attach(): void {
-      this.listeners.forEach(l => l.attach());
+  add(
+    target: EventTarget,
+    event: string,
+    handler: EventListener,
+    attach: boolean = true
+  ): void {
+    let listener = new ListenerManager(target, event, handler);
+    this.listeners.push(listener);
+    if (attach) {
+      listener.attach();
     }
+  }
 
-    detach(): void {
-      this.listeners.forEach(l => l.detach());
-    }
+  attach(): void {
+    this.listeners.forEach((l) => l.attach());
   }
 
-  export class FnContext {
-    current: Symbol = Symbol();
+  detach(): void {
+    this.listeners.forEach((l) => l.detach());
+  }
+}
 
-    invalidate() {
-      this.current = Symbol();
-    }
+export class FnContext {
+  current: Symbol = Symbol();
 
-    wrap<T extends Function>(fn: T): T {
-      const id = this.current;
-      const wrappedFn = (...args: any[]) => {
-        if (this.current === id) {
-          return fn(...args);
-        }
-      };
-      return wrappedFn as any as T;
-    }
+  invalidate() {
+    this.current = Symbol();
   }
 
-  export interface Deferred {
-    promise: Promise<void>;
-    resolve: () => void;
+  wrap<T extends Function>(fn: T): T {
+    const id = this.current;
+    const wrappedFn = (...args: any[]) => {
+      if (this.current === id) {
+        return fn(...args);
+      }
+    };
+    return (wrappedFn as any) as T;
   }
+}
 
-  export function makeDeferred(): Deferred {
-    let resolve: undefined | (() => void);
-    const promise = new Promise<void>((r) => {
-      resolve = r;
-    });
-    return {
-      promise,
-      resolve: resolve!,
-    }
-  }
+export interface Deferred {
+  promise: Promise<void>;
+  resolve: () => void;
+}
+
+export function makeDeferred(): Deferred {
+  let resolve: undefined | (() => void);
+  const promise = new Promise<void>((r) => {
+    resolve = r;
+  });
+  return {
+    promise,
+    resolve: resolve!,
+  };
+}

+ 46 - 46
src/youtube.ts

@@ -1,56 +1,56 @@
-  let apiPromise: Promise<void>;
+let apiPromise: Promise<void>;
 
-  export function loadYoutubeApi(): Promise<void> {
-    if (apiPromise) {
-      return apiPromise;
-    }
-    console.time('Loading YouTube API');
-    apiPromise = new Promise((resolve, _) => {
-      const tag = document.createElement('script');
-      tag.src = 'https://www.youtube.com/iframe_api';
-      window.onYouTubeIframeAPIReady = () => {
-        console.timeEnd('Loading YouTube API');
-        resolve();
-      }
-      document.body.appendChild(tag);
-    });
+export function loadYoutubeApi(): Promise<void> {
+  if (apiPromise) {
     return apiPromise;
   }
+  console.time('Loading YouTube API');
+  apiPromise = new Promise((resolve, _) => {
+    const tag = document.createElement('script');
+    tag.src = 'https://www.youtube.com/iframe_api';
+    window.onYouTubeIframeAPIReady = () => {
+      console.timeEnd('Loading YouTube API');
+      resolve();
+    };
+    document.body.appendChild(tag);
+  });
+  return apiPromise;
+}
 
-  export async function createPlayer(element: HTMLElement): Promise<YT.Player> {
-    await loadYoutubeApi();
-    console.time('Loading YouTube player');
-    return new Promise((resolve, reject) => {
-      const player = new YT.Player(element, {
-        height: '100%',
-        width: '100%',
-        events: {
-          onReady: () => {
-            console.timeEnd('Loading YouTube player');
-            resolve(player);
-          },
-          onError: ({ data }) => {
-            reject(data);
-          }
+export async function createPlayer(element: HTMLElement): Promise<YT.Player> {
+  await loadYoutubeApi();
+  console.time('Loading YouTube player');
+  return new Promise((resolve, reject) => {
+    const player = new YT.Player(element, {
+      height: '100%',
+      width: '100%',
+      events: {
+        onReady: () => {
+          console.timeEnd('Loading YouTube player');
+          resolve(player);
         },
-        playerVars: {
-          disablekb: 1,
-          rel: 0,
-          iv_load_policy: 3,
-          fs: 0
-        }
-      });
+        onError: ({ data }) => {
+          reject(data);
+        },
+      },
+      playerVars: {
+        disablekb: 1,
+        rel: 0,
+        iv_load_policy: 3,
+        fs: 0,
+      },
     });
-  }
+  });
+}
 
-  export function getVideoId(url: string): string | null {
-    try {
-      const parsed = new URL(url);
-      if (!parsed.hostname.endsWith('youtube.com')) {
-        return null;
-      }
-      return parsed.searchParams.get('v');
-    } catch {
+export function getVideoId(url: string): string | null {
+  try {
+    const parsed = new URL(url);
+    if (!parsed.hostname.endsWith('youtube.com')) {
       return null;
     }
+    return parsed.searchParams.get('v');
+  } catch {
+    return null;
   }
+}