<|start_filename|>app/javascript/v2/settingsMenu.js<|end_filename|>
import settings from 'v2/settings';

const settingsEnabled = window.location.search === '?wip';

window.addEventListener('initialize', (ev) => {
  const button = document.getElementById('settings-opener');

  button.style.display = (settingsEnabled ? 'block' : 'none');

  button.addEventListener('click', settings.open);
});


<|start_filename|>app/javascript/v2/builtinPlayerToggle.js<|end_filename|>
import settingStorage from './settingStorage';
import layout from './layout';

let $player, $scrubber, $button;

function init(params) {
  $player = $('#builtin-player'); // browser's UI for the <audio> element
  $scrubber = $('#scrubber'); // our own implementation of the progress bar / scrubber
  $button = $('#builtin-player-button');

  $button.click(() => {
    toggle();
    $button.blur();
  });

  if (settingStorage.get('builtin-player') == 'on') {
    enable();
  }
}

function enable() {
  $player.show();
  $scrubber.hide();
  layout.resize();
  settingStorage.set('builtin-player', 'on');
}

function disable() {
  $player.hide();
  $scrubber.show();
  layout.resize();
  settingStorage.set('builtin-player', 'off');
}

function toggle() {
  if ($player.css('display') == 'none') {
    enable();
  } else {
    disable();
  }
}

export default { init }


<|start_filename|>app/javascript/v2/settingStorage.js<|end_filename|>
function get(key) {
  return window.localStorage.getItem(`setting:${key}`);
}

function set(key, value) {
  window.localStorage.setItem(`setting:${key}`, value);
}

function getInteger(key) {
  const value = get(key);
  if (value) {
    return parseInt(value);
  }
}

function setBoolean(key, value) {
  set(key, value ? 'on' : 'off');
}

function getBoolean(key) {
  const value = get(key);
  if (value === 'on') return true;
  else if (value === 'off') return false;
}

export default {
  get, set,
  getInteger,
  getBoolean, setBoolean
}


<|start_filename|>app/javascript/player/word-highlighter.js<|end_filename|>
import scroller from 'player/scroller';

const WORD_HIGHLIGHT_START_AHEAD_IN_SECONDS = 1;

let lastHighlightedTs = null;

function secondsToStringTimestamp(x) {
  var seconds = x % 60;
  if (seconds < 10) { seconds = "0" + seconds }

  var rest = Math.floor(x / 60);
  if (rest == 0) { return `0:${seconds}` }

  var minutes = rest % 60
  const hours = Math.floor(rest / 60);

  if (hours == 0) { return `${minutes}:${seconds}` }
  if (minutes < 10) { minutes = "0" + minutes }

  return `${hours}:${minutes}:${seconds}`
}

function addClassSmoothlyToMultipleElements(className, $elem, callback) {
  // Make highlighting smoother, so that we don't highlight a bunch of words every second
  $elem.each((index, item) => {
    const $item = $(item);
    setTimeout(
      () => {
        $item.addClass(className);
        if (callback) {
          callback($item);
        }
      },
      index * 1000 / $elem.length
    )
  })
}

function handleTimeupdate(media, e) {
  const eventTs = media.currentTime + WORD_HIGHLIGHT_START_AHEAD_IN_SECONDS;
  const highlightTs = Math.floor(eventTs);

  if (lastHighlightedTs == highlightTs) { return; }

  // Remove highlight when seeking
  if (Math.abs(lastHighlightedTs - highlightTs) > 2) {
    $('.word-highlight').removeClass('word-highlight')
    $('.remove-word-highlight').remove('remove-word-highlight')
  }

  lastHighlightedTs = highlightTs;

  const strTs = secondsToStringTimestamp(highlightTs)

  let $highlighted = $('.word-highlight')
  let $current = $(`span[title="${strTs}"]`)

  if ($current.length > 0) {
    if (!$current.hasClass('word-highlight')) {
      $current.removeClass('remove-word-highlight')

      addClassSmoothlyToMultipleElements('word-highlight', $current, ($item) => {
        scroller.scrollTo($item);
      })
    }
  }

  let $old = $(`span[title="${secondsToStringTimestamp(highlightTs - 1)}"]`)
  if ($old.length > 0)  {
    addClassSmoothlyToMultipleElements('remove-word-highlight', $old)
  }
}

