From ac7808f7c6a0269915082985f9d3994843606b36 Mon Sep 17 00:00:00 2001
From: Frodo161 <110454463+Frodo161@users.noreply.github.com>
Date: Sun, 31 Mar 2024 20:12:57 +0200
Subject: [PATCH] Add annotation tool & migrate to JS in Thyme Player (#533)
* Outsource resize and fullscreen functions for thyme players.
* Outsource chapter functionality of the (normal) thyme player.
* Fix some minor errors and clean up.
* Outsource emergency button to separate file.
* Outsource heatmap functionality to separate JavaScript file.
* Make heatmap a variable of thyme feedback.
* Made some auxiliary functions private.
* Outsource functionaly of the annotation toggle and improve behaviour.
* Outsource some parts of the functionality of thyme which checks if the native HTML player is used or not.
* Improve display manager.
* Outscource video reference into thyme_attributes.js.
* Put element field back into the constructors of buttons such that the ids from the HTML files can be different in different players.
* Remove speed selector from thyme feedback player and adjust some CSS attributes in normal and feedback player.
* Outsource speed selector functionality in separate file.
* Rename update_markers into update_annotations (now also in non-JS-files).
* Remove reference on the video control bar in the java script files as it is not used.
* Outsource global annotation function into a new class called "AnnotationManager".
* Restructure annotation area.
* Fix some properties of the annotation-tool.
* Bring toggle-visible-for-teacher-annotations function (fully) to frontend (Java Script).
* Fix controller method to make the last commit work.
* Rename thyme.js into thyme_player.js.
* Clean up.
* Remove annotationSort() from utility.js as this function is now part of annotation_manager.js.
* Remove function annotationIndex() from utility.js as this function is unused by now.
* Outsource the parts of the thyme player scripts which set up the max time label.
* Code clean up and fix error message for editing annotations.
* Remove comment and made the function sortAnnotations() static.
* Next and previous annotation buttons now consider only valid annotations.
* Move attribute activeAnnotationId to the class AnnotationArea (now save the full annotation instead of just the id).
* Optical and structural improvements of code and fix of updating annotations after submitting the annotation form.
* Rename class Button as Component.
* Outsource hide functionality of the control bar.
* Rename class ControlBar as ControlBarHider and restyle some parts of the code.
* Outsource ia button.
* Outsource ia-close button.
* Replace expressions of the form "$('#' + component.id)" by "$(component)".
* Renamed chapters.js into chapter_manager.js and wrap the functionality into a class.
* Remove function iconClass() as it seems to be used nowhere.
* Made max height of the heatmap a static attribute.
* Outsource chapter functionality into chapter manager class. Remove back button (it'll be added again in a few commits when the chapter manager is well-structured).
* Changed fixed chapters id into an attribute of the chapter manager constructor.
* Remove parameter from nextChapterStart() and previousChapterStart() (current video time can be taken from thymeAttributes).
* Remove chapter class as modelling chapters as objects seems to make the situation more complicated than it is (at least for the moment).
* Remove unused variable from chapter manager.
* Outsource metadata functionality.
* Made metadataBefore() and metadataAfter() private.
* Clean up.
* Add back button again.
* Remove unnecessary variables from thyme editor and fix use of thymeUtility function secondsToTime().
* Change var to let or const in the thyme editor script.
* Add missing ; in for-loop and fix variable declaration.
* Make time in the plus/minus (ten) button variable.
* Replace comment.
* Use play, mute and plus/minus x seconds buttons from the new classes.
* Use seek bar from the new class and fix an event listener.
* Use volume bar from the new class.
* Use setUpMaxTime() from thymeUtility and remove unnecessary time update listener.
* Move dataURLtoBlob() to thymeUtility.
* Outsource add item/reference/screenshot button.
* Rename near_mistake_annotations by num_nearby_mistake_annotations and clean up code.
* Replace find_by_id by find_by.
* Clean up medium model.
* Get rid of unused comment.
* Rename id.
* Change id name.
* Capitalized the German words Du, Deine, etc.
* Fix emergency link feature and made "You need further help? ..." invisible if no link was entered by the teacher.
* Split up thyme CSS.
* Remove global CSS which is not needed.
* Remove duplication of the color map.
* Update annotation area when annotations are updated.
* Fix bad behaviour of ia-button, interactive area and annotation area.
* Replace CoffeeScript by JavaScript in app/views/annotations and clean up. Now, all subcategory radios show the emergency link (before it was only definition).
* Add tooltips for the category radio buttons and for the "post as comment"-field.
* Clean up code.
* Add comment.
* Put thyme player scripts in the thyme folder, separate load of thyme related scripts in application.js and add warning comment.
* Fix missing Latex preview in the annotation modal.
* Lower the opacity of previous/next button in the annotation area if the given annotation ist the first/last of the array.
* Correct language.
* Select necessary annotation attributes in backend, not in frontend.
* Improve interaction between annotation and associated comment.
* Set default value of visible_for_teacher to false, whenever the annotation_status doesn't equal 1 (before it was only set to false if the annotation_status equalled -1).
* Fix wrong/missing annotation subtext in the annotation area.
* Remove warning locales from the form html file and put it into the locales html file.
* Make the annotations in the thyme player appear (uniformly) in the same language as the user language.
* Code clean up.
* Replace text "subtext" by enum "subcategory" in the annotation table in the db.
* Add (sub)category class (+ superclass) to remove hardcoded categories in the (non-view) JavaScript.
* Comment update implies annotation update.
* Change label "Enable emergency button?" and add helpdesk.
* Add case "Thymestamp not found" to update_comments.
* Code clean up.
* Code clean up.
* Fix resize.
* link Commontator::Comment and Annotation models
* Delete unwanted .ruby-version file
* Update pdfcomprezzor to version on mampf-next
* Fix resize and IA bugs.
* Remove "video is not null" check from thyme players.
* Remove test for Internet Explorer
* Set default values for annotations_status
* Update annotations without delay
* Replace plus/minus button by time button.
* Add shortcuts for prev/next annotation.
* Merge together multiple annotation-related migrations
* Improve code comment
* Remove renaming of "this" in JS class
* Redesign "post as comment" checkbox
according to Bootstrap here:
https://getbootstrap.com/docs/5.3/forms/checks-radios/#checks
* Restructure cancel/close buttons
* Improve annotation commontator::comment association
* Code clean up
* Code clean-up
* Replace name "emergency button" by "annotation button"
* Add color check
* Fix comment is nil bug
* Code clean-up
* Add non-null constraint for category
* CSS improvement and bugfix
* Correct locales
* Add null constraint to category in original migration
* Rearrange save button
* Quick fix thyme feedback resize.
* Add non-nil constraint
* Add constraint to AnnotationsController
* Improve code style
* Rename variables to proper camelCase
* Improve code style
* Revert time_stamp.rb
* Remove ==/!= null statements
* Add newline after/before function
* Add comment
* Clean up code/fix bug
* Check timestamp in backend
* Add annotations status check + update
* Fix user authorization
* Reduce complexity of small/large display checks
Also removed deprecated device-width, see:
https://stackoverflow.com/a/18500871/
* Implement locking to prevent unnecessary DB calls
The AnnotationManager should handle its internal state on its own.
Therefore, updateMarkers() now checks if annotations are null
and if they are, it calls updateAnnotations() accordingly,
but only if no other method has already called updateMarkers
recently, e.g. when multiple resize events are fired in a small
period of time. Resource is freed in updateAnnotations.
* Rewrite handling of annotation update
* Use gender-neutral pronoun
* Replace alert() by Bootstrap alert
Used for "Post as comment" warnings.
* Use JQuery syntax and lint file
Note we also use .trigger("change") to trigger event listeners
for the checkbox, otherwise they won't be fired.
* Add tooltip (helpdesk) for category
* Make color picker elements round
* Use paddings instead of line breaks
* Don't show warnings if annotation was posted
* Show further help link in new tab
Also shortened current distinction into different categories
as right now it is not needed.
* Fix unwanted line indentation
* Use bootstrap switch for preview toggle
* Use bootstrap switch for annotation toggle
Also removed unwanted string concatenation artifacts
* Fix alignment of preview column & make more responsive
Note that we did not optimize for very small devices as the annotation tool
cannot be opened there. However, for tablets it should work fine.
* Fix case-sensitive color lookup bug
Due to this bug, one could not create annotations
with the last color (almost white).
* Pause video also when editing an annotation
* Update modal background according to annotation color
When we create a new annotation, a random color is chosen.
TODO: Maybe do this random choice already in the backend,
not just in the frontend.
* Fix another non-capitalized color bug
* Add transition to modal header background color
* Add more restrictive color check
* Don't show toggle if it doesn't do anything
* Put radius to backend
* Add cypress rules to ESLint & ignore some patterns
* Allow usage of tempusDominus global variable
* Ignore JS files with Sprocket syntax
* Further improve rules, e.g. allow common globals
* Ignore sprocket syntax in cable.js
* Autofix all `.js` and `.js.erb` files
Command used:
`yarn run eslint --fix .`
Still 47 problems (27 errors, 20 warnings) after this.
* Fix variables in turbolink fix
* Prepend unused variables with "_"
* Get rid of unused widget variable
* Fix specs comment tab alignment
* Bump active record schema version
This was automatically done when running the pending migrations.
* Run ESLint autofixes
* Hack: Add Thyme & Annotation tool globals to ESLint globals
See the comment in .eslintrc.js for more details for why we do this.
* Rename resize to Resizer to avoid name conflicts
* Show correct modal title.
* Fix duplicate comment bug
* Execute Rubocop safe autocorrect.
* Warn about too long GitHub commit messages (#586)
* Fix safe rubocops (manually)
* Fix unsafe autocorrections
* Fix comment status (#585)
* Reapply first fix for Reader/Media
See discussion on #574 for further details.
Previous PR for this was #576, closed in favor of this one
as this directly branches off the new "dev" branch.
* Correctly show latest post (might be current_user's comment)
* Fix update of unread comments logic in comments controller
* Fix update icon logic and latest post comment
* Simplify latest comment logic
* Improve code comments
* Further improve comments
* Fix wording in comment
* Fix construction of media array & use `.blank?` instead of `.empty?`
* Migrate `unread_comments` flag (fix inconsistencies) (#587)
* Add dummy migration
* Implement migration for unread comment flag
* Remove unnecessary comment
* Declare migration as not idempotent
* Use array.length instead of counting
* Throw error to prevent revert of migration
* Fix severe flaws in unread comments migration
* Simplify Reader retrieval
* Use the more explicit `.nil?` method
* Update migration date
* Fix annoying bug: don't use `.select!` but `.select`
* Polish migration
e.g. update comment, more suitable name for the method etc.
* Rename method according to #585
* Use `.instance_of` (fix unwanted merge artifact)
* Clean up annotation migrations
* Fix wrong table changing in migration & update schema
* Improve annotation area css
* Fix scrollable video bug
* Improve icons in annotation area
* Increase button click area
* Change annotation button icon
* Fix spacing between annotation area buttons
* Add title directly on tags & replace close button
* Fix alignment of video control bar
* Redesign annotation markers (use map pin icon)
* Highlight currently active annotation marker
* Reregister hotkeys also after *edit* modal closes
* Improve styling of annotation button
* Add subtle gradient to video control bar
* Show active annotation marker in front
* Fix ESLint warnings
* Delete duplicate line
* Remove unused variables resp. add underscore
* Delete unnecessary `annotations.coffee` file
* Show new annotation directly in AnnotationArea
* Add shortcut to jump to current annotation
* Make annotation button more prominent
- place it closer to the timeline
- color it with more distinct colors to showcase it
* Show preview by default in annotation modal
* Make delete button id more specific
* Add icons to save/delete in annotation modal
* Improve feedback player (CSS, category toggles etc.)
- CSS: extend from main thyme player instead of copying all CSS styles over
- Register correct shortcut to switch between annotations
- Use bootstrap switches instead of custom-made toggles
- Redesign colors for feedback player category toggles
- Simplify AnnotationCategoryToggle component
* Remove syntax error (duplicated line)
* Remove unnecessary console log
* Fix broken key listeners in feedback player
* Improve positioning of heatmap & toggle spikes as well
* Also show heatmap spikes for "mistake" annotations
* Avoid heatmap being cut off at the sides
* Fix video size in feedback player
* Fix small gap in thyme player resize issue
Instead of jQuery width() and height(), we use
window.innerWidth and window.innerHeight.
* Fix wrong resizing when exiting full screen mode
* Disable annotation key listeners when area closed
* Increase width of timeline in feedback player
Also fixed positioning of other elements on the bottom bar.
* Fix keyboard shortcut abbreviation hint
* Update schema & timestamps for annotation DB migrations
* Ignore other lib paths in autoloading
* Do not load Commontator extension in precompiling
* Get rid of "@extend" in SCSS, use CSS classes instead
* Fix amplitude of heatmap if no annotations are present
* Use string interpolation to construct heatmap string
* Don't rescue `NoDatabaseError`
* Fix nulldb error
* Add "sure to delete" warning in annotation modal
* Change annotation status numbering & activate by default
This is the new labeling:
-1: inherit from lecture
0: disabled
1: enabled
Beforehand it was:
-1: disabled
0: inherit from lecture
1: enabled
However, I think it makes more sense to have 0/1 for disabled/enabled
and leave the -1 for the "inherit" use case.
Also, the "share annotation with lecturer" feature is now enabled by
default for all lectures.
* Rename "Thymestamp" to "Timestamp"
* Update annotation db migration timestamps (one last time)
* Use enum for status in emergency link update
* Prepend emergency link with "https://" if necessary
* Remove unwanted change
---------
Co-authored-by: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com>
Co-authored-by: Splines <37160523+Splines@users.noreply.github.com>
---
.vscode/settings.json | 1 +
app/abilities/annotation_ability.rb | 11 +
app/abilities/medium_ability.rb | 4 +-
app/assets/javascripts/application.js | 48 +-
app/assets/javascripts/lectures.coffee | 23 +
app/assets/javascripts/thyme.coffee | 757 ------------------
.../thyme/annotations/annotation.js | 104 +++
.../thyme/annotations/annotation_area.js | 238 ++++++
.../thyme/annotations/annotation_manager.js | 170 ++++
.../javascripts/thyme/annotations/category.js | 23 +
.../thyme/annotations/category_enum.js | 39 +
.../thyme/annotations/subcategory.js | 21 +
app/assets/javascripts/thyme/attributes.js | 64 ++
.../javascripts/thyme/chapter_manager.js | 121 +++
.../thyme/components/add_item_button.js | 23 +
.../thyme/components/add_reference_button.js | 23 +
.../thyme/components/add_screenshot_button.js | 34 +
.../thyme/components/annotation_button.js | 20 +
.../components/annotation_category_toggle.js | 51 ++
.../thyme/components/annotations_toggle.js | 68 ++
.../javascripts/thyme/components/component.js | 17 +
.../thyme/components/full_screen_button.js | 69 ++
.../thyme/components/ia_back_button.js | 39 +
.../javascripts/thyme/components/ia_button.js | 76 ++
.../thyme/components/ia_close_button.js | 18 +
.../thyme/components/mute_button.js | 18 +
.../thyme/components/next_chapter_button.js | 15 +
.../thyme/components/play_button.js | 24 +
.../components/previous_chapter_button.js | 15 +
.../javascripts/thyme/components/seek_bar.js | 70 ++
.../thyme/components/speed_selector.js | 26 +
.../thyme/components/time_button.js | 24 +
.../thyme/components/volume_bar.js | 33 +
.../javascripts/thyme/control_bar_hider.js | 74 ++
.../javascripts/thyme/display_manager.js | 60 ++
app/assets/javascripts/thyme/heatmap.js | 122 +++
app/assets/javascripts/thyme/key_shortcuts.js | 135 ++++
.../javascripts/thyme/metadata_manager.js | 219 +++++
app/assets/javascripts/thyme/resizer.js | 32 +
app/assets/javascripts/thyme/thyme_editor.js | 36 +
.../javascripts/thyme/thyme_feedback.js | 109 +++
app/assets/javascripts/thyme/thyme_player.js | 185 +++++
app/assets/javascripts/thyme/utility.js | 143 ++++
app/assets/javascripts/thyme_editor.coffee | 208 -----
app/assets/stylesheets/annotations.scss | 213 +++++
app/assets/stylesheets/application.scss | 2 +
app/assets/stylesheets/thyme.scss | 289 ++++---
app/assets/stylesheets/thyme_feedback.scss | 56 ++
app/controllers/annotations_controller.rb | 211 +++++
app/controllers/lectures_controller.rb | 35 +-
app/controllers/media_controller.rb | 19 +
app/models/annotation.rb | 49 ++
app/models/lecture.rb | 8 +
app/models/medium.rb | 20 +
.../annotations/_annotation_area.html.erb | 30 +
.../annotations/_annotation_locales.html.erb | 19 +
.../annotations/_annotation_modal.html.erb | 39 +
app/views/annotations/_form.html.erb | 157 ++++
app/views/annotations/_form_content.html.erb | 44 +
.../_form_content_further_help.html.erb | 16 +
.../annotations/duplicate_comment.js.erb | 4 +
app/views/annotations/edit.js.erb | 305 +++++++
app/views/annotations/update.js.erb | 11 +
app/views/commontator/comments/_body.html.erb | 11 +
app/views/layouts/feedback.html.erb | 9 +
app/views/lectures/edit/_comments.html.erb | 45 +-
app/views/lectures/edit/_preferences.html.erb | 49 ++
app/views/media/_basics.html.erb | 39 +
app/views/media/_video.html.erb | 5 +-
app/views/media/comments/_comments.html.erb | 2 +-
app/views/media/enrich.html.erb | 2 +-
app/views/media/feedback.html.erb | 88 ++
app/views/media/play.html.erb | 32 +-
app/views/media/show_comments.html.erb | 2 +-
config/application.rb | 13 +
config/initializers/commontator.rb | 14 +
config/locales/de.yml | 100 +++
config/locales/en.yml | 97 +++
config/routes.rb | 20 +
.../20240329230000_create_annotations.rb | 17 +
...329230010_add_annotation_related_fields.rb | 15 +
db/schema.rb | 28 +-
eslint.config.mjs | 53 ++
lib/extensions/commontator/comment.rb | 15 +
84 files changed, 4677 insertions(+), 1116 deletions(-)
create mode 100644 app/abilities/annotation_ability.rb
delete mode 100644 app/assets/javascripts/thyme.coffee
create mode 100644 app/assets/javascripts/thyme/annotations/annotation.js
create mode 100644 app/assets/javascripts/thyme/annotations/annotation_area.js
create mode 100644 app/assets/javascripts/thyme/annotations/annotation_manager.js
create mode 100644 app/assets/javascripts/thyme/annotations/category.js
create mode 100644 app/assets/javascripts/thyme/annotations/category_enum.js
create mode 100644 app/assets/javascripts/thyme/annotations/subcategory.js
create mode 100644 app/assets/javascripts/thyme/attributes.js
create mode 100644 app/assets/javascripts/thyme/chapter_manager.js
create mode 100644 app/assets/javascripts/thyme/components/add_item_button.js
create mode 100644 app/assets/javascripts/thyme/components/add_reference_button.js
create mode 100644 app/assets/javascripts/thyme/components/add_screenshot_button.js
create mode 100644 app/assets/javascripts/thyme/components/annotation_button.js
create mode 100644 app/assets/javascripts/thyme/components/annotation_category_toggle.js
create mode 100644 app/assets/javascripts/thyme/components/annotations_toggle.js
create mode 100644 app/assets/javascripts/thyme/components/component.js
create mode 100644 app/assets/javascripts/thyme/components/full_screen_button.js
create mode 100644 app/assets/javascripts/thyme/components/ia_back_button.js
create mode 100644 app/assets/javascripts/thyme/components/ia_button.js
create mode 100644 app/assets/javascripts/thyme/components/ia_close_button.js
create mode 100644 app/assets/javascripts/thyme/components/mute_button.js
create mode 100644 app/assets/javascripts/thyme/components/next_chapter_button.js
create mode 100644 app/assets/javascripts/thyme/components/play_button.js
create mode 100644 app/assets/javascripts/thyme/components/previous_chapter_button.js
create mode 100644 app/assets/javascripts/thyme/components/seek_bar.js
create mode 100644 app/assets/javascripts/thyme/components/speed_selector.js
create mode 100644 app/assets/javascripts/thyme/components/time_button.js
create mode 100644 app/assets/javascripts/thyme/components/volume_bar.js
create mode 100644 app/assets/javascripts/thyme/control_bar_hider.js
create mode 100644 app/assets/javascripts/thyme/display_manager.js
create mode 100644 app/assets/javascripts/thyme/heatmap.js
create mode 100644 app/assets/javascripts/thyme/key_shortcuts.js
create mode 100644 app/assets/javascripts/thyme/metadata_manager.js
create mode 100644 app/assets/javascripts/thyme/resizer.js
create mode 100644 app/assets/javascripts/thyme/thyme_editor.js
create mode 100644 app/assets/javascripts/thyme/thyme_feedback.js
create mode 100644 app/assets/javascripts/thyme/thyme_player.js
create mode 100644 app/assets/javascripts/thyme/utility.js
delete mode 100644 app/assets/javascripts/thyme_editor.coffee
create mode 100644 app/assets/stylesheets/annotations.scss
create mode 100644 app/assets/stylesheets/thyme_feedback.scss
create mode 100644 app/controllers/annotations_controller.rb
create mode 100644 app/models/annotation.rb
create mode 100644 app/views/annotations/_annotation_area.html.erb
create mode 100644 app/views/annotations/_annotation_locales.html.erb
create mode 100644 app/views/annotations/_annotation_modal.html.erb
create mode 100644 app/views/annotations/_form.html.erb
create mode 100644 app/views/annotations/_form_content.html.erb
create mode 100644 app/views/annotations/_form_content_further_help.html.erb
create mode 100644 app/views/annotations/duplicate_comment.js.erb
create mode 100644 app/views/annotations/edit.js.erb
create mode 100644 app/views/annotations/update.js.erb
create mode 100644 app/views/layouts/feedback.html.erb
create mode 100644 app/views/media/feedback.html.erb
create mode 100644 db/migrate/20240329230000_create_annotations.rb
create mode 100644 db/migrate/20240329230010_add_annotation_related_fields.rb
create mode 100644 lib/extensions/commontator/comment.rb
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