diff --git a/.vscode/settings.json b/.vscode/settings.json
index 24b00c4b5..ad7b4a8e2 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -88,6 +88,7 @@
// Spell Checker
//////////////////////////////////////
"cSpell.words": [
+ "commontator",
"turbolinks"
]
}
\ No newline at end of file
diff --git a/app/abilities/annotation_ability.rb b/app/abilities/annotation_ability.rb
new file mode 100644
index 000000000..1d95ba944
--- /dev/null
+++ b/app/abilities/annotation_ability.rb
@@ -0,0 +1,11 @@
+class AnnotationAbility
+ include CanCan::Ability
+
+ def initialize(user)
+ can [:edit, :update, :destroy], Annotation do |annotation|
+ annotation.user == user
+ end
+
+ can [:new, :create, :update_annotations, :num_nearby_posted_mistake_annotations], Annotation
+ end
+end
diff --git a/app/abilities/medium_ability.rb b/app/abilities/medium_ability.rb
index 01c767b9c..41d068288 100644
--- a/app/abilities/medium_ability.rb
+++ b/app/abilities/medium_ability.rb
@@ -5,7 +5,7 @@ def initialize(user)
user ||= User.new
clear_aliased_actions
- can [:index, :new, :search], Medium
+ can [:index, :new, :search, :check_annotation_visibility], Medium
can [:show, :show_comments], Medium do |medium|
medium.visible_for_user?(user) &&
@@ -16,7 +16,7 @@ def initialize(user)
!user.generic? && medium.visible_for_user?(user)
end
- can [:edit, :update, :enrich, :publish, :destroy, :cancel_publication,
+ can [:edit, :update, :enrich, :feedback, :publish, :destroy, :cancel_publication,
:add_item, :add_reference, :add_screenshot, :remove_screenshot,
:import_script_items, :import_manuscript, :statistics,
:render_medium_tags, :fill_quizzable_area,
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 31511217e..49b7e76e6 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -50,11 +50,53 @@
//= require talks
//= require terms
//= require tex_preview
-//= require thyme
-//= require thyme_editor
//= require upload
//= require users
//= require vertices
//= require watchlists
//= require turbolinks
-//= require search_tags
\ No newline at end of file
+
+//= require search_tags
+
+/*
+ * THYME RELATED SCRIPTS
+ * (warning: the order of the scripts is important
+ * - do not switch to alphabetical order!)
+ */
+//= require thyme/components/component
+//= require thyme/components/add_item_button
+//= require thyme/components/add_reference_button
+//= require thyme/components/add_screenshot_button
+//= require thyme/components/annotation_category_toggle
+//= require thyme/components/annotations_toggle
+//= require thyme/components/annotation_button
+//= require thyme/components/full_screen_button
+//= require thyme/components/ia_button
+//= require thyme/components/ia_back_button
+//= require thyme/components/ia_close_button
+//= require thyme/components/mute_button
+//= require thyme/components/next_chapter_button
+//= require thyme/components/play_button
+//= require thyme/components/previous_chapter_button
+//= require thyme/components/seek_bar
+//= require thyme/components/speed_selector
+//= require thyme/components/time_button
+//= require thyme/components/volume_bar
+//= require thyme/annotations/category_enum
+//= require thyme/annotations/category
+//= require thyme/annotations/subcategory
+//= require thyme/annotations/annotation
+//= require thyme/annotations/annotation_area
+//= require thyme/annotations/annotation_manager
+//= require thyme/attributes
+//= require thyme/chapter_manager
+//= require thyme/control_bar_hider
+//= require thyme/display_manager
+//= require thyme/heatmap
+//= require thyme/key_shortcuts
+//= require thyme/metadata_manager
+//= require thyme/resizer
+//= require thyme/utility
+//= require thyme/thyme_player
+//= require thyme/thyme_editor
+//= require thyme/thyme_feedback
diff --git a/app/assets/javascripts/lectures.coffee b/app/assets/javascripts/lectures.coffee
index 20b6cc98c..e8a0f5587 100644
--- a/app/assets/javascripts/lectures.coffee
+++ b/app/assets/javascripts/lectures.coffee
@@ -203,6 +203,29 @@ $(document).on 'turbolinks:load', ->
userModalContent.dataset.filled = 'true'
return
+ # Dynamically render content for entering emergency links.
+ updateEmergencyLink = (value) ->
+ if value == "no_link"
+ $('#direct-link-field').hide()
+ $('#lecture-link-field').hide()
+ if value == "lecture_link"
+ $('#direct-link-field').hide()
+ $('#lecture-link-field').show()
+ if value == "direct_link"
+ $('#lecture-link-field').hide()
+ $('#direct-link-field').show()
+ return
+
+ emergencyLinkRadios = document.getElementById('emergency-link-status-radios')
+
+ if (emergencyLinkRadios != null)
+ $('#emergency-link-status-radios input:radio:checked').each ->
+ updateEmergencyLink(this.value)
+ return
+ emergencyLinkRadios.addEventListener 'click', (evt) ->
+ if evt.target && event.target.matches("input[type='radio']")
+ updateEmergencyLink(evt.target.value)
+ return
# on small mobile display, use shortened tag badges and
# shortened course titles
diff --git a/app/assets/javascripts/thyme.coffee b/app/assets/javascripts/thyme.coffee
deleted file mode 100644
index 65f8a7a8d..000000000
--- a/app/assets/javascripts/thyme.coffee
+++ /dev/null
@@ -1,757 +0,0 @@
-# convert time in seconds to string of the form H:MM:SS
-secondsToTime = (seconds) ->
- date = new Date(null)
- date.setSeconds seconds
- return date.toISOString().substr(12, 7)
-
-# return the start time of the next chapter relative to a given time in seconds
-nextChapterStart = (seconds) ->
- chapters = document.getElementById('chapters')
- times = JSON.parse(chapters.dataset.times)
- return if times.length == 0
- i = 0
- while i < times.length
- return times[i] if times[i] > seconds
- ++i
- return
-
-# return the start time of the previous chapter relative to a givben time in
-# seconds
-previousChapterStart = (seconds) ->
- chapters = document.getElementById('chapters')
- times = JSON.parse(chapters.dataset.times)
- return if times.length == 0
- i = times.length - 1
- while i > -1
- if times[i] < seconds
- return times[i] if seconds - times[i] > 3
- return times[i-1] if i > 0
- --i
- return
-
-showControlBar = ->
- $('#video-controlBar').css('visibility', 'visible')
- $('#video').css('cursor', '')
- return
-
-hideControlBar = ->
- $('#video-controlBar').css('visibility', 'hidden')
- $('#video').css('cursor', 'none')
- return
-
-# hide control bar after 3 seconds of inactivity
-idleHideControlBar = ->
- t = undefined
-
- resetTimer = ->
- clearTimeout t
- t = setTimeout hideControlBar, 3000
- return
-
- window.onload = resetTimer
- window.onmousemove = resetTimer
- window.onmousedown = resetTimer
- window.ontouchstart = resetTimer
- window.onclick = resetTimer
- return
-
-# material icons that represent different media types
-iconClass = (type) ->
- if type == 'video'
- return 'video_library'
- else if type == 'text'
- return 'library_books'
- else if type == 'quiz'
- return 'games'
- else if type == 'info'
- return 'info'
- return
-
-# returns the jQuery object of all metadata elements that start after the
-# given time in seconds
-metadataAfter = (seconds) ->
- metaList = document.getElementById('metadata')
- times = JSON.parse(metaList.dataset.times)
- return $() if times.length == 0
- i = 0
- while i < times.length
- if times[i] > seconds
- $nextMeta = $('#m-' + $.escapeSelector(times[i]))
- return $nextMeta.add($nextMeta.nextAll())
- ++i
- return $()
-
-# returns the jQuery object of all metadata elements that start before the
-# given time in seconds
-metadataBefore = (seconds) ->
- return $('[id^="m-"]').not(metadataAfter(seconds))
-
-# for a given time, show all metadata elements that start before this time
-# and hide all that start later
-metaIntoView = (time) ->
- metadataAfter(time).hide()
- $before = metadataBefore(time)
- $before.show()
- previousLength = $before.length
- if previousLength > 0
- $before.get(previousLength - 1).scrollIntoView()
- return
-
-# set up everything: read out track data and initialize html elements
-setupHypervideo = ->
- $chapterList = $('#chapters')
- $metaList = $('#metadata')
- video = $('#video').get 0
- backButton = document.getElementById('back-button')
- return if !video?
- document.body.style.backgroundColor = 'black'
- chaptersElement = $('#video track[kind="chapters"]').get 0
- metadataElement = $('#video track[kind="metadata"]').get 0
-
- # set up back button (transports back to the current chapter)
- displayBackButton = ->
- backButton.dataset.time = video.currentTime
- currentChapter = $('#chapters .current')
- if currentChapter.length > 0
- backInfo = currentChapter.data('text').split(':', 1)[0]
- if backInfo? && backInfo.length > 20
- backInfo = backButton.dataset.back
- else
- backInfo = backButton.dataset.backto + backInfo
- $(backButton).empty().append(backInfo).show()
- renderMathInElement backButton,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- return
-
- # set up the chapter elements
- displayChapters = ->
- if chaptersElement.readyState == 2 and
- (chaptersTrack = chaptersElement.track)
- chaptersTrack.mode = 'hidden'
- i = 0
- times = []
- # read out the chapter track cues and generate html elements for chapters,
- # run katex on them
- while i < chaptersTrack.cues.length
- cue = chaptersTrack.cues[i]
- chapterName = cue.text
- start = cue.startTime
- times.push start
- $listItem = $("
")
- $link = $(" ", { id: 'c-' + start, text: chapterName })
- $chapterList.append($listItem.append($link))
- chapterElement = $link.get(0)
- renderMathInElement chapterElement,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- $link.data('text', chapterName)
- # if a chapter element is clicked, transport to chapter start time
- $link.on 'click', ->
- displayBackButton()
- video.currentTime = @id.replace('c-', '')
- return
- ++i
- # store start times as data attribute
- $chapterList.get(0).dataset.times = JSON.stringify(times)
- $chapterList.show()
- # if the chapters cue changes (i.e. a switch between chapters), highlight
- # current chapter elment and scroll it into view, remove highlighting from
- # old chapter
- $(chaptersTrack).on 'cuechange', ->
- $('#chapters li a').removeClass 'current'
- if @activeCues.length > 0
- activeStart = @activeCues[0].startTime
- if chapter = document.getElementById('c-' + activeStart)
- $(chapter).addClass 'current'
- chapter.scrollIntoView()
- return
- return
-
- # set up the metadata elements
- displayMetadata = ->
- if metadataElement.readyState == 2 and (metaTrack = metadataElement.track)
- metaTrack.mode = 'hidden'
- i = 0
- times = []
- # read out the metadata track cues and generate html elements for
- # metadata, run katex on them
- while i < metaTrack.cues.length
- cue = metaTrack.cues[i]
- meta = JSON.parse cue.text
- start = cue.startTime
- times.push start
- $listItem = $(' ', id: 'm-' + start)
- $listItem.hide()
- $link = $(' ',
- text: meta.reference
- class: 'item'
- id: 'l-' + start)
- $videoIcon = $(' ',
- text: 'video_library'
- class: 'material-icons')
- $videoRef = $(' ',
- href: meta.video
- target: '_blank')
- $videoRef.append($videoIcon)
- $videoRef.hide() unless meta.video?
- $manIcon = $(' ',
- text: 'library_books'
- class: 'material-icons')
- $manRef = $(' ',
- href: meta.manuscript
- target: '_blank')
- $manRef.append($manIcon)
- $manRef.hide() unless meta.manuscript?
- $scriptIcon = $(' ',
- text: 'menu_book'
- class: 'material-icons')
- $scriptRef = $(' ',
- href: meta.script
- target: '_blank')
- $scriptRef.append($scriptIcon)
- $scriptRef.hide() unless meta.script?
- $quizIcon = $(' ',
- text: 'videogame_asset'
- class: 'material-icons')
- $quizRef = $(' ',
- href: meta.quiz
- target: '_blank')
- $quizRef.append($quizIcon)
- $quizRef.hide() unless meta.quiz?
- $extIcon = $(' ',
- text: 'link'
- class: 'material-icons')
- $extRef = $(' ',
- href: meta.link
- target: '_blank')
- $extRef.append($extIcon)
- $extRef.hide() unless meta.link?
- $description = $('
',
- text: meta.text
- class: 'mx-3')
- $explanation = $('
',
- text: meta.explanation
- class: 'm-3')
- $details = $('
')
- $details.append($link).append($description).append($explanation)
- $icons = $('
',
- style: 'flex-shrink: 3; display: flex; flex-direction: column;')
- $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef)
- $listItem.append($details).append($icons)
- $metaList.append($listItem)
- $videoRef.on 'click', ->
- video.pause()
- return
- $manRef.on 'click', ->
- video.pause()
- return
- $extRef.on 'click', ->
- video.pause()
- return
- $link.on 'click', ->
- displayBackButton()
- video.currentTime = this.id.replace('l-','')
- return
- metaElement = $listItem.get(0)
- renderMathInElement metaElement,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- ++i
- # store metadata start times as data attribute
- $metaList.get(0).dataset.times = JSON.stringify(times)
- # if user jumps to a new position in the video, display all metadata
- # that start before this time and hide all that start later
- $(video).on 'seeked', ->
- time = video.currentTime
- metaIntoView(time)
- return
- # if the metadata cue changes, highlight all current media and scroll
- # them into view
- $(metaTrack).on 'cuechange', ->
- j = 0
- time = video.currentTime
- $('#metadata li').removeClass 'current'
- while j<@activeCues.length
- activeStart = @activeCues[j].startTime
- if metalink = document.getElementById('m-' + activeStart)
- $(metalink).show()
- $(metalink).addClass 'current'
- ++j
- currentLength = $('#metadata .current').length
- if currentLength > 0
- $('#metadata .current').get(length - 1).scrollIntoView()
- return
- return
-
- # after video metadata have been loaded, display chapters and metadata in the
- # interactive area
- # Originally (and more appropriately, according to the standards),
- # only the 'loadedmetadata' event was used. However, Firefox triggers this event to soon,
- # i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
- # for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp
- initialChapters = true
- initialMetadata = true
- video.addEventListener 'loadedmetadata', ->
- if initialChapters and chaptersElement.readyState == 2
- displayChapters()
- initialChapters = false
- if initialMetadata and metadataElement.readyState == 2
- displayMetadata()
- initialMetadata = false
-
- video.addEventListener 'canplay', ->
- if initialChapters and chaptersElement.readyState == 2
- displayChapters()
- initialChapters = false
- if initialMetadata and metadataElement.readyState == 2
- displayMetadata()
- initialMetadata = false
- return
-
-$(document).on 'turbolinks:load', ->
- thymeContainer = document.getElementById('thyme-container')
- # no need for thyme if no thyme container on the page
- return if thymeContainer == null
- # Video
- video = document.getElementById('video')
- thyme = document.getElementById('thyme')
- # Buttons
- playButton = document.getElementById('play-pause')
- muteButton = document.getElementById('mute')
- iaButton = document.getElementById('ia-active')
- iaClose = document.getElementById('ia-close')
- fullScreenButton = document.getElementById('full-screen')
- plusTenButton = document.getElementById('plus-ten')
- minusTenButton = document.getElementById('minus-ten')
- nextChapterButton = document.getElementById('next-chapter')
- previousChapterButton = document.getElementById('previous-chapter')
- backButton = document.getElementById('back-button')
- # Sliders
- seekBar = document.getElementById('seek-bar')
- volumeBar = document.getElementById('volume-bar')
- # Selectors
- speedSelector = document.getElementById('speed')
- # Time
- currentTime = document.getElementById('current-time')
- maxTime = document.getElementById('max-time')
- # ControlBar
- videoControlBar = document.getElementById('video-controlBar')
-
- # resizes the thyme container to the window dimensions, taking into account
- # whether the interactive area is displayed or hidden
- resizeContainer = ->
- height = $(window).height()
- factor = if $('#caption').is(':hidden') then 1 else 1 / 0.82
- width = Math.floor((video.videoWidth * $(window).height() /
- video.videoHeight) * factor)
- if width > $(window).width()
- shrink = $(window).width() / width
- height = Math.floor(height * shrink)
- width = $(window).width()
- top = Math.floor(0.5*($(window).height() - height))
- left = Math.floor(0.5*($(window).width() - width))
- $('#thyme-container').css('height', height + 'px')
- $('#thyme-container').css('width', width + 'px')
- $('#thyme-container').css('top', top + 'px')
- $('#thyme-container').css('left', left + 'px')
- return
-
- # detect IE/edge and inform user that they are not suppported if necessary,
- # only use browser player
- if document.documentMode || /Edge/.test(navigator.userAgent)
- alert($('body').data('badbrowser'))
- $('#caption').hide()
- $('#video-controlBar').hide()
- video.style.width = '100%'
- video.controls = true
- document.body.style.backgroundColor = 'black'
- resizeContainer()
- window.onresize = resizeContainer
- return
-
- setupHypervideo()
-
- # on small mobile display, fall back to standard browser player
- mobileDisplay = ->
- $('#caption').hide()
- $('#video-controlBar').hide()
- video.controls = true
- video.style.width = '100%'
- return
-
- # on large display, use anything thyme has to offer, disable native player
- largeDisplay = ->
- video.controls = false
- $('#caption').show()
- $('#video-controlBar').show()
- video.style.width = '82%'
- # directly closes the IA again, if the IA-button status is "-"
- if iaButton.dataset.status == 'false'
- iaButton.innerHTML = 'remove_from_queue'
- $('#caption').hide()
- video.style.width = '100%'
- $('#video-controlBar').css('width', '100%')
- $(window).trigger('resize')
- return
-
- # display native control bar if screen is very small
- if window.matchMedia("screen and (max-width: 767px)").matches
- mobileDisplay()
-
- if window.matchMedia("screen and (max-device-width: 767px)").matches
- mobileDisplay()
-
- # mediaQuery listener for very small screens
- match_verysmall = window.matchMedia("screen and (max-width: 767px)")
- match_verysmall.addListener (result) ->
- if result.matches
- mobileDisplay()
- return
-
- match_verysmalldevice = window.matchMedia("screen and (max-device-width: 767px)")
- match_verysmalldevice.addListener (result) ->
- if result.matches
- mobileDisplay()
- return
-
- # mediaQuery listener for normal screens
- match_normal = window.matchMedia("screen and (min-width: 768px)")
- match_normal.addListener (result) ->
- if result.matches
- largeDisplay()
- return
-
- match_normal = window.matchMedia("screen and (min-device-width: 768px)")
- match_normal.addListener (result) ->
- if result.matches
- largeDisplay()
- return
-
- window.onresize = resizeContainer
- video.onloadedmetadata = resizeContainer
-
- idleHideControlBar()
-
- # if mouse is moved or screen is toiched, show control bar
- video.addEventListener 'mouseover', showControlBar, false
- video.addEventListener 'mousemove', showControlBar, false
- video.addEventListener 'touchstart', showControlBar, false
-
- # Event listener for the play/pause button
- playButton.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- return
-
- video.onplay = ->
- playButton.innerHTML = 'pause'
-
- video.onpause = ->
- playButton.innerHTML = 'play_arrow'
-
- # Event listener for the mute button
- muteButton.addEventListener 'click', ->
- if video.muted == false
- video.muted = true
- muteButton.innerHTML = 'volume_off'
- else
- video.muted = false
- muteButton.innerHTML = 'volume_up'
- return
-
- # Event handler for the plusTen button
- plusTenButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 10, video.duration)
- return
-
- # Event handler for the minusTen button
- minusTenButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 10, 0)
- return
-
- # Event handler for the nextChapter button
- nextChapterButton.addEventListener 'click', ->
- next = nextChapterStart(video.currentTime)
- video.currentTime = nextChapterStart(video.currentTime) if next?
- return
-
- # Event handler for the previousChapter button
- previousChapterButton.addEventListener 'click', ->
- previous = previousChapterStart(video.currentTime)
- video.currentTime = previousChapterStart(video.currentTime) if previous?
- return
-
- # Event handler for speed speed selector
- speedSelector.addEventListener 'change', ->
- if video.preservesPitch?
- video.preservesPitch = true
- else if video.mozPreservesPitch?
- video.mozPreservesPitch = true
- else if video.webkitPreservesPitch?
- video.webkitPreservesPitch = true
- video.playbackRate = @options[@selectedIndex].value
- return
-
- # Event handler for interactive area activation button
- iaButton.addEventListener 'click', ->
- if iaButton.dataset.status == 'true'
- iaButton.innerHTML = 'remove_from_queue'
- iaButton.dataset.status = 'false'
- $('#caption').hide()
- video.style.width = '100%'
- $('#video-controlBar').css('width', '100%')
- $(window).trigger('resize')
- else
- iaButton.innerHTML = 'add_to_queue'
- iaButton.dataset.status = 'true'
- video.style.width = '82%'
- $('#video-controlBar').css('width', '82%')
- $('#caption').show()
- $(window).trigger('resize')
- return
-
- # Event Handler for Back Button
- backButton.addEventListener 'click', ->
- video.currentTime = this.dataset.time
- $(backButton).hide()
- $('#back-reference').hide()
- return
-
- # Event handler for close interactive area button
- iaClose.addEventListener 'click', ->
- $(iaButton).trigger('click')
- return
-
- # Event listener for the full-screen button
- # unfortunately, lots of brwoser specific code
- fullScreenButton.addEventListener 'click', ->
- if fullScreenButton.dataset.status == 'true'
- if document.exitFullscreen
- document.exitFullscreen()
- else if document.mozCancelFullScreen
- document.mozCancelFullScreen()
- else if document.webkitExitFullscreen
- document.webkitExitFullscreen()
- else
- if thymeContainer.requestFullscreen
- thymeContainer.requestFullscreen()
- else if thymeContainer.mozRequestFullScreen
- thymeContainer.mozRequestFullScreen()
- else if thymeContainer.webkitRequestFullscreen
- thymeContainer.webkitRequestFullscreen()
- return
-
- document.onfullscreenchange = ->
- if document.fullscreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- # brute force patch: apparently, after exiting fullscreen mode,
- # window.onresize is triggered twice(!), the second time with incorrect
- # window height data, which results in a video area not quite filling
- # the whole window. The next line resizes the container again.
- setTimeout(resizeContainer, 20)
- return
-
- document.onwebkitfullscreenchange = ->
- if document.webkitFullscreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- setTimeout(resizeContainer, 20)
- return
-
- document.onmozfullscreenchange = ->
- if document.mozFullScreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- setTimeout(resizeContainer, 20)
- return
-
- # Event listeners for the seek bar
- seekBar.addEventListener 'input', ->
- time = video.duration * seekBar.value / 100
- video.currentTime = time
- return
-
- # if mouse is moved over seek bar, display tooltip with current chapter
- seekBar.addEventListener 'mousemove', (evt) ->
- positionInfo = seekBar.getBoundingClientRect()
- width = positionInfo.width;
- left = positionInfo.left
- measuredSeconds = ((evt.pageX - left)/width) * video.duration
- seconds = Math.min(measuredSeconds, video.duration)
- seconds = Math.max(seconds, 0)
- previous = previousChapterStart(seconds)
- info = $('#c-' + $.escapeSelector(previous)).text().split(':')[0]
- seekBar.setAttribute('title', info)
- return
-
- # if videomedtadata have been loaded, set up video length, volume bar and
- # seek bar
- video.addEventListener 'loadedmetadata', ->
- maxTime.innerHTML = secondsToTime(video.duration)
- volumeBar.value = video.volume
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' +
- video.volume*100 + '%, #ffffff)'
- if video.dataset.time?
- time = video.dataset.time
- video.currentTime = time
- seekBar.value = video.currentTime / video.duration * 100
- else
- seekBar.value = 0
- return
-
- # Update the seek bar as the video plays
- # uses a gradient for seekbar video time visualization
- video.addEventListener 'timeupdate', ->
- value = 100 / video.duration * video.currentTime
- seekBar.value = value
- seekBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)'
- currentTime.innerHTML = secondsToTime(video.currentTime)
- return
-
- # Pause the video when the seek handle is being dragged
- seekBar.addEventListener 'mousedown', ->
- video.dataset.paused = video.paused
- video.pause()
- return
-
- # Play the video when the seek handle is dropped
- seekBar.addEventListener 'mouseup', ->
- video.play() unless video.dataset.paused == 'true'
- return
-
- # Event listener for the volume bar
- volumeBar.addEventListener 'input', ->
- value = volumeBar.value
- video.volume = value
- return
-
- video.addEventListener 'volumechange', ->
- value = video.volume
- volumeBar.value = value
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)'
- return
-
- video.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- showControlBar()
- return
-
- # thyme can be used by keyboard as well
- # Arrow up - next chapter
- # Arrow down - previous chapter
- # Arrow right - plus ten seconds
- # Arrow left - minus ten seconds
- # f - fullscreen
- # Page up - volume up
- # Page down - volume down
- # m - mute
- # i - toggle interactive area
- window.addEventListener 'keydown', (evt) ->
- key = evt.key
- if key == ' '
- if video.paused == true
- video.play()
- else
- video.pause()
- else if key == 'ArrowUp'
- $(nextChapterButton).trigger('click')
- else if key == 'ArrowDown'
- $(previousChapterButton).trigger('click')
- else if key == 'ArrowRight'
- $(plusTenButton).trigger('click')
- else if key == 'ArrowLeft'
- $(minusTenButton).trigger('click')
- else if key == 'f'
- $(fullScreenButton).trigger('click')
- else if key == 'PageUp'
- video.volume = Math.min(video.volume + 0.1, 1)
- else if key == 'PageDown'
- video.volume = Math.max(video.volume - 0.1, 0)
- else if key == 'm'
- $(muteButton).trigger('click')
- else if key == 'i'
- $(iaButton).trigger('click')
- return
- return
diff --git a/app/assets/javascripts/thyme/annotations/annotation.js b/app/assets/javascripts/thyme/annotations/annotation.js
new file mode 100644
index 000000000..4b3d4d432
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation.js
@@ -0,0 +1,104 @@
+/**
+ This class helps to represent an annotation in JavaScript.
+*/
+// eslint-disable-next-line no-unused-vars
+class Annotation {
+ constructor(json) {
+ // We only save attributes that are needed in the thyme related JavaScripts!
+ this.category = Category.getByName(json.category);
+ this.color = json.color;
+ this.comment = json.comment;
+ this.id = json.id;
+ this.seconds = thymeUtility.timestampToSeconds(json.timestamp);
+ this.subcategory = Subcategory.getByName(json.subcategory);
+ this.belongsToCurrentUser = json.belongs_to_current_user;
+ }
+
+ /*
+ * AUXILIARY METHODS
+ */
+
+ /*
+ Create normal marker.
+
+ color = Color of the marker.
+ strokeColor = Color of the border of the marker.
+ onClick = A function triggered when one clicks on the marker.
+ */
+ createMarker(color, strokeColor, onClick) {
+ this.#create(color, false, onClick);
+ }
+
+ /*
+ Create big marker with customizable border color.
+ (Used e.g. for big mistake markers in the feedback player.)
+
+ color = Color of the marker.
+ strokeColor = Color of the border of the marker.
+ onClick = A function triggered when one clicks on the marker.
+ */
+ createBigMarker(color, strokeColor, onClick) {
+ this.#create(color, true, onClick);
+ }
+
+ /*
+ An auxiliary method, only used for a better structure of createMarker() and createBigMarker()!
+ */
+ #create(color, isBigMarker, onClick) {
+ const markerStr = `
+
+ `;
+ $("#" + thymeAttributes.markerBarId).append(markerStr);
+
+ const marker = $("#marker-" + this.id);
+ const size = thymeAttributes.seekBar.element.clientWidth - 15;
+ const ratio = this.seconds / thymeAttributes.video.duration;
+ const offset = marker.parent().offset().left + ratio * size + 3;
+ marker.offset({ left: offset });
+
+ marker.on("click", function () {
+ thymeAttributes.disableAnnotationKeyListeners = false;
+ onClick();
+ });
+ }
+
+ /*
+ Returns a string with the correct translation of the category and subcategory of this annotation.
+ */
+ categoryLocale() {
+ const c = this.category;
+ const s = this.subcategory;
+ return s ? c.locale() + " (" + s.locale() + ")" : c.locale();
+ }
+
+ /*
+ * Returns true if the given annotation is the last annotation
+ * in thymeAttributes.annotations
+ */
+ isFirst() {
+ return this == thymeAttributes.annotations[0];
+ }
+
+ /*
+ * Returns true if the given annotation is the last annotation
+ * in thymeAttributes.annotations
+ */
+ isLast() {
+ return this == thymeAttributes.annotations[thymeAttributes.annotations.length - 1];
+ }
+
+ updateOpenAnnotationMarker(oldId, newId) {
+ if (oldId) {
+ const oldMarker = $("#marker-" + oldId).children("i");
+ oldMarker.removeClass("annotation-marker-shown");
+ }
+
+ const newMarker = $("#marker-" + newId).children("i");
+ newMarker.addClass("annotation-marker-shown");
+ }
+
+ markCurrentAnnotationAsNotShown() {
+ const marker = $("#marker-" + this.id).children("i");
+ marker.removeClass("annotation-marker-shown");
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/annotation_area.js b/app/assets/javascripts/thyme/annotations/annotation_area.js
new file mode 100644
index 000000000..a1ddc3653
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation_area.js
@@ -0,0 +1,238 @@
+/**
+ This class helps to represent the annotation area in java script.
+*/
+// eslint-disable-next-line no-unused-vars
+class AnnotationArea {
+ static DISABLED_BUTTON_OPACITY = 0.2;
+
+ /*
+ hasFancyStyle = If true, all buttons are shown, if false,
+ only previous, goto and next are shown.
+
+ colorFunc = A function for colorizing the annotation area
+ which takes an annotation as argument and gives
+ back a color.
+
+ onClose = A function that is executed when closing the
+ annotation area.
+
+ isValid = A function which takes an annotation as argument
+ and returns true, if and only if the annotation is
+ "valid", i.e. should be visualized in the annotation
+ area. (This is needed to skip unwanted annotations in
+ the previous/next button listeners.)
+ */
+ constructor(hasFancyStyle, colorFunc, onClose, isValid) {
+ this.isActive = false;
+ this.annotation = null; // the current annotation
+ this.colorFunc = colorFunc;
+ this.onClose = onClose;
+ this.isValid = isValid;
+ this.hasFancyStyle = hasFancyStyle;
+
+ this.caption = $("#annotation-caption");
+ this.infoBar = $("#annotation-infobar");
+ this.commentField = $("#annotation-comment");
+ this.previousButton = $("#annotation-previous-button");
+ this.gotoButton = $("#annotation-goto-button");
+ this.editButton = $("#annotation-edit-button");
+ this.closeButton = $("#annotation-close-button");
+ this.nextButton = $("#annotation-next-button");
+ this.areaButtonsRegion = $("#annotation-area-buttons");
+
+ this.localesId = "annotation-locales";
+
+ if (!hasFancyStyle) {
+ this.editButton.hide();
+ this.closeButton.hide();
+ }
+ }
+
+ /*
+ Show the annotation area.
+ */
+ show() {
+ this.caption.show();
+ this.isActive = true;
+ }
+
+ /*
+ Hide the annotation area.
+ */
+ hide() {
+ this.caption.hide();
+ this.isActive = false;
+ }
+
+ /*
+ Update the annotation area with the content of the given annotation.
+ */
+ update(annotation) {
+ if (!annotation) {
+ return;
+ }
+ const oldId = this.annotation ? this.annotation.id : null;
+
+ this.annotation = annotation;
+
+ // update info and comment field
+ this.#updateInfoAndCommentField(annotation, this.colorFunc(annotation));
+ // update buttons
+ this.#updatePreviousButton(annotation);
+ this.#updateNextButton(annotation);
+ this.#updateGotoButton(annotation);
+ if (this.hasFancyStyle) {
+ this.#updateEditButton(annotation);
+ this.#updateCloseButton();
+ }
+ annotation.updateOpenAnnotationMarker(oldId, annotation.id);
+
+ // render LaTex
+ const commentId = this.commentField.attr("id");
+ thymeUtility.renderLatex(document.getElementById(commentId));
+ }
+
+ showAnnotationWithId(id) {
+ const annotation = AnnotationManager.find(id);
+ if (annotation) {
+ thymeAttributes.annotationManager.forceTriggerOnClick(annotation);
+ }
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+ #updateInfoAndCommentField(annotation, color) {
+ const head = annotation.categoryLocale();
+ const comment = annotation.comment.replaceAll("\n", " ");
+ const headColor = thymeUtility.lightenUp(color, 2);
+ const backgroundColor = thymeUtility.lightenUp(color, 3);
+ this.infoBar.empty().append(head);
+ this.infoBar.css("background-color", headColor);
+ this.commentField.empty().append(comment);
+
+ // Comment field background gradient
+ const colorGradientEnd = thymeUtility.lightenUp(color, 2.5);
+ const gradient = `linear-gradient(to bottom, ${backgroundColor} 50%, ${colorGradientEnd} 100%)`;
+ this.caption.css("background-image", gradient);
+
+ // Area buttons
+ this.areaButtonsRegion.css("background-color", headColor);
+ }
+
+ #updatePreviousButton(annotation) {
+ const area = this; // need a reference inside the listener scope!
+ this.previousButton.off("click");
+ this.previousButton.on("click", function () {
+ area.update(area.previousValidAnnotation());
+ });
+ if (annotation.isFirst()) {
+ this.previousButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY);
+ }
+ else {
+ this.previousButton.css("opacity", 1);
+ }
+ }
+
+ #updateNextButton(annotation) {
+ const area = this; // need a reference inside the listener scope!
+ this.nextButton.off("click");
+ this.nextButton.on("click", function () {
+ area.update(area.nextValidAnnotation());
+ });
+ if (annotation.isLast()) {
+ this.nextButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY);
+ }
+ else {
+ this.nextButton.css("opacity", 1);
+ }
+ }
+
+ #updateGotoButton(annotation) {
+ this.gotoButton.off("click");
+ this.gotoButton.on("click", function () {
+ video.currentTime = annotation.seconds;
+ });
+ }
+
+ #updateEditButton(annotation) {
+ const localesId = this.localesId;
+ this.editButton.off("click");
+ this.editButton.on("click", function () {
+ thymeAttributes.video.pause();
+ thymeAttributes.lockKeyListeners = true;
+ $.ajax(Routes.edit_annotation_path(annotation.id), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ annotation_id: annotation.id,
+ },
+ success: function (permitted) {
+ if (permitted === "false") {
+ alert(document.getElementById(localesId).dataset.permission);
+ }
+ },
+ error: function (e) {
+ console.log(e);
+ },
+ });
+ });
+ }
+
+ unmarkCurrentAnnotationAsShown() {
+ if (!this.annotation) {
+ return;
+ }
+ this.annotation.markCurrentAnnotationAsNotShown();
+ }
+
+ #updateCloseButton() {
+ const area = this; // need a reference inside the listener scope!
+ this.closeButton.off("click");
+ this.closeButton.on("click", function () {
+ area.unmarkCurrentAnnotationAsShown();
+ area.annotation = undefined;
+ area.hide();
+ thymeAttributes.disableAnnotationKeyListeners = true;
+ if (area.onClose != null) {
+ area.onClose();
+ }
+ });
+ }
+
+ /*
+ Returns the first annotation which is valid and which comes
+ before the input annotation on the timeline.
+ Returns null if no valid annotation before the input annotation
+ exists.
+ */
+ previousValidAnnotation() {
+ const currentId = this.annotation.id;
+ const currentIndex = AnnotationManager.findIndex(currentId);
+ const annotations = thymeAttributes.annotations;
+ for (let i = currentIndex - 1; i >= 0; i--) {
+ if (this.isValid(annotations[i])) {
+ return annotations[i];
+ }
+ }
+ return null;
+ }
+
+ /*
+ Returns the first annotation which is valid and which comes
+ after the input annotation on the timeline.
+ Returns null if no valid annotation after the input annotation
+ exists.
+ */
+ nextValidAnnotation() {
+ const currentId = this.annotation.id;
+ const currentIndex = AnnotationManager.findIndex(currentId);
+ const annotations = thymeAttributes.annotations;
+ for (let i = currentIndex + 1; i < annotations.length; i++) {
+ if (this.isValid(annotations[i])) {
+ return annotations[i];
+ }
+ }
+ return null;
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/annotation_manager.js b/app/assets/javascripts/thyme/annotations/annotation_manager.js
new file mode 100644
index 000000000..aed7d7c3e
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation_manager.js
@@ -0,0 +1,170 @@
+/**
+ * This class provides methods that help to manage all annotations in a thyme player.
+ */
+// eslint-disable-next-line no-unused-vars
+class AnnotationManager {
+ /*
+ colorFunc = A function which takes an annotation and gives
+ back a color for the corresponding marker.
+
+ strokeColorFunc = A function which takes an annotation and gives
+ back a color for the stroke of the corresponding marker.
+
+ sizeFunc = A function which takes an annotation and returns
+ a boolean that is true if and only if the marker
+ corresponding to the annotation should be big.
+
+ onClick = A function that takes an annotation as argument and
+ which is triggered when the corresponding marker is
+ clicked.
+
+ onUpdate = A function that is triggered when the annotations
+ have been updated.
+
+ isValid = A function that takes an annotation as argument and
+ returns a boolean. If and only if it returns true,
+ the given annotation is viualized by this annotation
+ manager.
+ */
+ constructor(colorFunc, strokeColorFunc, sizeFunc, onClick, onUpdate, isValid) {
+ this.colorFunc = colorFunc;
+ this.strokeColorFunc = strokeColorFunc;
+ this.sizeFunc = sizeFunc;
+ this.onClick = onClick;
+ this.onUpdate = onUpdate;
+ this.isValid = isValid;
+ this.isDbCalledForFreshAnnotations = false;
+ }
+
+ forceTriggerOnClick(annotation) {
+ this.onClick(annotation);
+ }
+
+ /*
+ Updates the markers on the timeline, i.e. the visual represention of the annotations.
+ This method is e.g. used for rearranging the markers when the window is being resized.
+ Don't mix up with updateAnnotatons() which sends an AJAX request and checks for changes
+ in the database.
+ */
+ updateMarkers() {
+ // In case the annotations have not been loaded yet, do nothing.
+ // This situation might occur during the initial page load.
+ if (thymeAttributes.annotations === null) {
+ if (!this.isDbCalledForFreshAnnotations) {
+ this.updateAnnotations();
+ }
+ return;
+ }
+
+ const annotationManager = this;
+ $("#" + thymeAttributes.markerBarId).empty();
+ AnnotationManager.sortAnnotations();
+
+ for (const a of thymeAttributes.annotations) {
+ if (this.isValid(a)) {
+ function onClick() {
+ annotationManager.onClick(a);
+ }
+ if (this.sizeFunc && this.sizeFunc(a)) {
+ a.createBigMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick);
+ }
+ else {
+ a.createMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick);
+ }
+ }
+ }
+ // call additional function that is individual for each player
+ this.onUpdate();
+ }
+
+ /*
+ Sends a AJAX request which returns all the annotations for the given medium.
+ This method is e.g. used when a new annotation is being created.
+ Don't mix up with updateMarkers() which just updates the position of the markers!
+
+ onSucess = A function that is triggered when the annotations have been
+ successfully updated.
+ onSuccess = A function that is triggered when the annotations have been
+ successfully updated.
+ */
+ updateAnnotations(onSuccess) {
+ if (!thymeAttributes.annotationFeatureActive) {
+ return;
+ }
+
+ this.isDbCalledForFreshAnnotations = true; // Lock resource
+
+ const manager = this;
+ $.ajax(Routes.update_annotations_path(), {
+ type: "GET",
+ dataType: "json",
+ data: {
+ mediumId: thymeAttributes.mediumId,
+ },
+ success: (annotations) => {
+ // update the annotation field in thymeAttributes
+ thymeAttributes.annotations = [];
+ if (!annotations) {
+ return;
+ }
+ for (let a of annotations) {
+ thymeAttributes.annotations.push(new Annotation(a));
+ }
+ // update visual representation on the seek bar
+ manager.updateMarkers();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ },
+ always: () => {
+ // Free resource
+ manager.isDbCalledForFreshAnnotations = false;
+ },
+ });
+ }
+
+ /* sorts all annotations according to their timestamp */
+ static sortAnnotations() {
+ if (!thymeAttributes.annotations) {
+ return;
+ }
+ thymeAttributes.annotations.sort(function (ann1, ann2) {
+ return ann1.seconds - ann2.seconds;
+ });
+ }
+
+ /*
+ Finds the annotation with the given ID in thymeAttributes.annotations.
+ Returns null if it doesn't exist.
+ */
+ static find(id) {
+ const annotations = thymeAttributes.annotations;
+ if (!annotations) {
+ return null;
+ }
+ for (let a of annotations) {
+ if (a.id === id) {
+ return a;
+ }
+ }
+ return null;
+ }
+
+ /*
+ Finds the index in the array thymeAttributes.annotations of an annotation
+ with the given id. Returns undefined if the array doesn't contain this annotation.
+ */
+ static findIndex(id) {
+ const annotations = thymeAttributes.annotations;
+ if (!annotations) {
+ return undefined;
+ }
+ for (let i = 0; i < annotations.length; i++) {
+ if (annotations[i].id === id) {
+ return i;
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/category.js b/app/assets/javascripts/thyme/annotations/category.js
new file mode 100644
index 000000000..56d0aed9a
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/category.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class Category extends CategoryEnum {
+ static _categories = [];
+
+ static NOTE = new Category("note", "#f78f19");
+ static CONTENT = new Category("content", "#A333C8");
+ static PRESENTATION = new Category("presentation", "#2185D0");
+ static MISTAKE = new Category("mistake", "#fc1461");
+
+ constructor(name, color) {
+ super(name);
+ this.color = color;
+ Category._categories.push(this);
+ }
+
+ static getByName(name) {
+ return super.getByName(name, Category._categories);
+ }
+
+ static all() {
+ return super.all(Category._categories);
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/category_enum.js b/app/assets/javascripts/thyme/annotations/category_enum.js
new file mode 100644
index 000000000..f687ee424
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/category_enum.js
@@ -0,0 +1,39 @@
+// eslint-disable-next-line no-unused-vars
+class CategoryEnum {
+ constructor(name) {
+ this.name = name;
+ }
+
+ /*
+ * Returns the correct locale for the given category.
+ * This method will only work if the given thyme player
+ * has a div-tag with the id "annotation-locales" which
+ * includes the name of the categories as data sets, e.g.
+ * data-note="<%= t(...) %>".
+ */
+ locale() {
+ return document.getElementById("annotation-locales").dataset[this.name];
+ }
+
+ /*
+ * Return the object with the given name in the given array.
+ *
+ * Override in subclasses.
+ */
+ static getByName(name, array) {
+ for (let a of array) {
+ if (a.name === name) {
+ return a;
+ }
+ }
+ }
+
+ /*
+ * Returns an array with all objects of this enum.
+ *
+ * Override in subclasses.
+ */
+ static all(array) {
+ return array.slice();
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/subcategory.js b/app/assets/javascripts/thyme/annotations/subcategory.js
new file mode 100644
index 000000000..1ab193887
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/subcategory.js
@@ -0,0 +1,21 @@
+// eslint-disable-next-line no-unused-vars
+class Subcategory extends CategoryEnum {
+ static _subcategories = []; // do not manipulate this array outside of this class!
+
+ static DEFINITION = new Subcategory("definition");
+ static ARGUMENT = new Subcategory("argument");
+ static STRATEGY = new Subcategory("strategy");
+
+ constructor(name) {
+ super(name);
+ Subcategory._subcategories.push(this);
+ }
+
+ static getByName(name) {
+ return super.getByName(name, Subcategory._subcategories);
+ }
+
+ static all() {
+ return super.all(Subcategory._subcategories);
+ }
+}
diff --git a/app/assets/javascripts/thyme/attributes.js b/app/assets/javascripts/thyme/attributes.js
new file mode 100644
index 000000000..5fc1415c9
--- /dev/null
+++ b/app/assets/javascripts/thyme/attributes.js
@@ -0,0 +1,64 @@
+/**
+ This file wraps up some attributes that are used in the different
+ versions of the thyme player.
+
+ Most attributes are set to undefined or null and must be
+ defined when the player is loaded.
+*/
+// eslint-disable-next-line no-unused-vars
+const thymeAttributes = {
+
+ /* Saves a reference on the annotation area. */
+ annotationArea: null,
+
+ /* Use this to check if the annotation feature is activated
+ (which it is not for users who aren't signed in). */
+ annotationFeatureActive: false,
+
+ /* When callig the updateMarkers() method this will be used to save an
+ array containing all annotations. */
+ annotations: null,
+
+ /* Saves a reference on the annotation manager */
+ annotationManager: null,
+
+ /* A list with all the chapters of the current video. */
+ chapters: null,
+
+ /* Saves a reference on the chapter manager. */
+ chapterManager: null,
+
+ /* If the window width (in px) gets below this threshold value, hide the control bar
+ (default value). */
+ hideControlBarThreshold: {
+ x: 850,
+ y: 500,
+ },
+
+ /* Saves a reference on the interactive area. */
+ interactiveArea: null,
+
+ /* A boolean that helps to deactivate all key listeners
+ for the time the annotation modal opens and the user
+ has to write text into the command box. */
+ lockKeyListeners: false,
+
+ disableAnnotationKeyListeners: false,
+
+ /* Saves the ID of the HTML element to which annotations are appended. */
+ markerBarId: undefined,
+
+ /* When loading a player, it should save the medium id in this field for later use
+ in different files. */
+ mediumId: undefined,
+
+ /* Saves a reference on the metadata manager. */
+ metadataManager: null,
+
+ /* Saves a reference on the video's seek bar. */
+ seekBar: undefined,
+
+ /* Saves a reference on the video itself */
+ video: undefined,
+
+};
diff --git a/app/assets/javascripts/thyme/chapter_manager.js b/app/assets/javascripts/thyme/chapter_manager.js
new file mode 100644
index 000000000..72e3f266a
--- /dev/null
+++ b/app/assets/javascripts/thyme/chapter_manager.js
@@ -0,0 +1,121 @@
+/**
+ This file wraps up most functionality of the thyme player(s) concerning chapters.
+*/
+// eslint-disable-next-line no-unused-vars
+class ChapterManager {
+ constructor(chapterListId, iaBackButton) {
+ this.chapterListId = chapterListId;
+ this.iaBackButton = iaBackButton;
+ }
+
+ load() {
+ let initialChapters = true;
+ const videoId = thymeAttributes.video.id;
+ const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0);
+ const chapterManager = this;
+
+ /* after video metadata have been loaded, display chapters in the interactive area
+ Originally (and more appropriately, according to the standards),
+ only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon,
+ i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
+ for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */
+ video.addEventListener("loadedmetadata", function () {
+ if (initialChapters && chaptersElement.readyState === 2) {
+ chapterManager.#displayChapters();
+ initialChapters = false;
+ }
+ });
+ video.addEventListener("canplay", function () {
+ if (initialChapters && chaptersElement.readyState === 2) {
+ chapterManager.#displayChapters();
+ initialChapters = false;
+ }
+ });
+ }
+
+ previousChapterStart() {
+ const currentTime = thymeAttributes.video.currentTime;
+ /* NOTE: We cannot use times as an attribute (yet) because it's initialized
+ before the dataset times is loaded into the HTML. */
+ const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times);
+ if (times.length === 0) {
+ return;
+ }
+ for (let i = times.length - 1; i >= 0; i--) {
+ if (times[i] < currentTime) {
+ if (currentTime - times[i] > 3) {
+ return times[i];
+ }
+ else if (i > 0) {
+ return times[i - 1];
+ }
+ }
+ }
+ }
+
+ nextChapterStart() {
+ const currentTime = thymeAttributes.video.currentTime;
+ const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times);
+ if (times.length === 0) {
+ return;
+ }
+ for (let i = 0; i < times.length; i++) {
+ if (times[i] > currentTime) {
+ return times[i];
+ }
+ }
+ }
+
+ #displayChapters() {
+ const videoId = thymeAttributes.video.id;
+ const chapterListId = this.chapterListId;
+ const iaBackButton = this.iaBackButton;
+ const chapterList = $("#" + chapterListId);
+ const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0);
+
+ let chaptersTrack;
+ if (chaptersElement.readyState === 2 && (chaptersTrack = chaptersElement.track)) {
+ chaptersTrack.mode = "hidden";
+ let times = [];
+ // read out the chapter track cues and generate html elements for chapters,
+ // run katex on them
+ for (let i = 0; i < chaptersTrack.cues.length; i++) {
+ const cue = chaptersTrack.cues[i];
+ const chapterName = cue.text;
+ const start = cue.startTime;
+ times.push(start);
+ const $listItem = $(" ");
+ const $link = $(" ", {
+ id: "c-" + start,
+ text: chapterName,
+ });
+ chapterList.append($listItem.append($link));
+ const chapterElement = $link.get(0);
+ thymeUtility.renderLatex(chapterElement);
+ $link.data("text", chapterName);
+ // if a chapter element is clicked, transport to chapter start time
+ $link.on("click", function () {
+ iaBackButton.update();
+ video.currentTime = this.id.replace("c-", "");
+ });
+ }
+ // store start times as data attribute
+ chapterList.get(0).dataset.times = JSON.stringify(times);
+ chapterList.show();
+ // if the chapters cue changes (i.e. a switch between chapters), highlight
+ // current chapter elment and scroll it into view, remove highlighting from
+ // old chapter
+ $(chaptersTrack).on("cuechange", function () {
+ $("#" + chapterListId + " li a").removeClass("current");
+ if (this.activeCues.length > 0) {
+ const activeStart = this.activeCues[0].startTime;
+ const chapter = document.getElementById("c-" + activeStart);
+ if (chapter) {
+ $(chapter).addClass("current");
+ chapter.scrollIntoView();
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_item_button.js b/app/assets/javascripts/thyme/components/add_item_button.js
new file mode 100644
index 000000000..9a3a0cf79
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_item_button.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class AddItemButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+
+ // Event listener for addItem button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // round time down to three decimal digits
+ const time = video.currentTime;
+ const intTime = Math.floor(time);
+ const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000;
+ video.currentTime = roundTime;
+ $.ajax(Routes.add_item_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ time: video.currentTime,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_reference_button.js b/app/assets/javascripts/thyme/components/add_reference_button.js
new file mode 100644
index 000000000..9d2fe7c32
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_reference_button.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class AddReferenceButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+
+ // Event listener for addItem button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // round time down to three decimal digits
+ const time = video.currentTime;
+ const intTime = Math.floor(time);
+ const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000;
+ video.currentTime = roundTime;
+ $.ajax(Routes.add_reference_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ time: video.currentTime,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_screenshot_button.js b/app/assets/javascripts/thyme/components/add_screenshot_button.js
new file mode 100644
index 000000000..dcf314100
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_screenshot_button.js
@@ -0,0 +1,34 @@
+// eslint-disable-next-line no-unused-vars
+class AddScreenshotButton extends Component {
+ constructor(element, canvasId) {
+ super(element);
+ this.canvas = document.getElementById(canvasId);
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const canvas = this.canvas;
+
+ // Event listener for add screenshot button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // extract video screenshot from canvas
+ const context = canvas.getContext("2d");
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const base64image = canvas.toDataURL("image/png");
+ // Get our file
+ const file = thymeUtility.dataURLtoBlob(base64image);
+ // Create new form data
+ const fd = new FormData();
+ // Append our Canvas image file to the form data
+ fd.append("image", file);
+ // And send it
+ $.ajax(Routes.add_screenshot_path(thymeAttributes.mediumId), {
+ type: "POST",
+ data: fd,
+ processData: false,
+ contentType: false,
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotation_button.js b/app/assets/javascripts/thyme/components/annotation_button.js
new file mode 100644
index 000000000..e81664b43
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotation_button.js
@@ -0,0 +1,20 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the annotation button
+ element.addEventListener("click", function () {
+ video.pause();
+ $.ajax(Routes.new_annotation_path(), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ total_seconds: video.currentTime,
+ medium_id: thymeAttributes.mediumId,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotation_category_toggle.js b/app/assets/javascripts/thyme/components/annotation_category_toggle.js
new file mode 100644
index 000000000..2ee0c7cc9
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotation_category_toggle.js
@@ -0,0 +1,51 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationCategoryToggle extends Component {
+ /*
+ element = A reference on the HTML component (via document.getElementByID()).
+ category = The category which this toggle triggers.
+ heatmap = The heatmap that will be updated depending on the value of the toggle.
+ */
+ constructor(category, heatmap) {
+ const id = AnnotationCategoryToggle.categoryToElementId(category);
+ super(id);
+ this.category = category;
+ this.heatmap = heatmap;
+ }
+
+ static categoryToElementId(category) {
+ return `annotation-category-${category.name}-switch`;
+ }
+
+ add() {
+ const heatmap = this.heatmap;
+ if (heatmap) {
+ heatmap.addCategory(this.category); // add category when adding the button
+ }
+
+ const categoryToggle = this;
+
+ this.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ if (!heatmap) {
+ return;
+ }
+
+ if (categoryToggle.isChecked()) {
+ heatmap.addCategory(this.category);
+ }
+ else {
+ heatmap.removeCategory(this.category);
+ }
+ heatmap.draw();
+ });
+ }
+
+ isChecked() {
+ return this.element.checked;
+ }
+
+ static isChecked(category) {
+ const id = AnnotationCategoryToggle.categoryToElementId(category);
+ return document.getElementById(id).checked;
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotations_toggle.js b/app/assets/javascripts/thyme/components/annotations_toggle.js
new file mode 100644
index 000000000..0e179fdaa
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotations_toggle.js
@@ -0,0 +1,68 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationsToggle extends Component {
+ constructor(element) {
+ super(element);
+ this.id = element;
+ this.check = document.getElementById(this.id + "-check");
+ this.$check = $("#" + this.id + "-check");
+ this.div = $("#" + this.id);
+ this.flag = false;
+ }
+
+ add() {
+ if (this.flag || !thymeAttributes.annotationFeatureActive) {
+ return;
+ }
+
+ this.flag = true; // <- only run the following part of the code once
+ const toggle = this;
+
+ /* User is teacher/editor for the given medium and visible_for_teacher ist activated?
+ -> add toggle annotations button */
+ $.ajax(Routes.check_annotation_visibility_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "json",
+ success: function (isPermitted) {
+ if (!isPermitted) {
+ return;
+ }
+ for (const annotation of thymeAttributes.annotations) {
+ // Only show toggle if there is at least one foreign annotation
+ if (!annotation.belongsToCurrentUser) {
+ toggle.show();
+ toggle.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ });
+ // When loading the player, the toggle is set to "true" by default,
+ // so we have to trigger updateAnnotations() manually once.
+ thymeAttributes.annotationManager.updateAnnotations();
+ }
+ }
+ },
+ });
+ }
+
+ installListener() {
+ this.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ });
+ }
+
+ /*
+ Returns true if the toggle's value is true and false otherwise.
+ */
+ getValue() {
+ return this.$check.is(":checked");
+ }
+
+ /*
+ Auxiliary method
+ */
+ show() {
+ $("#volume-controls").css("left", "66%");
+ $("#speed-control").css("left", "77%");
+ $("#annotation-button").css("left", "86%");
+ thymeAttributes.hideControlBarThreshold.x = 960;
+ this.div.show();
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/component.js b/app/assets/javascripts/thyme/components/component.js
new file mode 100644
index 000000000..a0677949c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/component.js
@@ -0,0 +1,17 @@
+/**
+ The basic component class from which every thyme related components (slider, selector, etc.)
+ should be a subclass.
+*/
+// eslint-disable-next-line no-unused-vars
+class Component {
+ /*
+ element = The id of the HTML element associated to this button.
+ */
+ constructor(element) {
+ this.element = document.getElementById(element);
+ }
+
+ /* This method should add the button functionality to the given player.
+ Override it in the given subclass! */
+ add() { }
+}
diff --git a/app/assets/javascripts/thyme/components/full_screen_button.js b/app/assets/javascripts/thyme/components/full_screen_button.js
new file mode 100644
index 000000000..d8991456d
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/full_screen_button.js
@@ -0,0 +1,69 @@
+// eslint-disable-next-line no-unused-vars
+class FullScreenButton extends Component {
+ constructor(element, container) {
+ super(element);
+ this.container = container;
+ }
+
+ add() {
+ const element = this.element;
+ const container = this.container;
+ const button = this;
+
+ // Event listener for the full-screen button
+ // (unfortunately, lots of browser specific code).
+ element.addEventListener("click", function () {
+ if (element.dataset.status === "true") {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ }
+ else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ }
+ else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ }
+ }
+ else {
+ if (container.requestFullscreen) {
+ container.requestFullscreen();
+ }
+ else if (container.mozRequestFullScreen) {
+ container.mozRequestFullScreen();
+ }
+ else if (container.webkitRequestFullscreen) {
+ container.webkitRequestFullscreen();
+ }
+ }
+ });
+
+ document.onfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+
+ document.onwebkitfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+
+ document.onmozfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+ }
+
+ #fullscreenChange() {
+ if (document.fullscreenElement) {
+ // User enters fullscreen mode
+ this.element.innerHTML = "fullscreen_exit";
+ this.element.dataset.status = "true";
+ /* Set height to 100vh in fullscreen mode as it otherwise
+ is too large. */
+ $(thymeAttributes.video).css("height", "100vh");
+ }
+ else {
+ // User exists fullscreen mode
+ this.element.innerHTML = "fullscreen";
+ this.element.dataset.status = "false";
+ $(thymeAttributes.video).css("height", "100%");
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_back_button.js b/app/assets/javascripts/thyme/components/ia_back_button.js
new file mode 100644
index 000000000..44ef372b8
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_back_button.js
@@ -0,0 +1,39 @@
+/**
+ * The Interactive Area Back Button saves a reference on the
+ * current time. If one clicks on a chapter field in the
+ * interactive area (which sets the current time to the start
+ * of the chapter), one has the possibility to go back by
+ * clicking this button.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaBackButton extends Component {
+ constructor(element, chapterListId) {
+ super(element);
+ this.chapterListId = chapterListId;
+ }
+
+ add() {
+ // Event Handler for Back Button
+ this.element.addEventListener("click", function () {
+ video.currentTime = this.dataset.time;
+ $(this).hide();
+ });
+ }
+
+ update() {
+ // set up back button (transports back to the current chapter)
+ this.element.dataset.time = video.currentTime;
+ const currentChapter = $("#" + this.chapterListId + " .current");
+ if (currentChapter.length > 0) {
+ let backInfo = currentChapter.data("text").split(":", 1)[0];
+ if (backInfo && backInfo.length > 20) {
+ backInfo = this.element.dataset.back;
+ }
+ else {
+ backInfo = this.element.dataset.backto + backInfo;
+ }
+ $(this.element).empty().append(backInfo).show();
+ thymeUtility.renderLatex(this.element);
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_button.js b/app/assets/javascripts/thyme/components/ia_button.js
new file mode 100644
index 000000000..a848e8d4c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_button.js
@@ -0,0 +1,76 @@
+/**
+ * The Interactive Area Button can show/hide specific elements of the
+ * thyme player (normally interactive and annotation area) and
+ * adjust the video position/size accordingly.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaButton extends Component {
+ /*
+ toHide = An array consisting of all the components that
+ should be hidden/shown when this button is clicked.
+ These components must provide a show() and hide()
+ method, but they havn't to be a JQuery reference
+ on a HTML element.
+
+ toShrink = An array consisting of JQuery references of all
+ the components that should grow/shrink when this
+ button is clicked.
+
+ shrink = The percentage telling how much the elements of toShrink
+ should shrink when the components of toHide are shown.
+ */
+ constructor(element, toHide, toShrink, shrink) {
+ super(element);
+ this.toHide = toHide;
+ this.toShrink = toShrink;
+ this.shrink = shrink;
+ }
+
+ add() {
+ const element = this.element;
+ const button = this;
+
+ element.addEventListener("click", function () {
+ if (element.dataset.status === "true") {
+ button.plus();
+ }
+ else {
+ button.minus();
+ }
+ });
+ }
+
+ /*
+ Sets the button to its plus value, i.e. shows all
+ toHide elements and shrinks all toShrink elements.
+ */
+ plus() {
+ this.#aux("false", "remove_from_queue", false, "100%");
+ thymeAttributes.annotationArea.unmarkCurrentAnnotationAsShown();
+ }
+
+ /*
+ Sets the button to its minus value, i.e. hides all
+ toHide elements and enlarges all toShrink elements.
+ */
+ minus() {
+ this.#aux("true", "add_to_queue", true, this.shrink);
+ }
+
+ getStatus() {
+ return this.element.dataset.status === "true";
+ }
+
+ #aux(status, innerHTML, sh, size) {
+ this.element.dataset.status = status;
+ this.element.innerHTML = innerHTML;
+ for (let e of this.toHide) {
+ sh ? e.show() : e.hide();
+ }
+ for (let e of this.toShrink) {
+ e.css("width", size);
+ }
+ $(window).trigger("resize");
+ thymeAttributes.annotationManager.updateMarkers();
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_close_button.js b/app/assets/javascripts/thyme/components/ia_close_button.js
new file mode 100644
index 000000000..b2ae8e3cb
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_close_button.js
@@ -0,0 +1,18 @@
+/**
+ * The Interactive Area button gives a shortcut
+ * for the minus event of an IaButton.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaCloseButton extends Component {
+ constructor(element, iaButton) {
+ super(element);
+ this.iaButton = iaButton;
+ }
+
+ add() {
+ const iaButton = this.iaButton;
+ this.element.addEventListener("click", function () {
+ iaButton.plus();
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/mute_button.js b/app/assets/javascripts/thyme/components/mute_button.js
new file mode 100644
index 000000000..5a662b21a
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/mute_button.js
@@ -0,0 +1,18 @@
+// eslint-disable-next-line no-unused-vars
+class MuteButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.muted) {
+ video.muted = true;
+ element.innerHTML = "volume_off";
+ }
+ else {
+ video.muted = false;
+ element.innerHTML = "volume_up";
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/next_chapter_button.js b/app/assets/javascripts/thyme/components/next_chapter_button.js
new file mode 100644
index 000000000..011ae8127
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/next_chapter_button.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line no-unused-vars
+class NextChapterButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the nextChapter button
+ element.addEventListener("click", function () {
+ const next = thymeAttributes.chapterManager.nextChapterStart();
+ if (next) {
+ video.currentTime = thymeAttributes.chapterManager.nextChapterStart();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/play_button.js b/app/assets/javascripts/thyme/components/play_button.js
new file mode 100644
index 000000000..8e8f1c6d7
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/play_button.js
@@ -0,0 +1,24 @@
+// eslint-disable-next-line no-unused-vars
+class PlayButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ });
+
+ video.onplay = function () {
+ element.innerHTML = "pause";
+ };
+
+ video.onpause = function () {
+ element.innerHTML = "play_arrow";
+ };
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/previous_chapter_button.js b/app/assets/javascripts/thyme/components/previous_chapter_button.js
new file mode 100644
index 000000000..3a2d277a0
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/previous_chapter_button.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line no-unused-vars
+class PreviousChapterButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the previousChapter button
+ element.addEventListener("click", function () {
+ const previous = thymeAttributes.chapterManager.previousChapterStart();
+ if (previous) {
+ video.currentTime = thymeAttributes.chapterManager.previousChapterStart();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/seek_bar.js b/app/assets/javascripts/thyme/components/seek_bar.js
new file mode 100644
index 000000000..805d632b1
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/seek_bar.js
@@ -0,0 +1,70 @@
+// eslint-disable-next-line no-unused-vars
+class SeekBar extends Component {
+ constructor(element) {
+ super(element);
+ thymeAttributes.seekBar = this; // save a reference for this seek bar
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event listeners for the seek bar
+ element.addEventListener("input", function () {
+ const time = video.duration * element.value / 100;
+ video.currentTime = time;
+ });
+
+ // if videomedtadata have been loaded, set up seek bar
+ video.addEventListener("loadedmetadata", function () {
+ if (video.dataset.time) {
+ element.value = video.currentTime / video.duration * 100;
+ }
+ else {
+ element.value = 0;
+ }
+ });
+
+ // Update the seek bar as the video plays.
+ // Uses a gradient for seekbar video time visualization.
+ video.addEventListener("timeupdate", function () {
+ const value = 100 / video.duration * video.currentTime;
+ element.value = value;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + value
+ + "%, #ffffff "
+ + value
+ + "%, #ffffff)";
+ const currentTime = document.getElementById("current-time");
+ currentTime.innerHTML = thymeUtility.secondsToTime(video.currentTime);
+ });
+
+ // Pause the video when the seek handle is being dragged
+ element.addEventListener("mousedown", function () {
+ video.dataset.paused = video.paused;
+ video.pause();
+ });
+
+ // Play the video when the seek handle is dropped
+ element.addEventListener("mouseup", function () {
+ if (video.dataset.paused !== "true") {
+ video.play();
+ }
+ });
+ }
+
+ /*
+ If mouse is moved over seek bar, display tooltip with current chapter
+ (only use this if the given thyme player provides chapters!).
+ */
+ addChapterTooltips() {
+ const element = this.element;
+
+ element.addEventListener("mousemove", function (_evt) {
+ const previous = thymeAttributes.chapterManager.previousChapterStart();
+ const info = $("#c-" + $.escapeSelector(previous)).text().split(":")[0];
+ element.setAttribute("title", info);
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/speed_selector.js b/app/assets/javascripts/thyme/components/speed_selector.js
new file mode 100644
index 000000000..f427b7d0c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/speed_selector.js
@@ -0,0 +1,26 @@
+// eslint-disable-next-line no-unused-vars
+class SpeedSelector extends Component {
+ constructor(element) {
+ super(element);
+ }
+
+ /* This method should add the button functionality to the given player.
+ Override it in the given subclass! */
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.preservesPitch) {
+ video.preservesPitch = true;
+ }
+ else if (video.mozPreservesPitch) {
+ video.mozPreservesPitch = true;
+ }
+ else if (video.webkitPreservesPitch) {
+ video.webkitPreservesPitch = true;
+ }
+ video.playbackRate = this.options[this.selectedIndex].value;
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/time_button.js b/app/assets/javascripts/thyme/components/time_button.js
new file mode 100644
index 000000000..c5c26af7a
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/time_button.js
@@ -0,0 +1,24 @@
+// eslint-disable-next-line no-unused-vars
+class TimeButton extends Component {
+ /*
+ time = The time to add in seconds.
+ */
+ constructor(element, time) {
+ super(element);
+ this.time = time;
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const time = this.time;
+
+ this.element.addEventListener("click", function () {
+ if (time >= 0) {
+ video.currentTime = Math.min(video.currentTime + time, video.duration);
+ }
+ else {
+ video.currentTime = Math.max(video.currentTime + time, 0);
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/volume_bar.js b/app/assets/javascripts/thyme/components/volume_bar.js
new file mode 100644
index 000000000..b1db66fc2
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/volume_bar.js
@@ -0,0 +1,33 @@
+// eslint-disable-next-line no-unused-vars
+class VolumeBar extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event listener for the volume bar
+ element.addEventListener("input", function () {
+ video.volume = element.value;
+ });
+
+ video.addEventListener("loadedmetadata", function () {
+ element.value = video.volume;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + video.volume * 100
+ + "%, #ffffff "
+ + video.volume * 100
+ + "%, #ffffff)";
+ });
+
+ video.addEventListener("volumechange", function () {
+ const value = video.volume;
+ element.value = value;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + value * 100
+ + "%, #ffffff "
+ + value * 100
+ + "%, #ffffff)";
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/control_bar_hider.js b/app/assets/javascripts/thyme/control_bar_hider.js
new file mode 100644
index 000000000..dc98346f3
--- /dev/null
+++ b/app/assets/javascripts/thyme/control_bar_hider.js
@@ -0,0 +1,74 @@
+/**
+ * This class contains the functionality for (auto-)hiding the control bar.
+ */
+// eslint-disable-next-line no-unused-vars
+class ControlBarHider {
+ /*
+ controlBarId = The ID of the control bar.
+ delay = The delay after which the control bar is automatically hidden.
+ */
+ constructor(controlBarId, delay) {
+ this.controlBarId = controlBarId;
+ this.delay = delay;
+ this.hideBlocker = false; // helper attribute
+ }
+
+ /*
+ Installs the control bar hider, i.e. after calling this method it will
+ start working.
+ */
+ install() {
+ const controlBarHider = this;
+ const controlBar = document.getElementById(this.controlBarId);
+ const video = thymeAttributes.video;
+
+ // show control bar when mouse is moved, etc.
+ function show() {
+ controlBarHider.showControlBar(); // <- need this extra function for reference
+ }
+ /* NOTE: Why do we need the mouseover listener? To trigger it, the mouse
+ has to be moved, i.e. the second event listener is triggered. */
+ video.addEventListener("mouseover", show);
+ video.addEventListener("mousemove", show);
+ video.addEventListener("touchstart", show);
+ video.addEventListener("click", show);
+
+ // block hiding if curser is over the control bar
+ controlBar.addEventListener("mouseover", function () {
+ controlBarHider.hideBlocker = true;
+ });
+ controlBar.addEventListener("mouseleave", function () {
+ controlBarHider.hideBlocker = false;
+ });
+
+ // auto hide control bar
+ let t = void 0;
+ function resetTimer() {
+ clearTimeout(t);
+ t = setTimeout(function () {
+ if (controlBarHider.hideBlocker) {
+ return;
+ }
+ controlBarHider.hideControlBar();
+ }, controlBarHider.delay);
+ }
+ window.onload = resetTimer;
+ window.onmousemove = resetTimer;
+ window.onmousedown = resetTimer;
+ window.ontouchstart = resetTimer;
+ window.onclick = resetTimer;
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+ showControlBar() {
+ $("#" + this.controlBarId).css("visibility", "visible");
+ $(thymeAttributes.video).css("cursor", "");
+ }
+
+ hideControlBar() {
+ $("#" + this.controlBarId).css("visibility", "hidden");
+ $(thymeAttributes.video).css("cursor", "none");
+ }
+}
diff --git a/app/assets/javascripts/thyme/display_manager.js b/app/assets/javascripts/thyme/display_manager.js
new file mode 100644
index 000000000..d28eca7de
--- /dev/null
+++ b/app/assets/javascripts/thyme/display_manager.js
@@ -0,0 +1,60 @@
+/**
+ * A DisplayManager helps to switch between the full thyme player and
+ * the native HTML player shown on small devices.
+ */
+// eslint-disable-next-line no-unused-vars
+class DisplayManager {
+ constructor(elements, onEnlarge) {
+ /*
+ elements = An array containing JQuery references on the HTML elements
+ that should be hidden, when the display is too small.
+
+ onEnlarge = A reference to a function that is called when the display
+ changes from small to large. Use this for player specific behavior.
+ */
+ this.elements = elements;
+ this.onEnlarge = onEnlarge;
+ }
+
+ // on small display, fall back to standard browser player
+ adaptToSmallDisplay() {
+ for (let e of this.elements) {
+ e.hide();
+ }
+ thymeAttributes.video.style.width = "100%";
+ thymeAttributes.video.controls = true;
+ }
+
+ // on large display, use anything thyme has to offer, disable native player
+ adaptToLargeDisplay() {
+ thymeAttributes.video.controls = false;
+ for (let e of this.elements) {
+ e.show();
+ }
+ this.onEnlarge();
+ }
+
+ // Check screen size and trigger the right method
+ updateControlBarType() {
+ const manager = this;
+
+ const matchSmallMediaQuery = window.matchMedia(`
+ screen and (
+ (max-width: ${thymeAttributes.hideControlBarThreshold.x}px)
+ or (max-height: ${thymeAttributes.hideControlBarThreshold.y}px)
+ )
+ `);
+
+ function handleSizeChange(event) {
+ if (event.matches) {
+ manager.adaptToSmallDisplay();
+ }
+ else {
+ manager.adaptToLargeDisplay();
+ }
+ }
+
+ matchSmallMediaQuery.addListener(handleSizeChange);
+ handleSizeChange(matchSmallMediaQuery); // initial call
+ }
+}
diff --git a/app/assets/javascripts/thyme/heatmap.js b/app/assets/javascripts/thyme/heatmap.js
new file mode 100644
index 000000000..463669ffb
--- /dev/null
+++ b/app/assets/javascripts/thyme/heatmap.js
@@ -0,0 +1,122 @@
+/**
+ * Objects of this class represent heatmaps. It provides the function draw() which
+ * draws the heatmap to the thyme player.
+ */
+// eslint-disable-next-line no-unused-vars
+class Heatmap {
+ static RADIUS = 10; // this number adjusts the radius of the peaks of the heatmap
+ static MAX_HEIGHT = 0.25; // this number adjusts the maximum heights of the heatmap peaks
+
+ /*
+ * id = The ID of the HTML element to which the heatmap will be appended.
+ */
+ constructor(id) {
+ this.heatmap = $("#" + id);
+ this.categories = [];
+ }
+
+ draw() {
+ if (!thymeAttributes.annotations) {
+ return;
+ }
+ this.heatmap.empty();
+
+ /*
+ variable definitions
+ */
+ // We assume a slightly bigger width to be able to display sine waves
+ // also at the beginning and the end of the timeline.
+ const stickOutWidthOneSided = Heatmap.RADIUS;
+ const thresh = 20; // a small additional width to avoid the heatmap to be cut off
+ const seekBarWidth = thymeAttributes.seekBar.element.clientWidth;
+ const width = seekBarWidth + 2 * stickOutWidthOneSided + thresh;
+ const stretch = seekBarWidth / (seekBarWidth + 4 * stickOutWidthOneSided + thresh);
+
+ const maxHeight = video.clientHeight * Heatmap.MAX_HEIGHT;
+ this.heatmap.css("top", -maxHeight - 11); // vertical offset
+
+ const numDivisons = width + 4 * Heatmap.RADIUS + 1;
+ /* An array for each pixel on the timeline. The indices of this array should be thought
+ of the x-axis of the heatmap's graph, while its entries should be thought of its
+ values on the y-axis. */
+ let pixels = new Array(numDivisons).fill(0);
+ /* amplitude should be calculated with respect to all annotations
+ (even those which are not shown). Otherwise the peaks increase
+ when turning off certain annotations because the graph has to be
+ normed. Therefore we need this additional "pixelsAll" array. */
+ let pixelsAll = new Array(numDivisons).fill(0);
+ /* for any visible annotation, this array contains its color (needed for the calculation
+ of the heatmap color) */
+ let colors = [];
+
+ /*
+ data calculation
+ */
+ for (const a of thymeAttributes.annotations) {
+ const valid = this.#isValidCategory(a.category)
+ && AnnotationCategoryToggle.isChecked(a.category);
+
+ if (valid) {
+ colors.push(a.category.color);
+ }
+ const time = a.seconds;
+ const position = Math.round(stretch * width * (time / video.duration));
+ for (let x = position - Heatmap.RADIUS; x <= position + Heatmap.RADIUS; x++) {
+ let y = Heatmap.#sinX(x, position, Heatmap.RADIUS);
+ pixelsAll[x + Heatmap.RADIUS] += y;
+ if (valid) {
+ pixels[x + Heatmap.RADIUS] += y;
+ }
+ }
+ }
+ const maxValue = Math.max(...pixelsAll);
+ const amplitude = maxValue != 0 ? maxHeight * (1 / maxValue) : 0;
+
+ /*
+ Construct heatmap SVG
+ */
+ let pointsStr = `0,${maxHeight} `;
+ for (let x = 0; x < pixels.length; x++) {
+ pointsStr += `${x},${maxHeight - amplitude * pixels[x]} `;
+ }
+ pointsStr += `${width},${maxHeight}`;
+
+ const heatmapStr = `
+
+ `;
+ this.heatmap.append(heatmapStr);
+ }
+
+ addCategory(category) {
+ if (this.categories.includes(category)) {
+ return;
+ }
+ this.categories.push(category);
+ }
+
+ removeCategory(category) {
+ this.categories = this.categories.filter(c => c !== category);
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+
+ #isValidCategory(category) {
+ return this.categories.includes(category);
+ }
+
+ /* A modified sine function for building nice peaks around the marker positions.
+
+ x = insert value
+ position = the position of the maximum value
+ */
+ static #sinX(x, position) {
+ return (1 + Math.sin(Math.PI / Heatmap.RADIUS * (x - position) + Math.PI / 2)) / 2;
+ }
+}
diff --git a/app/assets/javascripts/thyme/key_shortcuts.js b/app/assets/javascripts/thyme/key_shortcuts.js
new file mode 100644
index 000000000..c30b3fb45
--- /dev/null
+++ b/app/assets/javascripts/thyme/key_shortcuts.js
@@ -0,0 +1,135 @@
+/**
+ All key shortcuts should be bundled here.
+*/
+// eslint-disable-next-line no-unused-vars
+const thymeKeyShortcuts = {
+ /*
+ SHORTCUT LIST:
+ Arrow right - plus ten seconds
+ Arrow left - minus ten seconds
+ f - fullscreen
+ Page up - volume up
+ Page down - volume down
+ m - mute
+ */
+ addGeneralShortcuts: function () {
+ const video = document.getElementById("video");
+
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === " ") {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ }
+ else if (key === "ArrowRight") {
+ $("#plus-ten").trigger("click");
+ }
+ else if (key === "ArrowLeft") {
+ $("#minus-ten").trigger("click");
+ }
+ else if (key === "f") {
+ $("#full-screen").trigger("click");
+ }
+ else if (key === "m") {
+ $("#mute").trigger("click");
+ }
+ else if (key === "PageUp") {
+ video.volume = Math.min(video.volume + 0.1, 1);
+ }
+ else if (key === "PageDown") {
+ video.volume = Math.max(video.volume - 0.1, 0);
+ }
+ });
+ },
+
+ /*
+ Thyme player specific
+
+ SHORTCUT LIST:
+ Arrow Up - next chapter
+ Arrow Down - previous chapter
+ i - toggle interactive area
+ */
+ addPlayerShortcuts() {
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === "i") {
+ $("#ia-active").trigger("click");
+ }
+ else if (key === "ArrowUp") {
+ $("#next-chapter").trigger("click");
+ }
+ else if (key === "ArrowDown") {
+ $("#previous-chapter").trigger("click");
+ }
+
+ // annotation-related shortcuts
+ if (thymeAttributes.disableAnnotationKeyListeners) {
+ return;
+ }
+ else if (key === "a") {
+ $("#annotation-previous-button").trigger("click");
+ }
+ else if (key === "s") {
+ $("#annotation-goto-button").trigger("click");
+ }
+ else if (key === "d") {
+ $("#annotation-next-button").trigger("click");
+ }
+ });
+ },
+
+ /*
+ Thyme feedback specific
+
+ SHORTCUT LIST:
+ q - toggle mistake annotations
+ w - toggle presentation annotations
+ e - toggle content annotations
+ r - toggle note annotations
+ */
+ addFeedbackShortcuts() {
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners || thymeAttributes.disableAnnotationKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === "q") {
+ $("#annotation-category-mistake-switch").trigger("click");
+ }
+ else if (key === "w") {
+ $("#annotation-category-content-switch").trigger("click");
+ }
+ else if (key === "e") {
+ $("#annotation-category-presentation-switch").trigger("click");
+ }
+ else if (key === "r") {
+ $("#annotation-category-note-switch").trigger("click");
+ }
+ else if (key === "a") {
+ $("#annotation-previous-button").trigger("click");
+ }
+ else if (key === "s") {
+ $("#annotation-goto-button").trigger("click");
+ }
+ else if (key === "d") {
+ $("#annotation-next-button").trigger("click");
+ }
+ });
+ },
+
+ /*
+ Thyme editor specific
+ */
+ // Add editor specific keyboard shortcuts here.
+};
diff --git a/app/assets/javascripts/thyme/metadata_manager.js b/app/assets/javascripts/thyme/metadata_manager.js
new file mode 100644
index 000000000..760e58bb4
--- /dev/null
+++ b/app/assets/javascripts/thyme/metadata_manager.js
@@ -0,0 +1,219 @@
+/**
+ This file wraps up most functionality of the thyme player(s) concerning metadata.
+*/
+// eslint-disable-next-line no-unused-vars
+class MetadataManager {
+ constructor(metadataListId) {
+ this.metadataListId = metadataListId;
+ }
+
+ load() {
+ let initialMetadata = true;
+ const videoId = thymeAttributes.video.id;
+ const metadataElement = $("#" + videoId + ' track[kind="metadata"]').get(0);
+ const metadataManager = this;
+
+ /* after video metadata have been loaded, display chapters in the interactive area
+ Originally (and more appropriately, according to the standards),
+ only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon,
+ i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
+ for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */
+ video.addEventListener("loadedmetadata", function () {
+ if (initialMetadata && metadataElement.readyState === 2) {
+ metadataManager.#displayMetadata();
+ initialMetadata = false;
+ }
+ });
+ video.addEventListener("canplay", function () {
+ if (initialMetadata && metadataElement.readyState === 2) {
+ metadataManager.#displayMetadata();
+ initialMetadata = false;
+ }
+ });
+ }
+
+ /* returns the jQuery object of all metadata elements that start before the
+ given time in seconds */
+ #metadataBefore(seconds) {
+ return $('[id^="m-"]').not(this.#metadataAfter(seconds));
+ }
+
+ /* returns the jQuery object of all metadata elements that start after the
+ given time in seconds */
+ #metadataAfter(seconds) {
+ const metaList = document.getElementById(this.metadataListId);
+ const times = JSON.parse(metaList.dataset.times);
+ if (times.length === 0) {
+ return $();
+ }
+ for (let i = 0; i < times.length; i++) {
+ if (times[i] > seconds) {
+ const $nextMeta = $("#m-" + $.escapeSelector(times[i]));
+ return $nextMeta.add($nextMeta.nextAll());
+ }
+ }
+ return $();
+ }
+
+ /* for a given time, show all metadata elements that start before this time
+ and hide all that start later */
+ metaIntoView(time) {
+ this.#metadataAfter(time).hide();
+ const $before = this.#metadataBefore(time);
+ $before.show();
+ const previousLength = $before.length;
+ if (previousLength > 0) {
+ $before.get(previousLength - 1).scrollIntoView();
+ }
+ }
+
+ // set up the metadata elements
+ #displayMetadata() {
+ const video = thymeAttributes.video;
+ const metadataManager = this;
+ const metadataListId = this.metadataListId;
+ const $metaList = $("#" + metadataListId);
+ const metadataElement = $("#" + video.id + ' track[kind="metadata"]').get(0);
+
+ let metaTrack;
+ if (metadataElement.readyState === 2 && (metaTrack = metadataElement.track)) {
+ metaTrack.mode = "hidden";
+ let times = [];
+ // read out the metadata track cues and generate html elements for
+ // metadata, run katex on them
+ for (let i = 0; i < metaTrack.cues.length; i++) {
+ const cue = metaTrack.cues[i];
+ const meta = JSON.parse(cue.text);
+ const start = cue.startTime;
+ times.push(start);
+ const $listItem = $(" ", {
+ id: "m-" + start,
+ });
+ $listItem.hide();
+ const $link = $(" ", {
+ text: meta.reference,
+ class: "item",
+ id: "l-" + start,
+ });
+ const $videoIcon = $(" ", {
+ text: "video_library",
+ class: "material-icons",
+ });
+ const $videoRef = $(" ", {
+ href: meta.video,
+ target: "_blank",
+ });
+ $videoRef.append($videoIcon);
+ if (!meta.video) {
+ $videoRef.hide();
+ }
+ const $manIcon = $(" ", {
+ text: "library_books",
+ class: "material-icons",
+ });
+ const $manRef = $(" ", {
+ href: meta.manuscript,
+ target: "_blank",
+ });
+ $manRef.append($manIcon);
+ if (!meta.manuscript) {
+ $manRef.hide();
+ }
+ const $scriptIcon = $(" ", {
+ text: "menu_book",
+ class: "material-icons",
+ });
+ const $scriptRef = $(" ", {
+ href: meta.script,
+ target: "_blank",
+ });
+ $scriptRef.append($scriptIcon);
+ if (!meta.script) {
+ $scriptRef.hide();
+ }
+ const $quizIcon = $(" ", {
+ text: "videogame_asset",
+ class: "material-icons",
+ });
+ const $quizRef = $(" ", {
+ href: meta.quiz,
+ target: "_blank",
+ });
+ $quizRef.append($quizIcon);
+ if (!meta.quiz) {
+ $quizRef.hide();
+ }
+ const $extIcon = $(" ", {
+ text: "link",
+ class: "material-icons",
+ });
+ const $extRef = $(" ", {
+ href: meta.link,
+ target: "_blank",
+ });
+ $extRef.append($extIcon);
+ if (!meta.link) {
+ $extRef.hide();
+ }
+ const $description = $("
", {
+ text: meta.text,
+ class: "mx-3",
+ });
+ const $explanation = $("
", {
+ text: meta.explanation,
+ class: "m-3",
+ });
+ const $details = $("
");
+ $details.append($link).append($description).append($explanation);
+ let $icons = $("
", {
+ style: "flex-shrink: 3; display: flex; flex-direction: column;",
+ });
+ $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef);
+ $listItem.append($details).append($icons);
+ $metaList.append($listItem);
+ $videoRef.on("click", function () {
+ video.pause();
+ });
+ $manRef.on("click", function () {
+ video.pause();
+ });
+ $extRef.on("click", function () {
+ video.pause();
+ });
+ $link.on("click", function () {
+ // displayBackButton();
+ video.currentTime = this.id.replace("l-", "");
+ });
+ let metaElement = $listItem.get(0);
+ thymeUtility.renderLatex(metaElement);
+ }
+ // store metadata start times as data attribute
+ $metaList.get(0).dataset.times = JSON.stringify(times);
+ // if user jumps to a new position in the video, display all metadata
+ // that start before this time and hide all that start later
+ $(video).on("seeked", function () {
+ const time = video.currentTime;
+ metadataManager.metaIntoView(time);
+ });
+ // if the metadata cue changes, highlight all current media and scroll
+ // them into view
+ $(metaTrack).on("cuechange", function () {
+ let j = 0;
+ $("#" + metadataListId + " li").removeClass("current");
+ while (j < this.activeCues.length) {
+ const activeStart = this.activeCues[j].startTime;
+ let metalink = document.getElementById("m-" + activeStart);
+ if (metalink) {
+ $(metalink).show();
+ $(metalink).addClass("current");
+ }
+ ++j;
+ }
+ const currentLength = $("#" + metadataListId + " .current").length;
+ if (currentLength > 0) {
+ $("#" + metadataListId + " .current").get(length - 1).scrollIntoView();
+ }
+ });
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/resizer.js b/app/assets/javascripts/thyme/resizer.js
new file mode 100644
index 000000000..7c51e3291
--- /dev/null
+++ b/app/assets/javascripts/thyme/resizer.js
@@ -0,0 +1,32 @@
+/**
+ Use the method here to resize thyme players.
+*/
+// eslint-disable-next-line no-unused-vars
+const Resizer = {
+ resizeContainer: function (container, factor, offset) {
+ // see https://stackoverflow.com/a/73425736/
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+
+ const video = document.getElementById("video");
+ const $container = $(container);
+
+ let height = windowHeight;
+ const vWidth = video.videoWidth;
+ const vHeight = video.videoHeight;
+ let width = Math.floor((vWidth * windowHeight / vHeight) * factor) - offset;
+ if (width > windowWidth) {
+ const shrink = windowWidth / width;
+ height = Math.floor(height * shrink);
+ width = windowWidth;
+ }
+
+ const top = Math.floor(0.5 * (windowHeight - height));
+ const left = Math.floor(0.5 * (windowWidth - width));
+
+ $container.css("height", height + "px");
+ $container.css("width", width + "px");
+ $container.css("top", top + "px");
+ $container.css("left", left + "px");
+ },
+};
diff --git a/app/assets/javascripts/thyme/thyme_editor.js b/app/assets/javascripts/thyme/thyme_editor.js
new file mode 100644
index 000000000..a86f1c371
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_editor.js
@@ -0,0 +1,36 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeEdit = document.getElementById("thyme-edit");
+ if (!thymeEdit) {
+ return;
+ }
+ // initialize attributes
+ const video = document.getElementById("video-edit");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = thymeEdit.dataset.medium;
+
+ /*
+ COMPONENTS
+ */
+ (new PlayButton("play-pause")).add();
+ (new MuteButton("mute")).add();
+
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("plus-five", 5)).add();
+ (new TimeButton("plus-one", 1)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ (new TimeButton("minus-five", -5)).add();
+ (new TimeButton("minus-one", -1)).add();
+
+ (new SeekBar("seek-bar")).add();
+ (new VolumeBar("volume-bar")).add();
+
+ (new AddItemButton("add-item")).add();
+ (new AddReferenceButton("add-reference")).add();
+ (new AddScreenshotButton("add-screenshot", "snapshot")).add();
+
+ thymeUtility.setUpMaxTime("max-time");
+});
diff --git a/app/assets/javascripts/thyme/thyme_feedback.js b/app/assets/javascripts/thyme/thyme_feedback.js
new file mode 100644
index 000000000..1749b3be0
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_feedback.js
@@ -0,0 +1,109 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeContainer = document.getElementById("thyme-feedback-container");
+ if (!thymeContainer) {
+ return;
+ }
+
+ // background color
+ document.body.style.backgroundColor = "black";
+
+ // initialize attributes
+ const video = document.getElementById("video");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = document.getElementById("thyme-feedback").dataset.medium;
+ thymeAttributes.markerBarId = "feedback-markers";
+
+ /*
+ COMPONENTS
+ */
+ // Buttons
+ (new MuteButton("mute")).add();
+ (new PlayButton("play-pause")).add();
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ // Sliders
+ (new VolumeBar("volume-bar")).add();
+ seekBar = new SeekBar("seek-bar");
+ seekBar.add();
+
+ // heatmap
+ const heatmap = new Heatmap("heatmap");
+
+ // category toggles
+ const allCategories = Category.all();
+ const annotationCategoryToggles = new Array(allCategories.length);
+ let category;
+
+ for (let i = 0; i < allCategories.length; i++) {
+ category = allCategories[i];
+ annotationCategoryToggles[i] = new AnnotationCategoryToggle(category, heatmap);
+ annotationCategoryToggles[i].add();
+ }
+
+ /*
+ ANNOTATION FUNCTIONALITY
+ */
+ function colorFunc(annotation) {
+ return annotation.category.color;
+ }
+
+ function isValid(annotation) {
+ for (let toggle of annotationCategoryToggles) {
+ if (annotation.category === toggle.category && toggle.isChecked()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ const annotationArea = new AnnotationArea(false, colorFunc, null, isValid);
+ thymeAttributes.annotationArea = annotationArea;
+
+ function strokeColorFunc(annotation) {
+ return annotation.category === Category.MISTAKE ? "darkred" : "black";
+ }
+
+ function sizeFunc(annotation) {
+ return annotation.category === Category.MISTAKE;
+ }
+
+ function onClick(annotation) {
+ annotationArea.update(annotation);
+ }
+
+ function onUpdate() {
+ heatmap.draw();
+ }
+
+ const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc,
+ onClick, onUpdate, isValid);
+ thymeAttributes.annotationManager = annotationManager;
+ thymeAttributes.annotationFeatureActive = true;
+
+ /*
+ KEYBOARD SHORTCUTS
+ */
+ thymeKeyShortcuts.addGeneralShortcuts();
+ thymeKeyShortcuts.addFeedbackShortcuts();
+
+ /*
+ MISC
+ */
+ thymeUtility.playOnClick();
+ thymeUtility.setUpMaxTime("max-time");
+
+ // resizes the thyme container to the window dimensions
+ function resizeContainer() {
+ Resizer.resizeContainer(thymeContainer, 1 / 0.82, 0);
+ annotationManager.updateMarkers();
+ }
+
+ window.onresize = resizeContainer;
+ video.onloadedmetadata = resizeContainer;
+
+ $("#video").width("82%");
+});
diff --git a/app/assets/javascripts/thyme/thyme_player.js b/app/assets/javascripts/thyme/thyme_player.js
new file mode 100644
index 000000000..c8afaf225
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_player.js
@@ -0,0 +1,185 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeContainer = document.getElementById("thyme-container");
+ if (!thymeContainer) {
+ return;
+ }
+
+ // background color
+ document.body.style.backgroundColor = "black";
+
+ // initialize attributes
+ const thyme = document.getElementById("thyme");
+ const video = document.getElementById("video");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = thyme.dataset.medium;
+ thymeAttributes.markerBarId = "markers";
+
+ /*
+ COMPONENTS
+ */
+ // annotation components
+ const annotationFeatureActive = (document.getElementById("annotation-button") != null);
+ thymeAttributes.annotationFeatureActive = annotationFeatureActive;
+ if (annotationFeatureActive) {
+ (new AnnotationButton("annotation-button")).add();
+ }
+ const annotationsToggle = new AnnotationsToggle("annotations-toggle");
+
+ // regular components
+ (new FullScreenButton("full-screen", thymeContainer)).add();
+ (new MuteButton("mute")).add();
+ (new NextChapterButton("next-chapter")).add();
+ (new PlayButton("play-pause")).add();
+ (new PreviousChapterButton("previous-chapter")).add();
+ (new SpeedSelector("speed")).add();
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ // initialize iaButton here to have the reference but call add() later
+ // when we can define toHide (second argument which is set to null here)
+ const iaButton = new IaButton("ia-active", null, [$(video), $("#video-controlBar")], "82%");
+ (new VolumeBar("volume-bar")).add();
+ seekBar = new SeekBar("seek-bar");
+ seekBar.add();
+ seekBar.addChapterTooltips();
+
+ /*
+ ANNOTATION FUNCTIONALITY
+ */
+ // annotation manager and area
+ function colorFunc(annotation) {
+ return annotation.color;
+ }
+
+ function onClose() {
+ iaButton.minus();
+ }
+
+ function isValid(annotation) {
+ return (!annotationsToggle.getValue() && !annotation.belongsToCurrentUser ? false : true);
+ }
+
+ const annotationArea = new AnnotationArea(true, colorFunc, onClose, isValid);
+ thymeAttributes.annotationArea = annotationArea;
+
+ function strokeColorFunc(_annotation) {
+ return "black";
+ }
+
+ function sizeFunc(_annotation) {
+ return false;
+ }
+
+ function onClick(annotation) {
+ iaButton.minus();
+ annotationArea.update(annotation);
+ annotationArea.show();
+ $("#caption").hide();
+ }
+
+ function onUpdate() {
+ /* update might change the annotation which is currently shown in the
+ annotation area -> find the updated annotation in the annotation array
+ and update the area. */
+ if (annotationArea.annotation) {
+ const id = annotationArea.annotation.id;
+ annotationArea.update(AnnotationManager.find(id));
+ }
+ annotationsToggle.add();
+ }
+
+ const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc,
+ onClick, onUpdate, isValid);
+ thymeAttributes.annotationManager = annotationManager;
+
+ // Update annotations after deleting an annotation
+ const ANNOTATION_DELETE_SELECTOR = "#annotation-delete-button";
+ $(document).on("click", ANNOTATION_DELETE_SELECTOR, function () {
+ const deleteMsg = $(ANNOTATION_DELETE_SELECTOR).data("sureToDelete");
+ const reallyDelete = confirm(deleteMsg);
+ if (!reallyDelete) {
+ return;
+ }
+
+ const annotationId = Number(document.getElementById("annotation_id").textContent);
+ $.ajax(Routes.annotation_path(annotationId), {
+ type: "DELETE",
+ dataType: "json",
+ data: {
+ annotation_id: annotationId,
+ },
+ success: function () {
+ annotationManager.updateAnnotations();
+ // close and open again = show normal IA
+ iaButton.minus();
+ iaButton.plus();
+ },
+ });
+ });
+
+ /*
+ CHAPTERS & METADATA MANAGER
+ */
+ const iaBackButton = new IaBackButton("back-button", "chapters");
+ iaBackButton.add();
+ const chapterManager = new ChapterManager("chapters", iaBackButton);
+ const metadataManager = new MetadataManager("metadata");
+ thymeAttributes.chapterManager = chapterManager;
+ thymeAttributes.metadataManager = metadataManager;
+ chapterManager.load();
+ metadataManager.load();
+
+ /*
+ INTERACTIVE AREA
+ */
+ iaButton.toHide = [$("#caption"), annotationArea];
+ iaButton.add();
+ (new IaCloseButton("ia-close", iaButton)).add();
+
+ /*
+ RESIZE
+ */
+ // Manage large and small display
+ function onEnlarge() {
+ iaButton.plus();
+ }
+
+ const elements = [$("#caption"), $("#annotation-caption"), $("#video-controlBar")];
+ const displayManager = new DisplayManager(elements, onEnlarge);
+
+ // resizes the thyme container to the window dimensions, taking into account
+ // whether the interactive area is displayed or hidden
+ function resizeContainer() {
+ const factor = $("#caption").is(":hidden") && $("#annotation-caption").is(":hidden") ? 1 : 1 / 0.82;
+ Resizer.resizeContainer(thymeContainer, factor, 0);
+ annotationManager.updateMarkers();
+ }
+
+ window.onresize = resizeContainer;
+ video.onloadedmetadata = resizeContainer;
+
+ /*
+ KEYBOARD SHORTCUTS
+ */
+ thymeKeyShortcuts.addGeneralShortcuts();
+ thymeKeyShortcuts.addPlayerShortcuts();
+
+ /*
+ MISC
+ */
+ const controlBarHider = new ControlBarHider("video-controlBar", 3000);
+ controlBarHider.install();
+ displayManager.updateControlBarType();
+ thymeUtility.playOnClick();
+ thymeUtility.setUpMaxTime("max-time");
+
+ if (document.documentMode) {
+ alert($("body").data("badbrowser"));
+ displayManager.adaptToSmallDisplay();
+ resizeContainer();
+ return;
+ }
+});
diff --git a/app/assets/javascripts/thyme/utility.js b/app/assets/javascripts/thyme/utility.js
new file mode 100644
index 000000000..dd20d5e7a
--- /dev/null
+++ b/app/assets/javascripts/thyme/utility.js
@@ -0,0 +1,143 @@
+/**
+ This file contains some auxiliary functions used by the different thyme player types.
+*/
+const thymeUtility = {
+
+ /*
+ Mixes all colors in the array "colors" (write colors as hexadecimal, e.g. "#1fe67d").
+ */
+ mixColors: function (colors) {
+ let red = 0;
+ let green = 0;
+ let blue = 0;
+ for (let color of colors) {
+ red += Number("0x" + color.substr(5, 2));
+ green += Number("0x" + color.substr(3, 2));
+ blue += Number("0x" + color.substr(1, 2));
+ }
+ const n = colors.length;
+ red = Math.max(0, Math.min(255, Math.round(red / n)));
+ green = Math.max(0, Math.min(255, Math.round(green / n)));
+ blue = Math.max(0, Math.min(255, Math.round(blue / n)));
+ return "#" + thymeUtility.toHexaDecimal(blue)
+ + thymeUtility.toHexaDecimal(green)
+ + thymeUtility.toHexaDecimal(red);
+ },
+
+ /*
+ Convert given dataURL to Blob, used for converting screenshot canvas to png.
+ */
+ dataURLtoBlob: function (dataURL) {
+ // Decode the dataURL
+ const binary = atob(dataURL.split(",")[1]);
+ // Create 8-bit unsigned array
+ let array = [];
+ for (let i = 0; i < binary.length; i++) {
+ array.push(binary.charCodeAt(i));
+ }
+ // Return our Blob object
+ return new Blob([new Uint8Array(array)], {
+ type: "image/png",
+ });
+ },
+
+ /*
+ Lightens up a given color (given in a string in hexadecimal
+ representation "#xxyyzz") such that e.g. black becomes dark grey.
+ The higher the value of "factor" the brighter the colors become.
+ */
+ lightenUp: function (color, factor) {
+ const red = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(5, 2))) / factor);
+ const green = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(3, 2))) / factor);
+ const blue = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(1, 2))) / factor);
+ return "#" + thymeUtility.toHexaDecimal(blue)
+ + thymeUtility.toHexaDecimal(green)
+ + thymeUtility.toHexaDecimal(red);
+ },
+
+ /*
+ Installs a listener which lets the video play/pause when clicked.
+ */
+ playOnClick: function () {
+ const video = thymeAttributes.video;
+ video.addEventListener("click", function () {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ });
+ },
+
+ /*
+ Renders latex in a given HTML element.
+ */
+ renderLatex: function (element) {
+ renderMathInElement(element, {
+ delimiters: [
+ {
+ left: "$$",
+ right: "$$",
+ display: true,
+ }, {
+ left: "$",
+ right: "$",
+ display: false,
+ }, {
+ left: "\\(",
+ right: "\\)",
+ display: false,
+ }, {
+ left: "\\[",
+ right: "\\]",
+ display: true,
+ },
+ ],
+ throwOnError: false,
+ });
+ },
+
+ /*
+ Convert time in seconds to string of the form H:MM:SS.
+ */
+ secondsToTime: function (seconds) {
+ let date = new Date(null);
+ date.setSeconds(seconds);
+ return date.toISOString().substr(12, 7);
+ },
+
+ /*
+ Sets up the label on the right side of the seek bar which displays
+ the maximum time of the video.
+ (In order to make this work, we have to wait for the video's metadata
+ to be loaded.)
+ */
+ setUpMaxTime: function (maxTimeId) {
+ const video = thymeAttributes.video;
+ video.addEventListener("loadedmetadata", function () {
+ const maxTime = document.getElementById(maxTimeId);
+ maxTime.innerHTML = thymeUtility.secondsToTime(video.duration);
+ if (video.dataset.time) {
+ const time = video.dataset.time;
+ video.currentTime = time;
+ }
+ });
+ },
+
+ /*
+ Converts a json timestamp into a double value containing the absolute value of seconds.
+ */
+ timestampToSeconds: function (timestamp) {
+ return 3600 * timestamp.hours + 60 * timestamp.minutes + timestamp.seconds + 0.001 * timestamp.milliseconds;
+ },
+
+ /*
+ Converts a given integer between 0 and 255 into a hexadecimal, s.t. e.g. "15" becomes "0f"
+ (instead of just "f") -> needed for correct format.
+ */
+ toHexaDecimal: function (integer) {
+ return integer.toString(16).padStart(2, "0");
+ },
+
+};
diff --git a/app/assets/javascripts/thyme_editor.coffee b/app/assets/javascripts/thyme_editor.coffee
deleted file mode 100644
index dce0182cf..000000000
--- a/app/assets/javascripts/thyme_editor.coffee
+++ /dev/null
@@ -1,208 +0,0 @@
-# convert time in seconds to string of the form H:MM:SS
-secondsToTime = (seconds) ->
- date = new Date(null)
- date.setSeconds seconds
- return date.toISOString().substr(12, 7)
-
-# convert given dataURL to Blob, used for converting screenshot canvas to png
-dataURLtoBlob = (dataURL) ->
- # Decode the dataURL
- binary = atob(dataURL.split(',')[1])
- # Create 8-bit unsigned array
- array = []
- i = 0
- while i < binary.length
- array.push binary.charCodeAt(i)
- i++
- # Return our Blob object
- new Blob([ new Uint8Array(array) ], type: 'image/png')
-
-$(document).on 'turbolinks:load', ->
- thymeEdit = document.getElementById('thyme-edit')
- return if thymeEdit == null
- mediumId = thymeEdit.dataset.medium
- # Video
- video = document.getElementById('video-edit')
- # Buttons
- playButton = document.getElementById('play-pause')
- muteButton = document.getElementById('mute')
- plusTenButton = document.getElementById('plus-ten')
- plusFiveButton = document.getElementById('plus-five')
- plusOneButton = document.getElementById('plus-one')
- minusOneButton = document.getElementById('minus-one')
- minusFiveButton = document.getElementById('minus-five')
- minusTenButton = document.getElementById('minus-ten')
- addItemButton = document.getElementById('add-item')
- addReferenceButton = document.getElementById('add-reference')
- addScreenshotButton = document.getElementById('add-screenshot')
- # Sliders
- seekBar = document.getElementById('seek-bar')
- volumeBar = document.getElementById('volume-bar')
- # Time
- currentTime = document.getElementById('current-time')
- maxTime = document.getElementById('max-time')
- # ControlBar
- videoControlBar = document.getElementById('video-controlBar-edit')
- # Screenshot Canvas
- canvas = document.getElementById('snapshot')
-
- # Event listener for the play/pause button
- playButton.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- return
-
- video.onplay = ->
- playButton.innerHTML = 'pause'
-
- video.onpause = ->
- playButton.innerHTML = 'play_arrow'
-
- # Event listener for the mute button
- muteButton.addEventListener 'click', ->
- if video.muted == false
- video.muted = true
- muteButton.innerHTML = 'volume_off'
- else
- video.muted = false
- muteButton.innerHTML = 'volume_up'
- return
-
- # Event handler for the plusTen button
- plusTenButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 10, video.duration)
- return
-
- # Event handler for the plusFive button
- plusFiveButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 5, video.duration)
- return
-
- # Event handler for the plusOne button
- plusOneButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 1, video.duration)
- return
-
- # Event handler for the minusOne button
- minusOneButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 1, 0)
- return
-
- # Event handler for the minusFive button
- minusFiveButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 5, 0)
- return
-
- # Event handler for the minusTen button
- minusTenButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 10, 0)
- return
-
- # Event listener for the seek bar
- seekBar.addEventListener 'input', ->
- time = video.duration * seekBar.value / 100
- video.currentTime = time
- return
-
- # Event listener for addItem button
- addItemButton.addEventListener 'click', ->
- video.pause()
- # round time down to three decimal digits
- time = video.currentTime
- intTime = Math.floor(time)
- roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000
- video.currentTime = roundTime
- $.ajax Routes.add_item_path(mediumId),
- type: 'GET'
- dataType: 'script'
- data: {
- time: video.currentTime
- }
- return
-
- # Event listener for addItem button
- addReferenceButton.addEventListener 'click', ->
- video.pause()
- # round time down to three decimal digits
- time = video.currentTime
- intTime = Math.floor(time)
- roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000
- video.currentTime = roundTime
- $.ajax Routes.add_reference_path(mediumId),
- type: 'GET'
- dataType: 'script'
- data: {
- time: video.currentTime
- }
- return
-
- # Event listener for add screenshot button
- addScreenshotButton.addEventListener 'click', ->
- video.pause()
- # extract video screenshot from canvas
- context = canvas.getContext('2d')
- context.drawImage(video, 0, 0, canvas.width, canvas.height)
- base64image = canvas.toDataURL('image/png')
- # Get our file
- file = dataURLtoBlob(base64image)
- # Create new form data
- fd = new FormData
- # Append our Canvas image file to the form data
- fd.append 'image', file
- # And send it
- $.ajax Routes.add_screenshot_path(mediumId),
- type: 'POST'
- data: fd
- processData: false
- contentType: false
- return
-
- # after video metadata have been loaded, set up video length, volume bar and
- # seek bar
- video.addEventListener 'loadedmetadata', ->
- maxTime.innerHTML = secondsToTime(video.duration)
- volumeBar.value = video.volume
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' +
- video.volume*100 + '%, #ffffff)'
- seekBar.value = 0
- canvas.width = Math.floor($(video).width())
- canvas.height = Math.floor($(video).height())
- return
-
- # Update the seek bar as the video plays
- video.addEventListener 'timeupdate', ->
- value = 100 / video.duration * video.currentTime
- seekBar.value = value
- seekBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)'
- currentTime.innerHTML = secondsToTime(video.currentTime)
- return
-
- # Pause the video when the seek handle is being dragged
- seekBar.addEventListener 'mousedown', ->
- video.dataset.paused = video.paused
- video.pause()
- return
-
- # Play the video when the seek handle is dropped
- seekBar.addEventListener 'mouseup', ->
- video.play() unless video.dataset.paused == 'true'
- return
-
- # Event listener for the volume bar
- volumeBar.addEventListener 'change', ->
- value = volumeBar.value
- video.volume = value
- return
-
- video.addEventListener 'volumechange', ->
- value = video.volume
- volumeBar.value = value
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)'
- return
-
- return
diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss
new file mode 100644
index 000000000..02b3fc257
--- /dev/null
+++ b/app/assets/stylesheets/annotations.scss
@@ -0,0 +1,213 @@
+/* buttons */
+#annotation-button {
+ position: absolute;
+ left: 51%;
+ font-size: 1.3rem;
+ padding: 2px 8px;
+
+ i {
+ &::before {
+ color: transparent;
+ background-clip: text;
+ background-image: radial-gradient(at bottom right, rgb(30, 82, 141) 0%, rgb(237, 152, 189) 100%);
+ }
+ }
+
+ transition: filter 60ms ease-in-out;
+
+ &:hover {
+ filter: drop-shadow(2px 3px 2px rgba(97, 114, 138, 0.3));
+ }
+}
+
+#annotations-toggle {
+ position: absolute;
+ display: flex;
+ left: 89%;
+
+ input:checked {
+ background-color: #2196F3; // TODO: outsource common video control colors
+ }
+}
+
+/* annotation modal */
+#annotation-modal {
+ .modal-header {
+ transition: background-color 200ms linear;
+ }
+
+ .modal-body {
+ display: flex;
+ }
+
+ .modal-footer {
+ margin-top: 40px;
+ padding-bottom: 0;
+ padding-right: 0;
+ }
+
+ .annotation-dialog-normal {
+ max-width: 560px;
+ }
+
+ .annotation-dialog-expanded {
+ max-width: 730px;
+ }
+
+ .annotation-content-normal {
+ width: 100%;
+ }
+
+ .annotation-content-expanded {
+ width: 70%;
+ }
+
+ #annotation-preview-section {
+ width: 30%;
+ }
+
+ #annotation-modal-preview {
+ word-wrap: break-word;
+ overflow-y: auto;
+ }
+
+ #annotation_comment {
+ height: 200px;
+ max-height: 300px;
+ }
+
+ #annotation_category_text {
+ width: 200px;
+ }
+
+ // adapted from https://stackoverflow.com/a/49065029/
+ section {
+ display: flex;
+ flex-direction: column;
+ // border: thin solid rgb(176, 176, 176);
+ }
+
+ // this column adapts to the other column with respect to its height
+ .column-adaptable {
+ flex-basis: 0;
+ flex-grow: 1;
+ }
+
+ .annotation-preview {
+ border-right: thin solid rgb(176, 176, 176);
+ margin-right: 8px;
+ padding-right: 8px;
+ }
+
+ .annotation-content-spacing {
+ padding-left: 8px;
+ }
+}
+
+#emergency-link {
+ text-align: center;
+}
+
+.annotation-marker {
+ position: relative;
+ top: -12px;
+ width: 0;
+ height: 0;
+ display: flex;
+ cursor: pointer;
+
+ & i {
+ position: relative;
+ transition: filter 80ms ease-in-out;
+ filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.1));
+ font-size: 1.0rem;
+
+ &.annotation-marker-shown {
+ filter: drop-shadow(0px 0px 2px #fffd00) !important;
+ z-index: 100; // for markers on the same point in time
+ }
+
+ &:hover {
+ filter: drop-shadow(0px 0px 2px #fffd00);
+ }
+ }
+}
+
+#annotation-caption {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+#annotation-infobar {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+
+ padding: 0.6em;
+ box-shadow: 0px 2px 10px -2px rgba(0, 0, 0, 0.05);
+
+ color: #4b4b4b;
+ font-size: 0.85rem;
+ font-weight: bold;
+ letter-spacing: 1px;
+}
+
+#annotation-comment {
+ height: 100%;
+ overflow: overlay;
+ padding: 0.5em;
+
+ color: #4b4b4b;
+ font-size: 1rem;
+}
+
+#annotation-area-buttons {
+ display: flex;
+ justify-content: center;
+ padding: 0.4em 4.2em;
+
+ box-shadow: 0px -2px 10px -2px rgba(0, 0, 0, 0.05);
+
+ & i {
+ font-size: 1.1rem;
+ }
+}
+
+#annotation-color-picker {
+ input[type="radio"] {
+ display: none;
+
+ &:checked+label {
+ span {
+ transform: scale(1.25);
+ border: 2px solid #0000008a;
+ }
+ }
+ }
+
+ text-align: center;
+
+ label {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ margin-right: 2px;
+ cursor: pointer;
+
+ &:hover {
+ span {
+ transform: scale(1.25);
+ }
+ }
+
+ span {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ transition: transform .1s ease-in-out;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 66e6312e3..e8b97ca3e 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -42,6 +42,7 @@ $container-max-widths: (
/* override the !default vars with the values we set above */
+@import "annotations";
@import "bootstrap";
@import "rails_bootstrap_forms";
@import "chapters";
@@ -57,6 +58,7 @@ $container-max-widths: (
@import "tags";
@import "talks";
@import "thyme";
+@import "thyme_feedback";
@import "users";
@import "submissions";
@import "vertices";
diff --git a/app/assets/stylesheets/thyme.scss b/app/assets/stylesheets/thyme.scss
index 6fbed3b35..97d6ab777 100644
--- a/app/assets/stylesheets/thyme.scss
+++ b/app/assets/stylesheets/thyme.scss
@@ -1,47 +1,45 @@
-
-#thyme-container {
- position: absolute;
- top: 0;
- width: 100%;
- height: 100%;
- font-family: 'Roboto';
+.thyme-container-layout {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ font-family: 'Roboto';
}
#editor-container {
- height:100vh;
+ height: 100vh;
width: 100vw;
}
-#thyme {
- min-width: 100%;
- min-height: 100%;
- width: auto;
- height: auto;
- background: black;
+.thyme-generic {
+ min-width: 100%;
+ min-height: 100%;
+ width: auto;
+ height: auto;
+ background: black;
}
#hypervideo-container {
- font-size: 0;
position: relative;
background: white;
margin: 0;
}
#video {
- width: 82%;
- height: auto;
- display: inline-block;
+ display: block;
+ background: black;
}
#hypervideo-container figcaption {
position: absolute;
- right: 0; top: 0;
+ right: 0;
+ top: 0;
background: white;
width: 18%;
font-size: .8rem;
color: #666;
height: 100%;
- outline: 0;
+ outline: 0;
}
#hypervideo-container figcaption ol {
@@ -51,127 +49,176 @@
padding: 0;
}
-#video-container {
- position: relative;
-}
-
#video-controlBar {
- width: 82%;
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- padding: 5px;
- width: 82%;
- height: auto;
- background: lightgray;
- -webkit-user-select: none;
- -moz-user-select: none;
+ width: 82%;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 5px;
+ width: 82%;
+ height: auto;
+ background: linear-gradient(120deg, hsl(214, 19%, 84%) 0%, hsl(207, 18%, 82%) 100%);
user-select: none;
}
#video-controls {
- position: relative;
- display: inline-block;
- height: 30px;
- width: 100%;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ height: 2.5em;
+ padding: inherit;
}
#timeline {
- position: absolute;
- display: flex;
- width: 50%;
+ position: absolute;
+ display: flex;
+ width: 50%;
+ padding-left: inherit;
}
#current-time {
- font-size: 12px;
- padding: 4px;
+ font-size: 12px;
+ padding: 4px;
}
#seek-bar {
- flex-grow: 5;
- margin-top: 7px;
+ flex-grow: 5;
+ margin-top: 7px;
}
#max-time {
- font-size: 12px;
- padding: 4px;
+ font-size: 12px;
+ padding: 4px;
}
-#special-buttons {
- position: absolute;
- display: flex;
- left: 52%;
+.special-buttons-layout {
+ position: absolute;
+ display: flex;
+ left: 54%;
}
-.clickable
-{
+.clickable {
cursor: pointer;
}
#previous-chapter {
- margin-left: 10px;
+ margin-left: 10px;
}
-#volume-controls {
- position: absolute;
- display: flex;
- width: 10%;
- left: 68%;
+.volume-controls-layout {
+ position: absolute;
+ display: flex;
+ width: 10%;
+ left: 68%;
}
#speed-control {
- position: absolute;
- padding: 3px;
- font-size: 12px;
- left: 81%;
+ position: absolute;
+ padding: 3px;
+ font-size: 12px;
+ left: 79%;
}
#volume-bar {
- flex-grow: 2;
- min-width: 50%;
- margin-top: 7px;
- margin-left: 4px;
+ flex-grow: 2;
+ min-width: 50%;
+ margin-top: 7px;
+ margin-left: 4px;
+}
+
+/* The switch - the box around the slider */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 30px;
+ height: 17px;
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+/* The slider */
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: .4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 13px;
+ width: 13px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: .4s;
+}
+
+input:focus+.slider {
+ box-shadow: 0 0 1px #2196F3;
+}
+
+input:checked+.slider:before {
+ transform: translateX(13px);
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 17px;
+}
+
+.slider.round:before {
+ border-radius: 50%;
}
#size-buttons {
- position: absolute;
- right: 0%;
+ display: flex;
+ position: absolute;
+ right: 0%;
+ padding-right: inherit;
}
input[type=range] {
- -webkit-appearance: none;
- -moz-appearance: none;
- border-radius: 6px;
- height: 10px;
- background-image: linear-gradient(
- to right,
+ appearance: none;
+ border-radius: 6px;
+ height: 10px;
+ background-image: linear-gradient(to right,
#2497E3,
#2497E3 0%,
#ffffff 0%,
- #ffffff
- );
+ #ffffff);
}
input[type="range"]::-moz-range-track {
- border: none;
- background: none;
- outline: none;
+ border: none;
+ background: none;
+ outline: none;
}
input[type=range]:focus {
- outline: none;
- border: none;
+ outline: none;
+ border: none;
}
input[type=range]::-webkit-slider-thumb {
- -webkit-appearance: none !important;
- background-color: #2497E3;
- height: 16px;
- width: 16px;
- border-radius: 50%;
- border: 1px solid black;
+ -webkit-appearance: none !important;
+ background-color: #2497E3;
+ height: 16px;
+ width: 16px;
+ border-radius: 50%;
+ border: 1px solid black;
}
input[type=range]::-moz-range-thumb {
@@ -183,7 +230,7 @@ input[type=range]::-moz-range-thumb {
}
input[type=range]::-moz-focus-outer {
- border: 0;
+ border: 0;
}
figure {
@@ -210,38 +257,20 @@ figure {
height: calc(0.5*(100% - 6em - 4em));
border: 1px solid darkgray;
overflow-y: scroll;
- color: #484848;
+ color: #484848;
}
#metadata .current {
color: black;
- -webkit-animation: yellow-fade 10s ease-in;
- -moz-animation: yellow-fade 10s ease-in;
- -o-animation: yellow-fade 10s ease-in;
- animation: yellow-fade 10s ease-in;
+ animation: yellow-fade 10s ease-in;
+ animation: yellow-fade 10s ease-in;
}
-
-@-webkit-keyframes yellow-fade {
- from {
- background: gold;
- }
- to {
- background: #fff;
- }
-}
-@-moz-keyframes yellow-fade {
- from {
- background: gold;
- }
- to {
- background: #fff;
- }
-}
@keyframes yellow-fade {
from {
background: gold;
}
+
to {
background: #fff;
}
@@ -249,28 +278,29 @@ figure {
#metadata li a i {
- font-size: 1.5em;
+ font-size: 1.5em;
}
#metadata li {
- display: flex;
- justify-content: space-between;
+ display: flex;
+ justify-content: space-between;
position: relative;
border-bottom: 1px solid darkgrey;
- text-decoration: none;
- padding: 5px;
+ text-decoration: none;
+ padding: 5px;
}
#metadata li a {
- color: inherit;
+ color: inherit;
display: block;
text-decoration: none;
padding: 3px;
}
-.item:hover, .item:focus{
- background: #f0f0f0;
- cursor: pointer;
+.item:hover,
+.item:focus {
+ background: #f0f0f0;
+ cursor: pointer;
}
@@ -309,9 +339,9 @@ figure {
background: #e6e6e6;
border-left: 1px solid darkgray;
border-right: 1px solid darkgray;
- display: flex;
- justify-content: center;
- align-items: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
@@ -325,11 +355,9 @@ figure {
.replay {
position: absolute;
top: 0;
- right:0;
+ right: 0;
padding: 3px;
cursor: pointer;
- -webkit-user-select: none;
- -moz-user-select: none;
user-select: none;
}
@@ -337,7 +365,6 @@ figure {
background: lightgrey;
}
-:-webkit-full-screen::-webkit-backdrop
- {
- background-color: black;
- }
+:full-screen::backdrop {
+ background-color: black;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/thyme_feedback.scss b/app/assets/stylesheets/thyme_feedback.scss
new file mode 100644
index 000000000..1bd1cb6ec
--- /dev/null
+++ b/app/assets/stylesheets/thyme_feedback.scss
@@ -0,0 +1,56 @@
+#thyme-feedback {
+ #timeline {
+ width: 55%;
+ }
+}
+
+#annotation-switches {
+ display: flex;
+ position: absolute;
+ left: 57%;
+
+ .form-switch .form-check-input {
+ border: none;
+
+ &:focus {
+ box-shadow: 0 0 0 0.25rem #ffffff4f;
+ background-image: url("data:image/svg+xml, ");
+ }
+
+ &:checked {
+ background-image: url("data:image/svg+xml, ");
+ }
+ }
+
+ // reflected from category.js
+ #annotation-category-note-switch:checked {
+ background-color: #f78f19;
+ }
+
+ #annotation-category-content-switch:checked {
+ background-color: #A333C8;
+ }
+
+ #annotation-category-presentation-switch:checked {
+ background-color: #2185D0;
+ }
+
+ #annotation-category-mistake-switch:checked {
+ background-color: #fc1461;
+ }
+}
+
+#feedback-special-buttons {
+ left: 73%;
+}
+
+#feedback-volume-controls {
+ left: 79%;
+}
+
+#heatmap {
+ position: relative;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb
new file mode 100644
index 000000000..3b65c3a02
--- /dev/null
+++ b/app/controllers/annotations_controller.rb
@@ -0,0 +1,211 @@
+class AnnotationsController < ApplicationController
+ authorize_resource
+
+ def show
+ @annotation = Annotation.find(params[:id])
+ end
+
+ def new
+ @annotation = Annotation.new(category: :note, color: Annotation.colors[1])
+
+ @total_seconds = params[:total_seconds]
+ @medium_id = params[:medium_id]
+ @posted = false
+ @is_new_annotation = true
+
+ render :edit
+ end
+
+ def edit
+ @annotation = Annotation.find(params[:annotation_id])
+
+ # only allow editing, if the current user created the annotation
+ if @annotation.user_id != current_user.id
+ render json: false
+ return
+ end
+
+ @total_seconds = @annotation.timestamp.total_seconds
+ @medium_id = @annotation.medium_id
+ @posted = !@annotation.public_comment_id.nil?
+
+ # if this annotation has an associated commontator comment,
+ # we have to call the "comment_optional" method in order to get
+ # the text.
+ @annotation.comment = @annotation.comment_optional
+
+ @is_new_annotation = false
+ end
+
+ def create
+ @annotation = Annotation.new(annotation_params)
+
+ @annotation.user_id = current_user.id
+ @total_seconds = annotation_auxiliary_params[:total_seconds]
+ @annotation.timestamp = TimeStamp.new(total_seconds: @total_seconds)
+
+ return unless create_and_update_shared(@annotation)
+
+ @annotation.save
+ render :update
+ end
+
+ def update
+ @annotation = Annotation.find(params[:id])
+ @annotation.assign_attributes(annotation_params)
+
+ return unless create_and_update_shared(@annotation)
+
+ @annotation.save
+ end
+
+ def destroy
+ annotation = Annotation.find(params[:annotation_id])
+
+ # only the owner of the annotation is allowed to delete it
+ return unless annotation.user == current_user
+
+ # delete associated commontator comment
+ unless annotation.public_comment_id.nil?
+ commontator_comment = Commontator::Comment.find_by(id: annotation.public_comment_id)
+ commontator_comment.update(deleted_at: DateTime.now)
+ end
+
+ annotation.destroy
+
+ render json: []
+ end
+
+ def update_annotations
+ medium = Medium.find_by(id: params[:mediumId])
+
+ # Get the right annotations
+ annotations = if medium.annotations_visible?(current_user)
+ Annotation.where(medium: medium,
+ visible_for_teacher: true).or(
+ Annotation.where(medium: medium,
+ user: current_user)
+ )
+ else
+ Annotation.where(medium: medium,
+ user: current_user)
+ end
+
+ # If annotation is associated to a comment,
+ # the field "comment" is empty -> get it from the commontator comment
+ annotations.each do |a|
+ a.comment = a.comment_optional
+ end
+
+ # Convert to JSON (for easier hash operations)
+ annotations = annotations.as_json
+
+ # Filter attributes and add boolean "belongs_to_current_user".
+ annotations.each do |a|
+ a["belongs_to_current_user"] = (current_user.id == a["user_id"])
+ a.slice!("belongs_to_current_user", "category", "color", "comment",
+ "id", "subcategory", "timestamp")
+ end
+
+ render json: annotations
+ end
+
+ def num_nearby_posted_mistake_annotations
+ # the time (!) radius in which annotation are considered as "nearby"
+ radius = 60
+ timestamp = params[:timestamp].to_i
+ annotations = Annotation.where(medium: params[:mediumId], category: "mistake").commented
+ counter = annotations.to_a.count { |annotation| annotation.nearby?(timestamp, radius) }
+ render json: counter
+ end
+
+ def current_ability
+ @current_ability ||= AnnotationAbility.new(current_user)
+ end
+
+ private
+
+ def annotation_params
+ params.require(:annotation).permit(
+ :category, :color, :comment, :medium_id, :subcategory, :visible_for_teacher
+ )
+ end
+
+ def annotation_auxiliary_params
+ params.require(:annotation).permit(
+ :total_seconds, :post_as_comment
+ )
+ end
+
+ # TODO: Frontend should not pass color hex strings, instead pass the respective
+ # color keys, e.g. 14, see annotation.rb color_map for lookup.
+ def valid_color?(color)
+ Annotation.colors.value?(color)
+ # if you want to allow any color (not just the given selection
+ # in Annotation.colors), use the following regex check:
+ # color&.match?(/\A#([0-9]|[A-F]){6}\z/)
+ end
+
+ def valid_time?(annotation)
+ time = annotation.timestamp.total_seconds
+ time >= 0 and time <= annotation.medium.video["duration"]
+ end
+
+ # checks that the subcategory is non-nil if the category is "content" and
+ # resets the subcategory to "nil" if the selected category isn't "content"
+ def subcategory_nil(annotation)
+ return if (annotation.category_for_database == Annotation.categories[:content]) &&
+ annotation.subcategory.nil?
+
+ if annotation.category_for_database != Annotation.categories[:content]
+ annotation.subcategory = nil
+ end
+ true
+ end
+
+ # common code for the create and update method
+ def create_and_update_shared(annotation)
+ valid_color?(annotation.color) &&
+ valid_time?(annotation) &&
+ subcategory_nil(annotation) &&
+ commontator_comment(annotation)
+ end
+
+ # Run all the Commontator::Comment related code here.
+ def commontator_comment(annotation)
+ public_comment_id = annotation.public_comment_id
+
+ # return true (success) if checkbox "post_as_comment" is not checked
+ # and if there is no comment to update
+ return true if annotation_auxiliary_params[:post_as_comment] != "1" &&
+ public_comment_id.nil?
+
+ body = annotation_params[:comment]
+
+ if public_comment_id.nil? # comment doesn't exist yet -> create one
+ medium = annotation.medium
+ comment = Commontator::Comment.new(
+ thread: medium.commontator_thread,
+ creator: current_user,
+ body: body,
+ annotation: annotation
+ )
+ else # comment already exists -> update it
+ comment = Commontator::Comment.find_by(id: public_comment_id)
+ comment.assign_attributes(editor: current_user,
+ body: body)
+ end
+
+ # if the same comment already exists, the db will trigger a rollback
+ # -> print error message in that case
+ if !comment.save && comment.errors.of_kind?(:body, :taken)
+ render :duplicate_comment
+ return
+ end
+
+ # delete comment as it is already saved in the commontator comment model
+ annotation.comment = nil
+
+ annotation.public_comment_id = comment.id
+ end
+end
diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb
index a7196ea79..ff1e44734 100644
--- a/app/controllers/lectures_controller.rb
+++ b/app/controllers/lectures_controller.rb
@@ -53,6 +53,7 @@ def new
# info to the lecture
@lecture.course = Course.find_by(id: params[:course])
I18n.locale = @lecture.course.locale
+ @lecture.annotations_status = 0
end
def edit
@@ -61,6 +62,15 @@ def edit
Time.zone.parse(ENV.fetch("RAILS_CACHE_ID", nil))].max)
eager_load_stuff
end
+
+ # emergency link -> prefill form
+ status = @lecture.emergency_link_status_for_database
+ link = @lecture.emergency_link
+ if status == Lecture.emergency_link_statuses[:lecture_link]
+ @linked_lecture = Lecture.find_by(id: link.tr("^[0-9]", ""))
+ elsif status == Lecture.emergency_link_statuses[:direct_link]
+ @direct_link = link
+ end
end
def create
@@ -90,6 +100,8 @@ def create
end
def update
+ return unless @lecture.valid_annotations_status?
+
editor_ids = lecture_params[:editor_ids]
unless editor_ids.nil?
# removes the empty String "" in the NEW array of editor ids
@@ -109,6 +121,19 @@ def update
end
end
+ # emergency link update
+ status = params[:lecture][:emergency_link_status] # string
+ status = Lecture.emergency_link_statuses[status]
+ if status == Lecture.emergency_link_statuses[:lecture_link]
+ params[:lecture][:emergency_link] = params[:lecture][:lecture_link]
+ elsif status == Lecture.emergency_link_statuses[:direct_link]
+ link = params[:lecture][:direct_link]
+ # Prepend "https://" to link if not present to make it an absolute URL
+ # instead of a relative one. E.g. "example.com" -> "https://example.com".
+ link = "https://#{link}" unless link.start_with?("http")
+ params[:lecture][:emergency_link] = link
+ end
+
@lecture.update(lecture_params)
if structure_params.present?
structure_ids = structure_params.select { |_k, v| v.to_i == 1 }.keys
@@ -232,6 +257,12 @@ def search_examples
def close_comments
@lecture.close_comments!(current_user)
+ # disable annotation button
+ @lecture.update(annotations_status: 0)
+ @lecture.media.update(annotations_status: -1)
+ @lecture.lessons.each do |lesson|
+ lesson.media.update(annotations_status: -1)
+ end
redirect_to edit_lecture_path(@lecture)
end
@@ -313,7 +344,9 @@ def lecture_params
:organizational_concept, :muesli,
:organizational_on_top, :disable_teacher_display,
:content_mode, :passphrase, :sort, :comments_disabled,
- :submission_max_team_size, :submission_grace_period]
+ :submission_max_team_size, :submission_grace_period,
+ :annotations_status, :emergency_link,
+ :emergency_link_status]
if action_name == "update" && current_user.can_update_personell?(@lecture)
allowed_params.push(:teacher_id, { editor_ids: [] })
end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index ffefdc701..2e8bf90fd 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -59,6 +59,9 @@ def edit
def create
@medium = Medium.new(medium_params)
+
+ return unless @medium.valid_annotations_status?
+
@medium.locale = @medium.teachable&.locale
@medium.editors = [current_user]
@medium.tags = @medium.teachable.tags if @medium.teachable.instance_of?(::Lesson)
@@ -106,6 +109,8 @@ def update
@errors = @medium.errors
return unless @errors.empty?
+ return unless @medium.valid_annotations_status?
+
# make sure the medium is touched
# (it will not be touched automatically in some cases (e.g. if you only
# update the associated tags), causing trouble for caching)
@@ -509,6 +514,19 @@ def fill_reassign_modal
@no_rights = params[:rights] == "none"
end
+ def check_annotation_visibility
+ medium = Medium.find_by(id: params[:id])
+ isPermitted = medium.annotations_visible?(current_user) # rubocop:todo Naming/VariableName
+ render json: isPermitted # rubocop:todo Naming/VariableName
+ end
+
+ # Renders the feedback player. Do not confuse with the feedback button
+ # which has nothing to do with the thyme player(s).
+ def feedback
+ I18n.locale = @medium.locale_with_inheritance
+ render layout: "feedback"
+ end
+
private
def medium_params
@@ -519,6 +537,7 @@ def medium_params
:teachable_type, :teachable_id,
:released, :text, :locale,
:content, :boost,
+ :annotations_status,
editor_ids: [],
tag_ids: [],
linked_medium_ids: [])
diff --git a/app/models/annotation.rb b/app/models/annotation.rb
new file mode 100644
index 000000000..b15b36052
--- /dev/null
+++ b/app/models/annotation.rb
@@ -0,0 +1,49 @@
+class Annotation < ApplicationRecord
+ belongs_to :medium
+ belongs_to :user
+ belongs_to :public_comment, class_name: "Commontator::Comment",
+ optional: true
+
+ scope :commented, -> { where.not(public_comment_id: nil) }
+
+ # the timestamp for the annotation position is serialized as text in the db
+ serialize :timestamp, TimeStamp
+
+ enum category: { note: 0, content: 1, mistake: 2, presentation: 3 }
+ enum subcategory: { definition: 0, argument: 1, strategy: 2 }
+
+ # If the annotation has an associated commontator comment, its comment will
+ # be saved in the commontator comment. While calling annotation.comment returns
+ # nil in this case, this method pulls out the actual comment from the commontator
+ # comment.
+ def comment_optional
+ return comment if public_comment_id.nil?
+
+ Commontator::Comment.find_by(id: public_comment_id).body
+ end
+
+ def nearby?(other_timestamp, radius)
+ (timestamp.total_seconds - other_timestamp).abs < radius
+ end
+
+ def self.colors
+ # Colors must have 6 digits and be capitalized (!)
+ {
+ 1 => "#DB2828",
+ 2 => "#F2711C",
+ 3 => "#FBBD08",
+ 4 => "#B5CC18",
+ 5 => "#21BA45",
+ 6 => "#00B5AD",
+ 7 => "#2185D0",
+ 8 => "#6435C9",
+ 9 => "#A333C8",
+ 10 => "#E03997",
+ 11 => "#D05D41",
+ 12 => "#924129",
+ 13 => "#444444",
+ 14 => "#999999",
+ 15 => "#EEEEEE"
+ }
+ end
+end
diff --git a/app/models/lecture.rb b/app/models/lecture.rb
index 8d5c1d353..097bb4b52 100644
--- a/app/models/lecture.rb
+++ b/app/models/lecture.rb
@@ -67,6 +67,10 @@ class Lecture < ApplicationRecord
# in the erdbeere database
serialize :structure_ids, Array
+ # if the annotation button is enabled, one can add different types of links
+ # that e.g. bring students to the helpdesk
+ enum emergency_link_status: { no_link: 0, lecture_link: 1, direct_link: 2 }
+
# we do not allow that a teacher gives a certain lecture in a given term
# of the same sort twice
# rubocop:todo Rails/UniqueValidationWithoutIndex
@@ -840,6 +844,10 @@ def stale?
older_than?(1.year)
end
+ def valid_annotations_status?
+ [-1, 1].include?(annotations_status)
+ end
+
private
# used for after save callback
diff --git a/app/models/medium.rb b/app/models/medium.rb
index 645371915..540e4e593 100644
--- a/app/models/medium.rb
+++ b/app/models/medium.rb
@@ -1087,6 +1087,26 @@ def subscribed_users
Lecture.find_by(id: teachable.lecture_id).user_ids
end
+ # Returns either the annotations status (1 = activated, 0 = deactivated)
+ # of this medium or the annotations status of the associated lecture
+ # if "inherit from lecture" was selected (i.e. if the annotations status of
+ # this medium is -1).
+ def get_annotations_status # rubocop:todo Naming/AccessorMethodName
+ return lecture.annotations_status if annotations_status == -1
+
+ annotations_status
+ end
+
+ def annotations_visible?(user)
+ is_teacher = edited_with_inheritance_by?(user)
+ is_activated = (get_annotations_status == 1)
+ is_teacher && is_activated
+ end
+
+ def valid_annotations_status?
+ [-1, 0, 1].include?(annotations_status)
+ end
+
private
# media of type kaviar associated to a lesson and script do not require
diff --git a/app/views/annotations/_annotation_area.html.erb b/app/views/annotations/_annotation_area.html.erb
new file mode 100644
index 000000000..ab0cfa8e8
--- /dev/null
+++ b/app/views/annotations/_annotation_area.html.erb
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/annotations/_annotation_locales.html.erb b/app/views/annotations/_annotation_locales.html.erb
new file mode 100644
index 000000000..e7661d764
--- /dev/null
+++ b/app/views/annotations/_annotation_locales.html.erb
@@ -0,0 +1,19 @@
+<% I18n.locale = current_user.locale %>
+
+
+
diff --git a/app/views/annotations/_annotation_modal.html.erb b/app/views/annotations/_annotation_modal.html.erb
new file mode 100644
index 000000000..7c5038921
--- /dev/null
+++ b/app/views/annotations/_annotation_modal.html.erb
@@ -0,0 +1,39 @@
+
diff --git a/app/views/annotations/_form.html.erb b/app/views/annotations/_form.html.erb
new file mode 100644
index 000000000..62cdf13f8
--- /dev/null
+++ b/app/views/annotations/_form.html.erb
@@ -0,0 +1,157 @@
+
+ <%= form_with model: @annotation do |f| %>
+
+ <%= t('admin.annotation.time') %>
+  
+ <%= TimeStamp.new(total_seconds: @total_seconds).hms_colon_string %>
+
+
+
+ <%= f.hidden_field :medium_id, value: @medium_id %>
+ <%= f.hidden_field :total_seconds, value: @total_seconds %>
+
+
+ <%= @annotation.id %>
+
+
+
+ <%= @annotation.subcategory %>
+
+
+
+
+
<%= t('admin.annotation.comment') %>
+ <%= f.text_area :comment, class: 'form-control' %>
+
+
+
+
+
+ <%= t('admin.remark.preview') %>
+
+
+
+
+
+ <% colors = Annotation.colors %>
+ <% for i in 1..15 do %>
+ <%= f.radio_button :color,
+ colors[i],
+ id: "annotation_color#{i}" %>
+ <%= f.label :color,
+ "annotation_color#{i}",
+ for: "annotation_color#{i}" do %>
+
+ <% end %>
+ <% end %>
+
+
+
+
<%= t('admin.annotation.category') %>
+ <%= helpdesk(t('admin.annotation.category_tooltip'), false) %>
+
+
+
+
+
+
+
+ <% annotations_status = Medium.find_by(id: @medium_id).get_annotations_status %>
+ <% if annotations_status == 1 %>
+
+ <%= f.check_box :visible_for_teacher, class: "form-check-input" %>
+ <%= f.label :visible_for_teacher,
+ t('admin.annotation.visible_for_teacher'),
+ class: "form-check-label" %>
+ <%= helpdesk(t('admin.annotation.visible_for_teacher_helpdesk'), false) %>
+
+ <% else %>
+ <%= f.hidden_field :visible_for_teacher, value: false %>
+ <% end %>
+
+
+
+ <%= f.check_box :post_as_comment, class: "form-check-input" %>
+ <%= f.label :post_as_comment,
+ t('admin.annotation.post_as_comment'),
+ class: "form-check-label" %>
+ <%= helpdesk(t('admin.annotation.post_as_comment_helpdesk'), false) %>
+
+
+
+
+
+ <% end %>
+
diff --git a/app/views/annotations/_form_content.html.erb b/app/views/annotations/_form_content.html.erb
new file mode 100644
index 000000000..3ea42b6f7
--- /dev/null
+++ b/app/views/annotations/_form_content.html.erb
@@ -0,0 +1,44 @@
+<%= t('admin.annotation.whats_the_problem') %>
+
+
+
+
+
+
diff --git a/app/views/annotations/_form_content_further_help.html.erb b/app/views/annotations/_form_content_further_help.html.erb
new file mode 100644
index 000000000..f3921f2a3
--- /dev/null
+++ b/app/views/annotations/_form_content_further_help.html.erb
@@ -0,0 +1,16 @@
+
+ <% lecture = Medium.find_by_id(@medium_id).lecture %>
+ <% status = lecture.emergency_link_status_for_database %>
+ <% link = lecture.emergency_link %>
+ <% if status == Lecture.emergency_link_statuses[:lecture_link] %>
+ <% link_name = Lecture.find_by_id(link.tr("^[0-9]", "")).title %>
+ <% elsif status == Lecture.emergency_link_statuses[:direct_link] %>
+ <% link_name = link %>
+ <% end %>
+ <% unless link_name.blank? %>
+ <%= t('admin.annotation.further_help') %>
+
+ <%= link_name %>
+
+ <% end %>
+
diff --git a/app/views/annotations/duplicate_comment.js.erb b/app/views/annotations/duplicate_comment.js.erb
new file mode 100644
index 000000000..51a33fe4b
--- /dev/null
+++ b/app/views/annotations/duplicate_comment.js.erb
@@ -0,0 +1,4 @@
+var $warningElement = $("#annotation-comment-warning");
+var message = document.getElementById("annotation-locales").dataset.warningDoublePosted;
+$warningElement.html(message);
+$warningElement.show();
diff --git a/app/views/annotations/edit.js.erb b/app/views/annotations/edit.js.erb
new file mode 100644
index 000000000..5c8db9ef2
--- /dev/null
+++ b/app/views/annotations/edit.js.erb
@@ -0,0 +1,305 @@
+// When the modal opens, all key listeners must be
+// deactivated until the modal gets closed again
+thymeAttributes.lockKeyListeners = true;
+$("#annotation-modal").on("hidden.bs.modal", function () {
+ thymeAttributes.lockKeyListeners = false;
+});
+
+$("#annotation-modal-content").empty()
+ .append("<%= j render partial: "annotations/form"%>");
+$("#annotation-modal").modal("show");
+
+var submitButton = document.getElementById("annotation-modal-submit-button");
+var $postAsComment = $("#annotation_post_as_comment");
+var posted = <%= @posted %>;
+var isNewAnnotation = <%= @is_new_annotation %>;
+
+$postAsComment.on("change", function () {
+ // Don't show warnings if the annotation was already posted
+ if (posted) {
+ return;
+ }
+
+ if (this.checked) {
+ const $warningElement = $("#annotation-comment-warning");
+ const message = constructWarningMessage();
+ $warningElement.html(message);
+ $warningElement.show();
+ }
+ else {
+ $("#annotation-comment-warning").hide();
+ }
+});
+
+function constructWarningMessage() {
+ let message = $("#annotation-locales").data("warningPublishing");
+
+ const mistakeRatio = $("#annotation_category_mistake");
+ if (!mistakeRatio.is(":checked")) {
+ return message;
+ }
+
+ // Mistake specific warnings
+ $.ajax(Routes.num_nearby_posted_mistake_annotations_path(), {
+ type: "GET",
+ dataType: "json",
+ async: false,
+ data: {
+ mediumId: thyme.dataset.medium,
+ timestamp: video.currentTime,
+ },
+ success: function (count) {
+ const locale = $("#annotation-locales");
+ message += " ";
+ if (!count) {
+ message += $("#annotation-locales").data("warningMistake");
+ }
+ else if (count == 1) {
+ message += locale.data("warningOneCloseAnnotation");
+ }
+ else {
+ message += locale.data("warningMultipleCloseAnnotations1")
+ + count + locale.data("warningMultipleCloseAnnotations2");
+ }
+ return message;
+ },
+ error: function (err) {
+ console.error("Error while fetching nearby annotations");
+ console.error(err);
+ return message;
+ },
+ });
+
+ return message;
+}
+
+/*
+ * CATEGORY
+ */
+
+var categoryRadios = document.getElementById("category-radios");
+
+categoryRadios.addEventListener("click", function (evt) {
+ if (evt.target && event.target.matches("input[type='radio']")) {
+ switch (evt.target.value) {
+ case Category.NOTE.name:
+ note();
+ break;
+ case Category.CONTENT.name:
+ content();
+ break;
+ case Category.MISTAKE.name:
+ mistake();
+ break;
+ case Category.PRESENTATION.name:
+ presentation();
+ break;
+ }
+ }
+});
+
+function note() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(false);
+ postComment(false);
+}
+
+function content() {
+ $("#annotation-category-specific").empty()
+ .append("<%= j render partial: "annotations/form_content"%>");
+ // disable submit button until the content category is selected
+ submitButton.disabled = true;
+ visibleForTeacher(true);
+ postComment(false);
+ var contentCategoryRadios = document.getElementById("content-category-radios");
+ contentCategoryRadios.addEventListener("click", function (evt) {
+ if (evt.target && event.target.matches("input[type='radio']")) {
+ submitButton.disabled = false;
+
+ // Show further help
+ // (right now, the same help is display for all the different categories)
+ switch (evt.target.value) {
+ case Subcategory.DEFINITION.name:
+ showFurtherHelp();
+ break;
+ case Subcategory.ARGUMENT.name:
+ showFurtherHelp();
+ break;
+ case Subcategory.STRATEGY.name:
+ showFurtherHelp();
+ break;
+ }
+ }
+ });
+
+ function showFurtherHelp() {
+ $("#content-specific").empty()
+ .append("<%= j render partial: "annotations/form_content_further_help"%>");
+ }
+}
+
+function mistake() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(true);
+ postComment(true);
+}
+
+function presentation() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(true);
+ postComment(false);
+}
+
+function updatePreview() {
+ const text = $("#annotation_comment").val();
+ $("#annotation-modal-preview").empty();
+ $("#annotation-modal-preview").append(text.replaceAll("\n", " "));
+ renderMathInElement(document.getElementById("annotation-modal-preview"), {
+ delimiters: [
+ {
+ left: "$$",
+ right: "$$",
+ display: true,
+ }, {
+ left: "$",
+ right: "$",
+ display: false,
+ }, {
+ left: "\\(",
+ right: "\\)",
+ display: false,
+ }, {
+ left: "\\[",
+ right: "\\]",
+ display: true,
+ },
+ ],
+ throwOnError: false,
+ });
+}
+
+/*
+ * Color
+ */
+function initModalBackgroundAnnotationColor() {
+ // Init event handler
+ $("#annotation-color-picker").on("click", "input[type='radio']", function (event) {
+ setModalColor(event.target.value);
+ });
+
+ // New annotation
+ if (isNewAnnotation) {
+ const randomNumber = Math.floor(Math.random() * 15) + 1;
+ const colorRadio = $(`#annotation_color${randomNumber}`);
+ colorRadio.click();
+ return;
+ }
+
+ // Edit annotation
+ const $selectedColor = $("#annotation-color-picker input[type=radio]:checked");
+ if ($selectedColor.length) {
+ $selectedColor.click();
+ }
+}
+
+function setModalColor(hexColorString) {
+ const color = thymeUtility.lightenUp(hexColorString, 2);
+ $(".modal-header").css("background-color", color);
+}
+
+initModalBackgroundAnnotationColor();
+
+/*
+ * Auxiliary methods
+ */
+function visibleForTeacher(isVisible) {
+ $("#annotation_visible_for_teacher").prop("checked", isVisible).trigger("change");
+}
+
+function postComment(isVisible) {
+ isVisible = posted ? true : isVisible;
+ $("#annotation_post_as_comment").prop("checked", isVisible).trigger("change");
+}
+
+function previewCSS(shouldShowPreview) {
+ if (shouldShowPreview) {
+ updatePreview();
+ $("#annotation-preview-section").show();
+
+ $("#annotation-modal-dialog").removeClass("annotation-dialog-normal");
+ $("#annotation-modal-dialog").addClass("annotation-dialog-expanded");
+
+ $("#annotation-content-section").addClass("annotation-content-spacing");
+ $("#annotation-content-section").removeClass("annotation-content-normal");
+ $("#annotation-content-section").addClass("annotation-content-expanded");
+ }
+ else {
+ $("#annotation-preview-section").hide();
+
+ $("#annotation-modal-dialog").removeClass("annotation-dialog-expanded");
+ $("#annotation-modal-dialog").addClass("annotation-dialog-normal");
+
+ $("#annotation-content-section").removeClass("annotation-content-spacing");
+ $("#annotation-content-section").removeClass("annotation-content-expanded");
+ $("#annotation-content-section").addClass("annotation-content-normal");
+ }
+}
+
+// Change modal title depending on the method that opens the modal
+var editSpan = $("#modal-title-edit-annotation");
+var createSpan = $("#modal-title-create-annotation");
+
+<% if action_name == 'new' %>
+createSpan.show();
+editSpan.hide();
+<% elsif action_name == 'edit' %>
+createSpan.hide();
+editSpan.show();
+<% end %>
+
+/* If this script is rendered by the edit method of the annotation controller:
+ Select correct subcategory (this is not automatically done by the rails form
+ as the content is dynamically rendered). */
+var contentRadio = $("#annotation_category_content");
+if (contentRadio.is(":checked")) {
+ content();
+ submitButton.disabled = false;
+ var subcategory = document.getElementById("annotation_subcategory").textContent.replace(/[^a-z]/g, "");
+ switch (subcategory) {
+ case Subcategory.DEFINITION.name:
+ document.getElementById("content-category-definition").checked = true;
+ break;
+ case Subcategory.ARGUMENT.name:
+ document.getElementById("content-category-argument").checked = true;
+ break;
+ case Subcategory.STRATEGY.name:
+ document.getElementById("content-category-strategy").checked = true;
+ break;
+ }
+}
+
+// render preview
+var annotationComment = document.getElementById("annotation_comment");
+annotationComment.addEventListener("input", function () {
+ updatePreview();
+});
+previewCSS(false); // Initialize modal without preview
+
+// preview toggle listener
+var previewToggle = document.getElementById("preview-toggle");
+previewToggle.addEventListener("change", function () {
+ const shouldShowPreview = $("#preview-toggle-check").is(":checked");
+ previewCSS(shouldShowPreview);
+});
+
+// disable post comment checkbox if annotation was already posted
+if (posted) {
+ postComment(true);
+ $postAsComment.get(0).disabled = true;
+}
+
+initBootstrapPopovers();
+previewCSS(true);
diff --git a/app/views/annotations/update.js.erb b/app/views/annotations/update.js.erb
new file mode 100644
index 000000000..10e43f354
--- /dev/null
+++ b/app/views/annotations/update.js.erb
@@ -0,0 +1,11 @@
+// Update annotations after submitting the annotations form
+thymeAttributes.annotationManager.updateAnnotations(() => {
+ // After creation/update of an annotation,
+ // show the annotation in the annotation area.
+ <% if @annotation %>
+ var newAnnotationId = <%= @annotation.id %>;
+ thymeAttributes.annotationArea.showAnnotationWithId(newAnnotationId);
+ <% end %>
+});
+
+$("#annotation-modal").modal("hide");
diff --git a/app/views/commontator/comments/_body.html.erb b/app/views/commontator/comments/_body.html.erb
index 6ec78b6ee..f6e3df238 100644
--- a/app/views/commontator/comments/_body.html.erb
+++ b/app/views/commontator/comments/_body.html.erb
@@ -4,3 +4,14 @@
%>
<%= commontator_simple_format comment.body %>
+
+
+<% annotation = comment.annotation %>
+<% unless annotation.nil? %>
+ <% medium = annotation.medium %>
+ <% timestamp = annotation.timestamp %>
+
+ Timestamp:
+
+ <%= timestamp.hms_colon_string %>
+<% end %>
diff --git a/app/views/layouts/feedback.html.erb b/app/views/layouts/feedback.html.erb
new file mode 100644
index 000000000..9b8a9bf9e
--- /dev/null
+++ b/app/views/layouts/feedback.html.erb
@@ -0,0 +1,9 @@
+
+
+
+ <%= render partial: 'layouts/head' %>
+
+
+ <%= yield %>
+
+
diff --git a/app/views/lectures/edit/_comments.html.erb b/app/views/lectures/edit/_comments.html.erb
index af5a62fc0..cdc6345c5 100644
--- a/app/views/lectures/edit/_comments.html.erb
+++ b/app/views/lectures/edit/_comments.html.erb
@@ -57,8 +57,47 @@
-
- <% end %>
+
+
+
+ <% end %>
+
-
\ No newline at end of file
+
diff --git a/app/views/lectures/edit/_preferences.html.erb b/app/views/lectures/edit/_preferences.html.erb
index 9fb9adfcd..4ace30803 100644
--- a/app/views/lectures/edit/_preferences.html.erb
+++ b/app/views/lectures/edit/_preferences.html.erb
@@ -158,6 +158,55 @@
<% end %>
+
+ <% if lecture.annotations_status == 1 %>
+
+
+ <%= t('admin.lecture.select_helpdesk') %>
+ <%= f.select :lecture_link,
+ options_for_select(Lecture.all.map { |l| [l.title, l.lecture_path] },
+ [@linked_lecture&.title, @linked_lecture&.lecture_path]) %>
+
+
+ <% end %>
diff --git a/app/views/media/_basics.html.erb b/app/views/media/_basics.html.erb
index 075b1a76b..2e6cdd877 100644
--- a/app/views/media/_basics.html.erb
+++ b/app/views/media/_basics.html.erb
@@ -173,6 +173,45 @@
class: 'form-control' %>
<% end %>
+
+ <% if medium.video.present? %>
+
+ <% end %>
+
<%= f.hidden_field :teachable_id, value: medium.teachable_id %>
<%= f.hidden_field :teachable_type, value: medium.teachable_type %>
diff --git a/app/views/media/_video.html.erb b/app/views/media/_video.html.erb
index 7912702d0..ce6ae4e20 100644
--- a/app/views/media/_video.html.erb
+++ b/app/views/media/_video.html.erb
@@ -43,6 +43,9 @@
<%= link_to t('basics.thyme_editor'),
enrich_medium_path(medium),
class: 'btn btn-sm btn-outline-secondary' %>
+ <%= link_to 'Feedback',
+ feedback_medium_path(medium),
+ class: 'btn btn-sm btn-outline-secondary' %>
<%= link_to t('basics.thyme'),
play_medium_path(medium),
class: 'btn btn-outline-secondary btn-sm',
@@ -53,4 +56,4 @@
<%= render partial: 'media/upload_video',
locals: { medium: medium } %>
<%= f.hidden_field :detach_video, value: false %>
-
\ No newline at end of file
+
diff --git a/app/views/media/comments/_comments.html.erb b/app/views/media/comments/_comments.html.erb
index 54f70ab6f..e1d385eac 100644
--- a/app/views/media/comments/_comments.html.erb
+++ b/app/views/media/comments/_comments.html.erb
@@ -18,4 +18,4 @@
style="max-height: 30vh; overflow-y: scroll;">
<%= commontator_thread(medium) %>
-
\ No newline at end of file
+
diff --git a/app/views/media/enrich.html.erb b/app/views/media/enrich.html.erb
index 1ec644eb3..99a5007b8 100644
--- a/app/views/media/enrich.html.erb
+++ b/app/views/media/enrich.html.erb
@@ -23,7 +23,7 @@
0:00:00
-
+
diff --git a/config/application.rb b/config/application.rb
index b5e4e0f1e..85776e7ff 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -11,6 +11,19 @@ class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults(7.0)
config.autoloader = :zeitwerk
+
+ # Autoload lib extensions path (ignore all other subdirectories of lib/)
+ lib_path = Rails.root.join("lib")
+ config.autoload_paths << lib_path
+ config.eager_load_paths << lib_path
+ Rails.autoloaders.main.ignore(
+ lib_path.join("assets"),
+ lib_path.join("collectors"),
+ lib_path.join("core_ext"),
+ lib_path.join("scrapers"),
+ lib_path.join("tasks")
+ )
+
config.i18n.default_locale = :de
config.i18n.fallbacks = [:en]
config.i18n.available_locales = [:de, :en]
diff --git a/config/initializers/commontator.rb b/config/initializers/commontator.rb
index 78a775e9d..bc76ad318 100644
--- a/config/initializers/commontator.rb
+++ b/config/initializers/commontator.rb
@@ -314,3 +314,17 @@
# Default: false
config.mentions_enabled = false
end
+
+Rails.application.config.to_prepare do
+ # Load the Commontator extensions only if we are not in the assets:precompile
+ # step where no db connection is available. See the production Dockerfile.
+ db_adapter = ENV.fetch("DB_ADAPTER", nil)
+ if db_adapter == "nulldb"
+ Rails.logger.info("DB_ADAPTER env var is #{db_adapter}. Skipping Commontator extensions.")
+ next
+ end
+
+ if ActiveRecord::Base.connection.table_exists?(:thredded_topics)
+ Commontator::Comment.include(Extensions::Commontator::Comment)
+ end
+end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 13ae67031..75f7e2cc6 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -322,6 +322,90 @@ de:
title: 'Titel'
teacher: 'DozentIn'
editors: 'EditorInnen'
+ annotation:
+ annotations: 'Annotationen'
+ annotation_modal_head_create_annotation: 'Annotation anlegen'
+ annotation_modal_head_edit_annotation: 'Annotation bearbeiten'
+ inherit_from_lecture: 'von Vorlesung erben'
+ time: 'Zeit:'
+ comment: 'Kommentar'
+ color: 'Farbe'
+ category: 'Kategorie'
+ category_tooltip: >
+ Verwende "Notiz" für persönliche Notizen, "Inhalt" wenn Du den Inhalt
+ des Videos an dieser Stelle nicht verstehst, "Fehler" wenn Du einen
+ Fehler entdeckt hast, und "Darstellung" wenn Dir die Darstellung im
+ Video Probleme bereitet, z.B. falls Du die Schrift nicht lesen kannst
+ oder der Ton unverständlich ist.
+ note: 'Notiz'
+ note_tooltip: >
+ Wähle diese Kategorie aus, wenn Du Deine Annotation als
+ persönliche Notiz anlegen möchtest.
+ content: 'Inhalt'
+ content_tooltip: >
+ Wähle diese Kategorie aus, wenn Du den Inhalt des Videos an dieser
+ Stelle nicht verstehst.
+ mistake: 'Fehler'
+ mistake_tooltip: >
+ Wähle diese Kategorie aus, wenn Du der Meinung bist, an dieser
+ Stelle im Video wurde ein inhaltlicher Fehler gemacht.
+ presentation: 'Darstellung'
+ presentation_tooltip: >
+ Wähle diese Kategorie aus, wenn Dir die Darstellung im Video
+ an dieser Stelle Probleme bereitet, z.B. falls Du die Schrift
+ nicht lesen kannst oder falls der Ton unverständlich ist.
+ whats_the_problem: >
+ Wo liegt das Problem?
+ definition: 'Definition'
+ definition_tooltip: >
+ Wähle diese Unterkategorie aus, wenn Du Probleme mit einer Definition hast.
+ argument: 'Argument'
+ argument_tooltip: >
+ Wähle diese Unterkategorie aus, wenn Du Probleme mit einem Argument
+ (z.B. in einem Beweis) hast.
+ strategy: 'Beweisstrategie'
+ strategy_tooltip: >
+ Wähle diese Unterkategorie aus, wenn Du Probleme mit der allgemeinen
+ (Beweis-)Strategie hast.
+ previous_annotation: 'vorherige Annotation'
+ annotation_position: 'zur Annotation springen'
+ edit_annotation: 'Annotation editieren'
+ close_annotation_area: 'Feld schließen'
+ next_annotation: 'nächste Annotation'
+ further_help: 'Du brauchst weitere Hilfe? Dann klicke auf:'
+ warning_message:
+ publishing: >
+ Jeder, der diese Vorlesung abonniert hat, wird Deinen Kommentar
+ lesen können, sobald Du auf "Speichern" klickst.
+ Falls Du das nicht möchtest, entferne bitte wieder den Haken
+ bei "Als Kommentar veröffentlichen?".
+ mistake: >
+ Bevor Du diesen Kommentar abschickst, überprüfe außerdem bitte,
+ ob bereits ein Kommentar zu dem von Dir gefundenen Fehler existiert.
+ one_close_annotation: >
+ Beachte: in der Nähe Deiner Annotation gibt es bereits eine veröffentlichte
+ Fehler-Annotation.
+ multiple_close_annotations_1: "Beachte: in der Nähe Deiner Annotation gibt es bereits "
+ multiple_close_annotations_2: " veröffentlichte Fehler-Annotationen."
+ permission: "Dir fehlen die Rechte, um diese Annotation zu bearbeiten."
+ double_posted: >
+ Es wurde bereits eine Annotation mit dem gleichen Inhalt zu diesem Medium
+ veröffentlicht.
+ sure_to_delete: >
+ Diese Annotation wirklich löschen?
+ visible_for_teacher: 'Für DozentIn sichtbar?'
+ visible_for_teacher_helpdesk: >
+ Falls diese Checkbox ausgewählt ist, kann Dein(e) Dozent(in) diese Annotation
+ in ihrem/seinem Thyme-Player (ohne Deinen Namen) sehen.
+ post_as_comment: 'Als Kommentar veröffentlichen?'
+ post_as_comment_helpdesk: >
+ Falls diese Checkbox ausgewählt ist, wird diese Annotation automatisch
+ als Kommentar zu diesem Video veröffentlicht.
+ toggle:
+ note: "Notiz-Annotationen ein-/ausblenden"
+ content: "Inhalt-Annotationen ein-/ausblenden"
+ mistake: "Fehler-Annotationen ein-/ausblenden"
+ presentation: "Darstellung-Annotationen ein-/ausblenden"
announcement:
help: >
Hier kannst Du den Text eingeben. Du kannst LaTeX benutzen
@@ -371,6 +455,16 @@ de:
no_imported_media: 'Es sind keine importierten Medien vorhanden.'
comments_disabled: >
Kommentare für neu veröffentlichte Medien sind standardmäßig deaktiviert
+ enable_annotation_button: 'Dürfen Studierende Annotationen für Sie sichtbar machen?'
+ enable_annotation_button_helpdesk: >
+ Wenn Sie "ja" anwählen, dann haben Studierende die Möglichkeit,
+ ihre Annotationen für den/die Dozent*in bzw. alle Editor*innen
+ der Vorlesung sichtbar machen. Sie können diese dann entweder
+ im normalen Thyme-Player oder im Thyme-Feedback-Player einsehen
+ und dadurch Rückmeldung zu Ihren Vorlesungsvideos erhalten.
+ enable_annotation_button_inherit_helpdesk: >
+ Ist die Option "von Vorlesung erben" ausgewählt, dann wird die
+ globale Option aus den Vorlesungseinstellungen (Kommentare) übernommen.
new_notifications: 'neue Benachrichtigungen'
new_media: 'neue Medien'
new_posts: 'neue Forenbeiträge'
@@ -620,6 +714,12 @@ de:
script_based: >
unter Verwendung eines Veranstaltungsskriptes, das mit dem MaMpf
LaTeX-Paket erstellt wurde
+ emergency_link: 'Link für den Emergency-Button'
+ emergency_link_no_link: 'kein Link'
+ emergency_link_lecture_link: 'Vorlesungslink'
+ emergency_link_direct_link: 'direkter Link'
+ enter_emergency_link: 'Emergency-Link eingeben:'
+ select_helpdesk: 'Helpdesk auswählen:'
no_chapters: 'Es sind noch keine Kapitel vorhanden.'
no_talks: Es sind noch keine Vorträge vorhanden.
orphaned_lessons: 'Verwaiste Sitzungen'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0f738f912..b7a7ca624 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -323,6 +323,87 @@ en:
title: 'Title'
teacher: 'Teacher'
editors: 'Editors'
+ annotation:
+ annotations: 'Annotations'
+ annotation_modal_head_create_annotation: 'Create annotation'
+ annotation_modal_head_edit_annotation: 'Edit annotation'
+ inherit_from_lecture: 'inherit from lecture'
+ time: 'Time:'
+ comment: 'Comment'
+ color: 'Color'
+ category: 'Category'
+ category_tooltip: >
+ Use "note" for personal notes, "content" if you don't understand the
+ content of the video at this position, "mistake" if you've spot a mistake,
+ and "presentation" if you have problems with the presentation,
+ e.g. you cannot read the handwriting or cannot understand the audio.
+ note: 'note'
+ note_tooltip: >
+ Select this category if you want to create your annotation
+ as a personal note.
+ content: 'content'
+ content_tooltip: >
+ Select this category if you don't understand the content
+ of the video at this position.
+ mistake: 'mistake'
+ mistake_tooltip: >
+ Select this category if you think there has been made a mistake
+ in the content of the video at this position.
+ presentation: 'presentation'
+ presentation_tooltip: >
+ Select this category if you have problems with the presentation
+ in the video at this position, e.g. if you cannot read the
+ handwriting or if you cannot understand the audio.
+ whats_the_problem: >
+ What's the problem?
+ definition: 'definition'
+ definition_tooltip: >
+ Select this subcategory if you have problems with a definition.
+ argument: 'argument'
+ argument_tooltip: >
+ Select this subcategory if you have problems with an argument
+ (e.g. in a proof).
+ strategy: 'proof strategy'
+ strategy_tooltip: >
+ Select this subcategory if you have problems with the general
+ (proof) strategy.
+ previous_annotation: 'previous annotation'
+ annotation_position: 'go to annotation'
+ edit_annotation: 'edit annotation'
+ close_annotation_area: 'close area'
+ next_annotation: 'next annotation'
+ further_help: 'You need further help? Then click on:'
+ warning_message:
+ publishing: >
+ Everyone who subscribed this lecture will be able to read
+ your comment once you click "Save".
+ If this is not what you intended to do,
+ please unselect "Post as comment?".
+ mistake: >
+ Before submitting, please also check, if someone already posted
+ a comment concerning this mistake.
+ one_close_annotation: >
+ Note there is already a mistake annotation around this timestamp.
+ multiple_close_annotations_1: "Note there are already "
+ multiple_close_annotations_2: " mistake annotations close to yours."
+ permission: "You don't have the permission to edit this annotation."
+ double_posted: >
+ An annotation with the same content has already been published.
+ sure_to_delete: >
+ Really delete this annotation?
+ visible_for_teacher: 'Visible for teacher?'
+ visible_for_teacher_helpdesk: >
+ If this checkbox is selected your teacher can see this annotation
+ (without your name!) in their own thyme player.
+ post_as_comment: 'Post as comment?'
+ post_as_comment_helpdesk: >
+ If this checkbox is selected, this annotation will be published as
+ comment for this video.
+ toggle:
+ note: "toggle note annotations"
+ content: "toggle content annotations"
+ mistake: "toggle mistake annotations"
+ presentation: "toggle presentation annotations"
announcement:
help: >
Here you can enter a text. You may use LaTeX (by putting the
@@ -372,6 +453,16 @@ en:
close_comments: 'Close all threads for related media'
comments_disabled: >
Comments for newly published media are disabled by default
+ enable_annotation_button: 'Are students allowed to make annotations visible for you?'
+ enable_annotation_button_helpdesk: >
+ If you select "yes", students have the possibility to make
+ their annotations visible for the teacher resp. all editors
+ of the lecture. You can then see them either in the normal
+ thyme player or in the thyme feedback player to get feedback
+ for your lecture videos.
+ enable_annotation_button_inherit_helpdesk: >
+ If the option "inherit from lecture" is selected, the global
+ option of the lecture preferences (comments) is taken.
new_notifications: 'new notifications'
new_media: 'new media'
new_posts: 'new forum posts'
@@ -590,6 +681,12 @@ en:
content_mode: 'Content determination'
video_based: 'media based'
script_based: 'using a manuscript generated by the MaMpf LaTeX package'
+ emergency_link: 'Link for the emergency button'
+ emergency_link_no_link: 'no link'
+ emergency_link_lecture_link: 'lecture link'
+ emergency_link_direct_link: 'direct link'
+ enter_emergency_link: 'Enter emergency link:'
+ select_helpdesk: 'Select helpdesk:'
no_chapters: 'There are no chapters yet.'
no_talks: 'There are no talks yet.'
orphaned_lessons: 'Orphaned Sessions'
diff --git a/config/routes.rb b/config/routes.rb
index 470168970..cb0b1c763 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -38,6 +38,18 @@
to: "administration#classification",
as: "classification"
+ # annotation routes
+
+ get "annotations/update_annotations",
+ to: "annotations#update_annotations",
+ as: "update_annotations"
+
+ get "annotations/num_nearby_posted_mistake_annotations",
+ to: "annotations#num_nearby_posted_mistake_annotations",
+ as: "num_nearby_posted_mistake_annotations"
+
+ resources :annotations, only: [:new, :create, :edit, :update, :destroy]
+
# announcements routes
post "announcements/:id/propagate",
@@ -283,6 +295,10 @@
to: "media#inspect",
as: "inspect_medium"
+ get "media/:id/feedback",
+ to: "media#feedback",
+ as: "feedback_medium"
+
get "media/:id/enrich",
to: "media#enrich",
as: "enrich_medium"
@@ -393,6 +409,10 @@
to: "media#fill_reassign_modal",
as: "fill_reassign_modal"
+ get "media/:id/check_annotation_visibility",
+ to: "media#check_annotation_visibility",
+ as: "check_annotation_visibility"
+
resources :media
# notifications controller
diff --git a/db/migrate/20240329230000_create_annotations.rb b/db/migrate/20240329230000_create_annotations.rb
new file mode 100644
index 000000000..af851eb71
--- /dev/null
+++ b/db/migrate/20240329230000_create_annotations.rb
@@ -0,0 +1,17 @@
+class CreateAnnotations < ActiveRecord::Migration[7.0]
+ def change
+ create_table :annotations do |t|
+ t.references :medium, null: false, foreign_key: true
+ t.references :user, null: false, foreign_key: true
+ t.text :timestamp, null: false
+ t.text :comment
+ t.string :color, null: false
+ t.integer :category, null: false
+ t.integer :subcategory
+ t.boolean :visible_for_teacher, default: false, null: false
+ t.integer :public_comment_id
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240329230010_add_annotation_related_fields.rb b/db/migrate/20240329230010_add_annotation_related_fields.rb
new file mode 100644
index 000000000..ac846bdc0
--- /dev/null
+++ b/db/migrate/20240329230010_add_annotation_related_fields.rb
@@ -0,0 +1,15 @@
+class AddAnnotationRelatedFields < ActiveRecord::Migration[7.0]
+ def change
+ # Annotations status
+ # Media inherits annotation status from lecture by default
+ add_column :media, :annotations_status, :integer, default: -1, null: false
+ # Lecture: activate "share annotation with lecturer" feature by default
+ add_column :lectures, :annotations_status, :integer, default: 1, null: false
+
+ # Emergency Link
+ change_table :lectures, bulk: true do |t|
+ t.integer :emergency_link_status, default: 0, null: false
+ t.text :emergency_link
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d17bf4c82..70e865353 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,11 +10,27 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2024_03_19_130000) do
+ActiveRecord::Schema[7.0].define(version: 2024_03_29_230010) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
+ create_table "annotations", force: :cascade do |t|
+ t.bigint "medium_id", null: false
+ t.bigint "user_id", null: false
+ t.text "timestamp", null: false
+ t.text "comment"
+ t.string "color", null: false
+ t.integer "category", null: false
+ t.integer "subcategory"
+ t.boolean "visible_for_teacher", default: false, null: false
+ t.integer "public_comment_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["medium_id"], name: "index_annotations_on_medium_id"
+ t.index ["user_id"], name: "index_annotations_on_user_id"
+ end
+
create_table "announcements", force: :cascade do |t|
t.bigint "lecture_id"
t.bigint "announcer_id"
@@ -282,6 +298,9 @@
t.integer "submission_max_team_size"
t.integer "submission_grace_period", default: 15
t.boolean "legacy_seminar", default: false
+ t.integer "annotations_status", default: 1, null: false
+ t.integer "emergency_link_status", default: 0, null: false
+ t.text "emergency_link"
t.index ["teacher_id"], name: "index_lectures_on_teacher_id"
t.index ["term_id"], name: "index_lectures_on_term_id"
end
@@ -361,6 +380,7 @@
t.text "publisher"
t.datetime "file_last_edited", precision: nil
t.text "external_link_description"
+ t.integer "annotations_status", default: -1, null: false
t.index ["quizzable_type", "quizzable_id"], name: "index_media_on_quizzable_type_and_quizzable_id"
t.index ["teachable_type", "teachable_id"], name: "index_media_on_teachable_type_and_teachable_id"
end
@@ -413,7 +433,7 @@
t.index ["subject_id"], name: "index_programs_on_subject_id"
end
- create_table "quiz_certificates", id: :uuid, default: -> { "public.gen_random_uuid()" }, force: :cascade do |t|
+ create_table "quiz_certificates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.bigint "quiz_id", null: false
t.bigint "user_id"
t.text "code"
@@ -499,7 +519,7 @@
t.datetime "updated_at", null: false
end
- create_table "submissions", id: :uuid, default: -> { "public.gen_random_uuid()" }, force: :cascade do |t|
+ create_table "submissions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.bigint "tutorial_id", null: false
t.bigint "assignment_id", null: false
t.text "token"
@@ -913,6 +933,8 @@
t.index ["watchlist_entry_id"], name: "index_watchlists_on_watchlist_entry_id"
end
+ add_foreign_key "annotations", "media"
+ add_foreign_key "annotations", "users"
add_foreign_key "announcements", "lectures"
add_foreign_key "announcements", "users", column: "announcer_id"
add_foreign_key "assignments", "lectures"
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 38122f670..0d2ada414 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -33,6 +33,59 @@ const customGlobals = {
// Common global methods
initBootstrapPopovers: "readable",
+
+ // Thyme & Annotation tool globals
+ // TODO: This is a "hack" right now to get rid of "xy is not defined" error
+ // messages in ESLint.
+ // In an ideal world, we would use the new ES6 module syntax, but that is a
+ // bigger undertaking as we have to get rid of rails webpacker and use
+ // webpack itself or even better try to use the new import maps.
+ // See the links in this issue: https://github.com/MaMpf-HD/mampf/issues/454
+ thyme: "readable",
+ video: "readable",
+ thymeAttributes: "readable",
+ thymeKeyShortcuts: "readable",
+ thymeUtility: "readable",
+ Resizer: "readable",
+
+ ControlBarHider: "readable",
+
+ ChapterManager: "readable",
+ DisplayManager: "readable",
+ MetadataManager: "readable",
+
+ Component: "readable",
+ Category: "readable",
+ CategoryEnum: "readable",
+ Subcategory: "readable",
+ VolumeBar: "readable",
+ TimeButton: "readable",
+ MuteButton: "readable",
+ PlayButton: "readable",
+ SeekBar: "readable",
+ FullScreenButton: "readable",
+ NextChapterButton: "readable",
+ PreviousChapterButton: "readable",
+ SpeedSelector: "readable",
+ AddItemButton: "readable",
+ AddReferenceButton: "readable",
+ AddScreenshotButton: "readable",
+ IaBackButton: "readable",
+ IaButton: "readable",
+ IaCloseButton: "readable",
+
+ seekBar: "writable",
+
+ Annotation: "readable",
+ AnnotationManager: "readable",
+ AnnotationArea: "readable",
+ AnnotationsToggle: "readable",
+ AnnotationCategoryToggle: "readable",
+ AnnotationButton: "readable",
+ Heatmap: "readable",
+
+ // KaTeX
+ renderMathInElement: "readable",
};
// We don't have cypress linting yet, as the Cypress ESLint plugin
diff --git a/lib/extensions/commontator/comment.rb b/lib/extensions/commontator/comment.rb
new file mode 100644
index 000000000..3da558ce4
--- /dev/null
+++ b/lib/extensions/commontator/comment.rb
@@ -0,0 +1,15 @@
+module Extensions
+ module Commontator
+ module Comment
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :annotation, foreign_key: :public_comment_id
+
+ def medium
+ thread.commontable
+ end
+ end
+ end
+ end
+end