export default {
  handleTimeupdate: handleTimeupdate
}


<|start_filename|>app/javascript/player/scroller.js<|end_filename|>
const THRESHOLD_IN_SECONDS = 2;
let lastScrolledAt;

const recentlyScrolled = () => (
  lastScrolledAt && lastScrolledAt > performance.now() - THRESHOLD_IN_SECONDS * 1000
);

const scroller = {
  scrollTo: ($elem, options = {}) => {
    if (!options.evenIfRecentlyScrolled) {
      if (recentlyScrolled()) {
        return;
      }
    }
    lastScrolledAt = performance.now();

    const block = (($elem.height() > $('#content').height()) ? 'start' : 'center');
    $elem.get(0).scrollIntoView({ behavior: 'smooth', block });
  }
}

export default scroller;


<|start_filename|>app/javascript/player.js<|end_filename|>
import wordHighlighter from 'player/word-highlighter';
import scroller from 'player/scroller';
import languagePicker from 'player/languagePicker';

$(document).ready(() => {
  const media = document.querySelector('audio');
  const timestampElements = $('.timestamp').toArray().map((x) => [parseInt(x.dataset['timestamp']), x])
  let activeTimestamp = null;
  let activeChapter = null;
  const mode = $('#content').data('mode');
  const chapters = $('#content').data('chapters');
  const accessKey = $('#content').data('accessKey');
  const coverUrl = $('#vocab-helper-img').attr('src');

  function timeupdateNormalMode(e) {
    const eventTs = media.currentTime;
    const item = timestampElements.find(([t1, e], index) => {
      const nextPair = timestampElements[index + 1];
      const t2 = nextPair ? nextPair[0] : null;
      return eventTs >= t1 && (!t2 || eventTs < t2);
    })
    if (item) {
      const [ts, e] = item;
      const $elem = $(e).parent();
      if (ts !== activeTimestamp) {
        const $segment = $elem.find('.segment:first');
        scroller.scrollTo($segment.length ? $segment : $elem, { evenIfRecentlyScrolled: true });
        $(".current").removeClass("current");
        $elem.addClass("current");
        activeTimestamp = ts;
      }
    }
    else {
      $(".current").removeClass("current");
      activeTimestamp = null;
    }
  }

  function timeupdateForVocabHelper(e) {
    const eventTs = media.currentTime;
    const chapter = chapters.find(({ id, start_time, end_time, has_picture }, index) => {
      return eventTs >= start_time && eventTs < end_time
    });
    if (chapter !== activeChapter) {
      activeChapter = chapter;
      if (chapter && chapter.has_picture) {
        $('#vocab-helper-img').attr('src', ''); // clear the image to avoid transition artifacts when we change 'is-cover'
        $('#vocab-helper-img').off('error');
        // TODO: customize for podcasts
        const primaryURL = `https://easygermanpodcastplayer-public.s3.eu-central-1.amazonaws.com/vocab/${accessKey}/${chapter.id}.jpg`;
        const fallbackURL = `/episodes/${accessKey}/chapters/${chapter.id}/picture.jpg`
        $('#vocab-helper-img').attr('src', primaryURL);
        $('#vocab-helper-img').on('error', (e, a) => {
          console.warn("Error loading image from primary location");
          $('#vocab-helper-img').off('error');
          $('#vocab-helper-img').attr('src', fallbackURL);
        })
        $('#vocab-helper-img').removeClass('is-cover');
      } else {
        $('#vocab-helper-img').attr('src', coverUrl);
        $('#vocab-helper-img').addClass('is-cover');
      }
    }
  }

  media.addEventListener('timeupdate', timeupdateNormalMode);
  media.addEventListener('timeupdate', wordHighlighter.handleTimeupdate.bind(null, media));
  media.addEventListener('timeupdate', timeupdateForVocabHelper);

  $('.timestamp').closest('.paragraph').addClass('timestampedEntry')

  $(window).keypress(function(event) {
    switch (event.key) {
      case " ":
        if (media.paused) {
          media.play();
        }
        else {
          media.pause();
        }
        event.preventDefault();
        break;
    }
  });
  $(window).keydown(function(event) {
    const ev = event.originalEvent;
    let e;
    switch (ev.keyCode) {
      case 37: // left
        media.currentTime -= ev.shiftKey ? 60 : 5;
        event.preventDefault();
        break;
      case 39: // right
        media.currentTime += ev.shiftKey ? 60 : 5;
        event.preventDefault();
        break;
      case 38: // up
        e = $('.current').prevAll('.timestampedEntry').get(0);
        if (e) {
          media.currentTime = parseInt($(e).find('.timestamp').data('timestamp'))
          event.preventDefault();
        }
        break;
      case 40: // down
        e = $('.current').nextAll('.timestampedEntry').get(0);
          if (e) {
          media.currentTime = parseInt($(e).find('.timestamp').data('timestamp'))
          event.preventDefault();
        }
        break;
    }
  });

  let playButton = $('<button>').addClass("paragraphButton").addClass("playButton").text("Play")
  let translateButton = $('<button>').addClass("paragraphButton").addClass("translateButton").text("Translate");
  $('.timestampedEntry').append(
    $('<div>').addClass('controls').append(playButton).append(translateButton)
  );

  $('.playButton').click(function(event) {
    let timestamp = $(event.target).closest('.timestampedEntry').find('.timestamp');
    if (timestamp.length) {
      media.currentTime = parseInt(timestamp.data('timestamp'));
      media.play();
    }
  });

  $('.translateButton').click(function(event) {
    event.preventDefault();
    event.stopPropagation();
    let $button = $(event.target);
    let $entry = $button.closest('.timestampedEntry');

    $button.html("Translating...");
    $button.attr("disabled", "disabled");

    $.get({
      url: "/translate.json",
      method: 'post',
      data: {
        key: $entry.data('translationId'),
        lang: languagePicker.lang,
      }
    }).done(function(resp) {
      let $translation = $('<p>').addClass('translation').text(resp.text)
      let $controls = $button.closest('.controls')
      $entry.append($translation);
      $button.remove();
    }).catch(function(err) {
      let jsonr = err.responseJSON;
      console.error("Error while fetching translation:", jsonr)
      let errorMessage = (jsonr && jsonr.error) ? jsonr.error.message : "Translation failed"
      let $controls = $button.closest('.controls')
      $button.text(errorMessage);
      $button.addClass('error')
    });
  })

  const vocabHelperButtonLabel = $('#vocab-button').text();
  const vocabHelperButtonInverseLabel = $('#vocab-button').data('inverse-label');
  $('#vocab-button').click(() => {
    if ($('#player-page').hasClass('vocab-on')) {
      $('#player-page').removeClass('vocab-on');
      $('#vocab-button').removeClass('on');
    } else {
      $('#player-page').addClass('vocab-on');
      $('#vocab-button').addClass('on');
      // Ensure we scroll to the current position, since scrolling doesn't work while we're in vocab mode
      activeTimestamp = null;
    }
    window.dispatchEvent(new Event('vocabToggle'));
    $('#vocab-button').blur();
  })

  const anchor = window.location.hash;
  if (anchor && anchor.length) {
    const match = anchor.match(/^#(((\d{1,2}):)?(\d{1,2}):(\d{2}))$/)
    if (match) {
      const [_x, _y, h, m, s] = anchor.match(/^#((\d{1,2}):)?(\d{1,2}):(\d{2})$/)
      const seconds = (parseInt(h) || 0) * 3600 + parseInt(m) * 60 + parseInt(s)
      console.log('jumping to', seconds, h, m, s)
      media.currentTime = seconds;
      media.play();
    }
  }
});


<|start_filename|>app/javascript/v2/bookmarking/sidebar.js<|end_filename|>
import { findParagraphByTimestamp, findSegmentElementByTimestamp } from 'v2/contentTimestamps';
import { createElementFromHTML } from 'v2/utils';

function placeBookmark(timestamp) {
  const paragraph = findParagraphByTimestamp(timestamp);

  if (!paragraph) {
    console.warn('Tried to bookmark a timestamp that has no paragraph:', timestamp)
    return;
  }

  const bookmarkIconElement = createElementFromHTML(`
    <button class="paragraph-bookmark-icon ui orange tiny icon button" data-timestamp="${timestamp}">
      <i class="bookmark icon"></i>
    </button>
  `)

  bookmarkIconElement.addEventListener('click', handleParagraphBookmarkClick);
  paragraph.bookmarkElement.appendChild(bookmarkIconElement);

  const position = calculateBookmarkIconOffset({ timestamp, paragraph, bookmarkIconElement });
  bookmarkIconElement.style.top = `${position}px`;
}

function calculateBookmarkIconOffset({ timestamp, paragraph, bookmarkIconElement }) {
  const segmentElement = findSegmentElementByTimestamp(timestamp);
  if (segmentElement) {
    return segmentElement.offsetTop - segmentElement.parentElement.offsetTop;
  }
  else {
    return calculateBookmarkIconOffsetByRatio({
      value: timestamp,
      min: paragraph.startTime,
      max: paragraph.endTime,
      containerHeight: paragraph.bookmarkElement.clientHeight,
      iconHeight: bookmarkIconElement.clientHeight,
    });
  }
}

function calculateBookmarkIconOffsetByRatio({ value, min, max, containerHeight, iconHeight }) {
  const usableHeight = containerHeight - iconHeight;
  if (usableHeight <= 0) return 0;
  const ratio = (value - min) / (max - min);
  return usableHeight * ratio;
}

function handleParagraphBookmarkClick(event) {
  window.player.playAt(parseInt(this.dataset['timestamp']));
}


export default { placeBookmark };


<|start_filename|>app/javascript/v2/contentTimestamps.js<|end_filename|>
import { secondsToStringTimestamp } from './utils';
import Paragraph from './models/Paragraph';

let timestampElements;

$(document).ready(() => {
  timestampElements = $('.timestamp').toArray().map((x) => [parseInt(x.dataset['timestamp']), x])
});

export function findParagraphByTimestamp(timestamp) {
  const item = timestampElements.find(([t1, e], index) => {
    const nextPair = timestampElements[index + 1];
    const t2 = nextPair ? nextPair[0] : null;
    return timestamp >= t1 && (!t2 || timestamp < t2);
  })
  if (item) {
    const [ts, e] = item;
    const paragraphEl = e.closest('.paragraph');
    return new Paragraph(paragraphEl);
  }
}

export function findSegmentElementByTimestamp(timestamp) {
  const segmentElement = document.querySelector(`span[title="${secondsToStringTimestamp(timestamp)}"]`);
  return segmentElement;
}


<|start_filename|>app/javascript/v2/vocabHelper.js<|end_filename|>
let $img, media, chapters, accessKey, coverUrl;
let activeChapter = null;

function init(params) {
  media = params.media;
  chapters = params.chapters;
  accessKey = params.accessKey;
  $img = $('#vocab-helper-img');
  coverUrl = $img.attr('src');
}

function setImgSrc($img, chapterId) {
  $img.off('error');
  // TODO: multi-podcast: dynamic URL format
  const primaryURL = `https://easygermanpodcastplayer-public.s3.eu-central-1.amazonaws.com/vocab/${accessKey}/${chapterId}.jpg`;
  const fallbackURL = `/episodes/${accessKey}/chapters/${chapterId}/picture.jpg`
  $img.attr('src', primaryURL);
  $img.on('error', (e, a) => {
    console.warn("Error loading image from primary location");
    $img.off('error');
    $img.attr('src', fallbackURL);
  });
  $img.removeClass('is-cover');
}

function ontimeupdate(e) {
  const eventTs = media.currentTime;

  // Find the current chapter
  const chapter = chapters.find(({ id, start_time, end_time, has_picture }, index) => {
    return start_time <= eventTs && end_time > eventTs;
  });

  // If we just entered a new chapter
  if (chapter !== activeChapter) {
    console.log('Chapter changed:', chapter);
    activeChapter = chapter;

    if (chapter && chapter.has_picture) {
      // Show a vocab helper slide
      $img.attr('src', ''); // clear the image to avoid transition artifacts when we change 'is-cover'
      setImgSrc($img, chapter.id);
    } else {
      // Show podcast cover
      $img.attr('src', coverUrl);
      $img.addClass('is-cover');
    }

    // Preload next vocab helper
    const nextChapter = chapters.find(({ id, start_time, end_time, has_picture }, index) => {
      // If there's no current chapter => select the first chapter that has a picture;
      // otherwise select the one that comes after the current chapter.
      return (activeChapter ? start_time > activeChapter.start_time : true) && has_picture;
    });
    if (nextChapter) {
      setImgSrc($(new Image()), nextChapter.id);
    }
  }
}

export default { init, ontimeupdate };


<|start_filename|>app/javascript/v2/bookmarking.js<|end_filename|>
import sidebar from './bookmarking/sidebar';
import recap from './bookmarking/recap';
import { readJSONFromStorage, writeJSONToStorage } from 'v2/utils';
import { createElementFromHTML } from 'v2/utils';
import settingStorage from 'v2/settingStorage';

window.addEventListener('initialize', (event) => {
  if (settingStorage.get('bookmarking') != 'on') return;

  const player = event.detail.player;
  const storageKey = `episode:${window.location.href}:bookmarks`;

  const buttonElement = createElementFromHTML(`
    <div id="bookmark-button" class="ui icon huge white button">
      <i class="align bookmark icon"></i>
    </div>
  `);

  document.querySelector('#main-buttons .right.side-buttons').appendChild(buttonElement);

  buttonElement.addEventListener('click', addBookmark);

  $(window).keydown(function(event) {
    const ev = event.originalEvent;
    if (ev.key === 'b') {
      addBookmark();
    }
  });

  const bookmarks = readJSONFromStorage(storageKey, { items: [] });
  if (bookmarks.items.length > 0) {
    bookmarks.items.forEach(({ t }, index) => {
      sidebar.placeBookmark(t);
    })
    recap.setBookmarks(bookmarks);
  }

  function addBookmark() {
    const timestamp = player.getCurrentSecond();

    let boomarks = readJSONFromStorage(storageKey, { items: [] })
    boomarks.items.push({ t: timestamp });
    writeJSONToStorage(storageKey, boomarks);

    console.log('Bookmarked:', timestamp)

    sidebar.placeBookmark(timestamp);
    recap.setBookmarks(bookmarks);
  }
});


<|start_filename|>app/javascript/v2/translations.js<|end_filename|>
const t = window.translations;
export default t;


<|start_filename|>app/javascript/v2/infoModal.js<|end_filename|>
window.addEventListener('initialize', (ev) => {
  $('#info-modal-opener').click((ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    $('#info-modal').show();
  });

  $('#info-modal .close-button').click(() => {
    $('#info-modal').hide();
  });
});


<|start_filename|>app/javascript/v2/utils.js<|end_filename|>
export function createElementFromHTML(htmlString) {
  var div = document.createElement('div');
  div.innerHTML = htmlString.trim();
  return div.firstChild;
}

export function secondsToStringTimestamp(x) {
  var seconds = x % 60;
  if (seconds < 10) { seconds = "0" + seconds }

  var rest = Math.floor(x / 60);
  if (rest == 0) { return `0:${seconds}` }

  var minutes = rest % 60
  const hours = Math.floor(rest / 60);

  if (hours == 0) { return `${minutes}:${seconds}` }
  if (minutes < 10) { minutes = "0" + minutes }

  return `${hours}:${minutes}:${seconds}`
}

export function readJSONFromStorage(key, defaultValue = {}) {
  let item = window.localStorage.getItem(key);
  if (!item) return defaultValue;
  try {
    return JSON.parse(item);
  } catch (SyntaxError) {
    return defaultValue;
  }
}

export function writeJSONToStorage(key, value) {
  window.localStorage.setItem(key, JSON.stringify(value));
}


<|start_filename|>app/javascript/v2/bookmarking/recap.js<|end_filename|>
import { createElementFromHTML } from 'v2/utils';
import settingStorage from 'v2/settingStorage';

const playWindowDuration = 5; // seconds
const fadeInDuration = 2;
const fadeOutDuration = 2;
const minJumpDuration = fadeInDuration + fadeOutDuration + 5; // seconds; keep playing instead of jumping if the next bookmark is less than this amount away

let initialized = false;
let bookmarkItems;
let currentIndex;
let timeout;
let triggerTime;


function init() {
  if (settingStorage.get('bookmarking:recap') != 'on') return;
  if (initialized) return;
  initialized = true;

  const recapButton = createElementFromHTML(`
    <button class="recap-button ui orange tiny icon button">
      <i class="bookmark icon"></i>
    </button>
  `);
  recapButton.addEventListener('click', handleRecapClick);
  document.getElementById('pre-play').appendChild(recapButton);

  window.player.media.addEventListener('timeupdate', handleTimeUpdate);
  // window.player.media.addEventListener('seeking', () => {
  //   // pause recap
  //   console.log("seeking - recap interrupted")
  //   triggerTime = null;
  // });
}

function setBookmarks(argBookmarks) {
  init();
  // WARNING: sorts IN-PLACE
  if (!argBookmarks.items) return;
  bookmarkItems = argBookmarks.items.sort((a, b) => a.t - b.t);
  // console.log('bookmarkItems', bookmarkItems)
}

function handleRecapClick() {
  console.log('bookmarkItems', bookmarkItems)
  if (!bookmarkItems || bookmarkItems.length == 0) {
    console.warn("Cannot start recap, there are no bookmarks.");
    return;
  }

  jumpTo(0);
}

function jumpTo(index) {
  currentIndex = index;
  const { t } = bookmarkItems[index];
  console.log('jump to', index, t);

  if (t - fadeInDuration <= 0) {
    window.player.playAt(t - fadeInDuration);
  }
  else {
    window.player.media.volume = 0;
    window.player.playAt(t - fadeInDuration);
    $(window.player.media).animate({ volume: 1 }, fadeInDuration * 1000);
  }

  console.log(performance.now(), 'scheduling timeout');
  triggerTime = t + playWindowDuration;
  console.log('next triggerTime:', triggerTime, `bookmarkTime: ${t}`);
}

function handleTimeUpdate() {
  if (!triggerTime) return;
  const currentTime = window.player.media.currentTime;
  console.log('t', currentTime);
  if (currentTime > triggerTime) {
    triggerTime = null;
    continueRecap();
  }
}


function continueRecap() {
  // console.log(performance.now(), 'continueRecap');
  let i = currentIndex;
  let didJump;
  while (true) {
    i++;
    if (!bookmarkItems[i]) {
      // No more bookmarks, end of recap
      $(window.player.media).animate({ volume: 0 }, fadeOutDuration * 1000, () => {
        window.player.media.volume = 1;
        window.player.pause();
      });
      break;
    };
    didJump = jumpCarefully(i)
    if (didJump) break;
  }
}

function jumpCarefully(index) {
  const { t } = bookmarkItems[index];
  const currentTime = window.player.currentTime;
  console.log(`checking bookmark[${index}] bookmarkTime=${t} currentTime=${currentTime}`);
  if (currentTime >= t + playWindowDuration) {
    // We've already played this bookmark (probably a duplicate bookmark)
    console.log('location already played, no jump');
    return false;
  } else if (currentTime + minJumpDuration >= t) {
    // We've already played part of this bookmark (currentTime >= t)
    // or the jump would be too short
    // Continue playing, set timeout for the remaining part.
    const remainingSeconds = t + playWindowDuration - currentTime;
    currentIndex = index;
    triggerTime = t + playWindowDuration;
    console.log('next triggerTime:', triggerTime, `bookmarkTime: ${t} - extended`);
    return true;
  } else {
    $(window.player.media).animate({ volume: 0 }, fadeOutDuration * 1000, () => {
      window.player.media.volume = 1;
      jumpTo(index);
    });
    return true;
  }
}

export default { setBookmarks };


<|start_filename|>app/javascript/v2/fontSettingsModal.js<|end_filename|>
import { changeFontSizeSettingBy } from './layout';

const template = `
<div id="font-settings-modal" class="mini-modal">
  <button class="minus-button blue ui icon button">
    <i class="minus icon"></i>
  </button>

  <button class="plus-button blue ui icon button">
    <i class="plus icon"></i>
  </button>

  <button class="close-button ui icon button">
    <i class="close icon"></i>
  </button>
</div>
`;

$(document).ready(() => {
  init();

  $('#font-settings-opener').click((ev) => {
    show();
  });
});

function init() {
  $('body').append($(template).css('display', 'none'));
  $('#font-settings-modal .close-button').click(() => {
    $('#font-settings-modal').hide();
  });

  $('#font-settings-modal .minus-button').click(() => changeFontSizeSettingBy(-1));
  $('#font-settings-modal .plus-button').click(() => changeFontSizeSettingBy(1));
}

function show() {
  $('#font-settings-modal').show();
}


<|start_filename|>app/javascript/v2/progressBar.js<|end_filename|>
function secondsToStringTimestamp(x) {
  var seconds = x % 60;
  if (seconds < 10) { seconds = "0" + seconds }

  var rest = Math.floor(x / 60);
  // if (rest == 0) { return `0:${seconds}` }

  var minutes = rest % 60
  const hours = Math.floor(rest / 60);

  // if (hours == 0) { return `${minutes}:${seconds}` }
  if (minutes < 10) { minutes = "0" + minutes }

  return `${hours}:${minutes}:${seconds}`
}

const init = ({ media }) => {
  let duration = null;
  updateDuration();

  function updateDuration() {
    if (media.duration) {
      duration = Math.ceil(media.duration);
      $('#total-time').text(secondsToStringTimestamp(duration));
    }
  }

  function updateProgress() {
    const currentTime = Math.round(media.currentTime);

    $('#progress-time').text(secondsToStringTimestamp(currentTime));

    if (duration === null) {
      updateDuration();
      if (duration === null) return;
    }

    const ratio = currentTime / duration;
    const thickness = 6; // 6px .bar height set in CSS
    const maxWidth = $('#progress-bar').width();
    const w = thickness + (maxWidth - thickness) * ratio;

    $('#progress-bar .bar').css('width', `${w/maxWidth * 100}%`);
  }

  media.addEventListener('durationchange', updateDuration);
  media.addEventListener('timeupdate', updateProgress);
  updateProgress();

  $('#progress-bar-container').click((e) => {
    const maxWidth = $('#progress-bar').width();
    const clickedTime = duration * e.originalEvent.clientX / maxWidth;
    media.currentTime = clickedTime;
  });
}

export default {
  init: init
}


<|start_filename|>app/javascript/v2/settings.js<|end_filename|>
import settingStorage from 'v2/settingStorage';
import { openInModal } from 'utils/modal';

function open() {
  const modal = openInModal('Settings', `
    <div>
      <h3>Translations</h3>

      <input id="auto_translate" type="checkbox"/>
      <label for="auto_translate">Show translations automatically (if available)</label>

      <h3>Controls</h3>

      <div>
        <input name="controls" type="radio" id="controls_custom_n" value="custom_n">
        <label for="controls_custom_n">Big buttons with simple progress bar</label>
      </div>

      <div>
        <input name="controls" type="radio" id="controls_custom" value="custom">
        <label for="controls_custom">Big buttons with built-in player</label>
      </div>

      <div>
        <input name="controls" type="radio" id="controls_native" value="native">
        <label for="controls_native">Built-in player</label>
      </div>

      <h3>Attributions</h3>

      <p>
        Automated translations are provided by <strong><a href="https://deepl.com" target="_blank">DeepL.com</a></strong>.
      </p>

      <p>
        Icons are provided by <strong><a href="https://fontawesome.com/" target="_blank">Font Awesome</a></strong> under the
        <a href="https://fontawesome.com/license/free" target="_blank">CC BY 4.0 License</a>.
      </p>
    </div>
  `);

  const translationsAutoCheckbox = modal.querySelector('input#auto_translate');
  translationsAutoCheckbox.addEventListener('click', (ev) => {
     settingStorage.setBoolean('auto_translate', translationsAutoCheckbox.checked);
     window.dispatchEvent(new CustomEvent('auto_translate_setting_updated', { detail: { checked: translationsAutoCheckbox.checked } }));
  });
}

export default { open };


<|start_filename|>app/javascript/v2/paragraphMenu.js<|end_filename|>
const playElement = $(`
  <button class="playParagraphButton ui tiny icon button">
    <i class="play icon"></i>
  </button>
`);

export default {
  init: () => {
    $('.paragraph').prepend(playElement);
    $('.ui.dropdown').dropdown();
  }
}


<|start_filename|>app/javascript/utils/modal.js<|end_filename|>
import { createElementFromHTML } from 'v2/utils';

export function openInModal(title, html) {
  const modal = createElementFromHTML(`
    <div class="modal">
      <div class="header">
        <button class="close-button">
          <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="arrow-left" class="svg-inline--fa fa-arrow-left fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M257.5 445.1l-22.2 22.2c-9.4 9.4-24.6 9.4-33.9 0L7 273c-9.4-9.4-9.4-24.6 0-33.9L201.4 44.7c9.4-9.4 24.6-9.4 33.9 0l22.2 22.2c9.5 9.5 9.3 25-.4 34.3L136.6 216H424c13.3 0 24 10.7 24 24v32c0 13.3-10.7 24-24 24H136.6l120.5 114.8c9.8 9.3 10 24.8.4 34.3z"></path></svg>
        </button>
        <div class="title"></div>
      </div>
      <div class="content">
        ${html}
      </div>
    </div>
  `)
  modal.classList.add('modal');
  document.body.appendChild(modal);

  modal.querySelector('.title').innerText = title;

  modal.querySelector('.close-button').addEventListener('click', () => {
    close();
  });

  document.addEventListener("keydown", handleKeyDown);

  function handleKeyDown(event) {
    console.log('key down', event)
    if (event.key === 'Escape') close();
  }

  function close() {
    document.body.removeChild(modal);
    document.removeEventListener("keydown", handleKeyDown);
  }

  return modal;
}
