menu.MenuItem = function MenuItem(label, callback) { var self = utils.create(menu.MenuItem); callback = callback || function() {}; self.text = ko.observable(label); self.callback = ko.observable(callback); self.hovered = utils.flag(false); return self; }
var Lesson = function(slug, data) { var self = this; self.hash = ko.observable(data.hash); self.backendhash = ko.observable(self.hash()); self.is_out_of_sync = ko.computed(function() { return self.backendhash() != self.hash(); }); data = data.lesson; // HACK // XXX for now assume the media is always a video source .. self.video_source = ko.observable(data.media); // XXX should we make this a constant?! self.title = ko.observable(data.title); self.video_element = utils.constant(null); self.video_time = ko.observable(null); self.video_paused = ko.observable(true); self.player = null; self.furigana_visible = utils.flag(true); self.video_visible = utils.flag(true); self.video_initialized = utils.wait_for_init(self.video_element).then(function() { var element = self.video_element(); self.player = new video.Player(element); // keep our time in sync with player time self.player.time.subscribe(function(time) { self.video_time(time); // XXX do we even need this as a separate observable?! }); var sync_paused = function(paused) { self.video_paused(paused); } self.player.paused.subscribe(sync_paused); sync_paused(self.player.paused()); console.log("Video Player initialized"); return true; }); var player_peek = function(start, end, reset) { self.player.show_controls(false); self.follow_video_blockers.push(1); video.player_play_segment(self.player, start, end, reset).then(function(){ console.log("Segment peek done!"); self.player.show_controls(true); self.follow_video_blockers.pop(); }).catch(function(error) { console.log("Segment peek interrupted!", error); self.player.show_controls(true); self.follow_video_blockers.pop(); }); } var peek_duration = 0.8; // see what starts here self.video_peek = function() { var start = self.video_time(); var end = start + peek_duration; var reset = start; player_peek(start, end, reset); } // see what ends here self.video_back_peek = function() { var end = self.video_time(); var start = end - peek_duration; var reset = end; player_peek(start, end, reset); } self.play_segment = function(start, end) { var reset = start; return video.player_play_segment(self.player, start, end, reset).then(function(){ console.log("Segment play done!"); }).catch(function(error) { console.log("Segment play interrupted!", error); throw error; }); } self.forward_smaller = function() { self.player.forward(0.1); } self.forward_small = function() { self.player.forward(0.5); } self.backward_small = function() { self.player.backward(0.5) } self.backward_smaller = function() { self.player.backward(0.1) } var lesson = self; /** json fields: time: time stamp, e.g. 11:23.2 text: string notes: string (optional) */ var Section = function(data) { // ctor var self = this; self.time = ko.observable(parse_ts(data.time)); self.text = ko.observable(data.text); make_parsable(self.text); self.notes = ko.observable(data.notes || ""); make_parsable(self.notes); self.element = utils.constant(null); self.lesson = lesson; // for templates (views) // when a user clicks section, seek video to its time self.click = function() { lesson.jump_to_section(self); } self.use_video_time = function() { self.time(lesson.video_time()); } self.jump_video_to_start = function() { lesson.player.seek(self.time()); } self.play_section_only = function() { var start = self.time(); var end; var next = lesson.find_next_section(self); if(next) { end = next.time(); } else { end = lesson.player.duration() - 0.1; // hack because reaching the end looks like a pause to the player } // Prevent selecting next section at the end by turning off video following. lesson.follow_video_blockers.push(1); lesson.play_segment(start, end).then(function(){ console.log("Section play done!"); lesson.follow_video_blockers.pop(); }).catch(function(error) { console.log("Section play interrupted!", error); lesson.follow_video_blockers.pop(); }); } self.insert_section_at_player_time = function() { var time = lesson.player.time(); lesson.insert_new_section(time); } var dialog = require("plugins/dialog"); self.delete_section_confirmation = function() { var section = self; var Dialog = function LineDeleteConfirmationDialog() { var self = utils.create(LineDeleteConfirmationDialog); self.viewUrl = "section_delete_confirmation.html"; self.confirm = function() { dialog.close(self, true); } self.cancel = function() { dialog.close(self, false); } return self; } var confirmation_dialog = Dialog(); dialog.show(confirmation_dialog).then(function(yes) { if(yes) { lesson.sections_list.remove(self); lesson.update_current_section(); var undo_fn = function() { lesson.sections_list.insert(self); } // TODO: popup a notification with an undo link that runs the given callback! } else { // nothing } }); } // Section.export_data self.export_data = function() { var out = {}; out.time = as_ts(self.time()); out.text = self.text(); if(self.notes()) { out.notes = self.notes(); } return out; } } self.sections_list = ko.observableArray(utils.ctor_map(data.text_segments, Section)); self.sections = ko.computed(function() { return u.sortBy(self.sections_list(), function(s) { return s.time() }); }); // find the last section whose time is <= video_time self.find_video_section = function() { var video_time = self.video_time(); return u.findLast(self.sections(), function(section) { return section.time() <= video_time; }); }; self.current_section = ko.observable(null).extend({ notify_strict: true }); // we wish to give other components the ability to temporarily block following video // so we must first build a list of video blockers that can be removed! self.follow_video_blockers = ko.observableArray(); self.following_video = ko.computed(function() { return (self.follow_video_blockers().length === 0 && !self.video_paused()) || !self.current_section(); }); self.update_current_section = function() { self.current_section(self.find_video_section()); } ko.computed(function() { if(self.following_video()) { self.update_current_section(); } }); self.current_section_element = ko.computed(function() { var s = self.current_section(); if(!s) { return null; } return s.element(); }); self.auto_scroll = utils.flag(true); var get_scrolling_element = function() { var original = window.scrollY; var test_target = 100; if(test_target == original) { test_target = 50; } var element = null; // do a test scroll window.scrollTo(0, 100); if(document.body.scrollTop == test_target) { element = document.body; } if(document.documentElement.scrollTop == test_target) { element = document.documentElement; } window.scrollTo(0, original); // restore return element; }; // auto scroll! self.current_section_element.subscribe(function(element) { if(!element) { return; } if(self.auto_scroll.is_off()) { return; } function scroll_by(shift) { var cont = get_scrolling_element(); if(!cont) { return; } var target = cont.scrollTop + shift; var duration = Math.abs(shift) * 3; // 3 seconds per 1000 pixels duration = Math.min(duration, 600); utils.smooth_scroll_to(cont, target, duration).then(function() { console.log("Scrolling done"); }).catch(function(e){ console.log("Scrolling aborted:", e); }); } var rect = element.getBoundingClientRect(); var offset_to_bottom = window.innerHeight - rect.bottom; var offset_to_top = rect.top; // console.log("offset to bottom:", offset_to_bottom); // console.log("offset to top:", offset_to_top); var top_threshold = 40; // topbar, etc // enforce some minimum bottom offset var bottom_threshold = 80; // if we're too low, bring it to almost near the top var target_bottom_offset = Math.round(window.innerHeight * 0.6); // the value we want for the bottom offset var target_top_offset = 80; var top_shift = offset_to_top - target_top_offset; var bottom_shift = target_bottom_offset - offset_to_bottom; bottom_shift = Math.max(bottom_shift, 400); // don't scroll up *too* much if(offset_to_bottom < bottom_threshold) { // we want to use bottom_shift, but make sure not to make the // top value too small! var shift = Math.min(bottom_shift, top_shift); scroll_by(shift); } if(offset_to_top < top_threshold) { scroll_by(top_shift); } }); // debug if(false) { utils.invoke(function() { // IIFE var previous_section = self.current_section(); self.current_section.subscribe(function(section) { // console.log("New section .. are they equal?", section === previous_section); previous_section = section; }); }); } // find section after given one self.find_next_section = function(section) { if(!section) { return null; } var index = u.findIndex(self.sections(), section); if(index == -1) { return null; }; if( (index + 1) < self.sections().length ) { return self.sections()[index + 1]; } else { return null; } } // find section preceeding given one self.find_prev_section = function(section) { if(!section) { return null; } var index = u.findIndex(self.sections(), section); if(index == -1) { return null }; if(index > 0) { return self.sections()[index - 1]; } else { return null; } } self.jump_to_section = function(section) { if(utils.is_initialized(self.video_element)) { self.current_section(section); self.player.seek(section.time()); } } self.next_section = ko.computed(function() { return self.find_next_section(self.current_section()); }); self.prev_section = ko.computed(function() { return self.find_prev_section(self.current_section()); }); self.use_next_section = function() { var section = self.next_section(); if(section) { self.jump_to_section(section); } } self.use_prev_section = function() { var section = self.prev_section(); if(section) { self.jump_to_section(section); } } self.insert_section_at_player_time = function() { var time = self.player.time(); self.insert_new_section(time); } self.insert_new_section = function(time) { // find the index where must insert this new section var new_section = new Section({time: as_ts(time), text: "", notes: ""}); self.sections_list.push(new_section); self.jump_to_section(new_section); self.note_edit_mode.turn_on(); } self.note_edit_mode = utils.flag(false); /* // when the current section changes, turn off edit mode! self.current_section.subscribe(function() { self.note_edit_mode.turn_off(); }); */ self.enter_note_edit_mode = function() { self.note_edit_mode.turn_on(); } self.leave_note_edit_mode = function() { self.note_edit_mode.turn_off(); } self.note_edit_mode.subscribe(function(yes) { // XXX assuming the first call will always be a "yes" .. // because we don't want to pop something that someone else pushed!! if(yes) { self.follow_video_blockers.push(1); } else { self.follow_video_blockers.pop(); } }); // Lesson.export_data self.export_data = function() { var out = {}; out.media = data.media; // as-is out.text_language = data.text_language; out.user_language = data.user_language; out.title = self.title(); out.text_segments = u.invoke(self.sections(), 'export_data'); return out; } self.as_json = ko.computed(function() { return JSON.stringify(self.export_data(), null, 4); }); self.saving = ko.observable(false); self.saved = ko.observable(false); self.last_saved = ko.observable(Date.now()); self.saved.subscribe(function(yes) { // everytime we save, listen to changes and unset the flag! if(yes) { // XXX this will glitch if edits were made before the save completed?! // XXX compare against self.last_saved_data() var change = self.as_json.subscribe(function() { self.saved(false); change.dispose(); }); } }); self.saved(true); // start saved self.last_saved_data = ko.observable(null); var _save = function(method, data) { var url = "/api/lesson/" + slug; self.saving(true); self.saved(true); // be optimistic! return req.request(method, url, {}, data).then(function(response) { // update hash self.hash(response.hash); self.backendhash(response.hash); self.saving(false); self.last_saved(Date.now()); self.last_saved_data(data); }).catch(function(error) { self.saving(false); self.saved(false); // our optimism was wrong! throw error.response; }); } self.save = function() { var data = { hash: self.hash(), lesson: self.export_data() } return _save("put", data); } self.create = function() { // like save, put for first time creation: uses POST, and doesn't send a hash var data = { lesson: self.export_data() } return _save("post", data); } self.save_enabled = ko.computed(function() { return !self.saving() && !self.saved(); }); self.save_text = ko.computed(function() { if(self.save_enabled()) { return "Save"; } if(self.saving()) { return "Saving .."; } if(self.saved()) { return "Saved!"; } return "Save"; // defaule }); self.last_hash_check = ko.observable(Date.now()); self.check_hash = function() { if(self.is_out_of_sync()) { return Promise.reject("Already out of sync"); } var url = "/api/lesson_hash/" + slug; return req.get(url).then(function(response) { self.last_hash_check(Date.now()); self.backendhash(response.hash); }); } self.reload = function() { // XXX check for the existence of unsaved edits, and warn user if so! var lesson_url = "/api/lesson/" + slug; return req.get(lesson_url).then(function(data) { // return new Lesson(slug, data); // update the lesson! self.hash(data.hash); self.backendhash(self.hash()); data = data.lesson; // HACK // the section list can be completely rebuilt - nothing special there self.title(data.title); self.sections_list(utils.ctor_map(data.text_segments, Section)); // hack - force current section to be recalculated // this is a hack because it should be more automatic self.current_section(null); // don't update the media - it might f**k around with the player and the media element and all that shit! return true; }); } };