├── .browserslistrc ├── instrucciones.md ├── public ├── favicon.ico └── index.html ├── src ├── assets │ └── logo.png ├── App.vue ├── speak.js ├── store.js ├── main.js ├── views │ ├── Settings.vue │ ├── Help.vue │ └── WorkView.vue └── utils.js ├── babel.config.js ├── zmovidesc.code-workspace ├── vue.config.js ├── .gitignore ├── .eslintrc.js ├── README.md └── package.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /instrucciones.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/moviDescGenerator/main/instrucciones.md -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/moviDescGenerator/main/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/moviDescGenerator/main/src/assets/logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /zmovidesc.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.NODE_ENV === 'production' 3 | ? '/moviDescGenerator/' 4 | : "", 5 | 6 | lintOnSave: false, 7 | configureWebpack: { 8 | devtool: 'source-map' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | testingFiles 4 | /dist 5 | 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended' 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint' 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # movidesc 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movidescGenerator", 3 | "homepage": "https://github.com/davidacm/moviDescGenerator", 4 | "version": "0.1.0", 5 | "private": true, 6 | "scripts": { 7 | "serve": "vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "lint": "vue-cli-service lint", 10 | "deploy": "node ./node_modules/vue-gh-pages/index.js -b gh-pages", 11 | "build-report": "vue-cli-service build --report" 12 | }, 13 | "dependencies": { 14 | "bootstrap": "^5.1.3", 15 | "bootstrap-vue": "^2.21.2", 16 | "core-js": "^3.6.5", 17 | "vue": "^2.6.14", 18 | "vue-plyr": "^7.0.0", 19 | "vuex": "^3.4.0" 20 | }, 21 | "devDependencies": { 22 | "@vue/cli-plugin-babel": "~4.5.0", 23 | "@vue/cli-plugin-eslint": "~4.5.0", 24 | "@vue/cli-plugin-vuex": "~4.5.0", 25 | "@vue/cli-service": "~4.5.0", 26 | "babel-eslint": "^10.1.0", 27 | "eslint": "^6.7.2", 28 | "eslint-plugin-vue": "^6.2.2", 29 | "vue-gh-pages": "^1.19.1", 30 | "vue-template-compiler": "^2.6.11" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/speak.js: -------------------------------------------------------------------------------- 1 | var synth = window.speechSynthesis; 2 | 3 | export const isSynthAvailable = synth? true: false; 4 | 5 | export const voiceData = { 6 | default: undefined, 7 | voices: {} 8 | }; 9 | 10 | // callback to ve notified when voices where loaded. 11 | export function populateVoices(callback) { 12 | if (!synth) return; 13 | let clb = () => { 14 | let v = synth.getVoices(); 15 | let tmp = {} 16 | for (let k of v) { 17 | if (tmp[k.lang] == undefined) tmp[k.lang] = {}; 18 | tmp[k.lang][k.name] = k; 19 | if (k.default) { 20 | voiceData.default = k; 21 | } 22 | } 23 | voiceData.voices = tmp; 24 | if (callback && Object.keys(tmp).length > 0) { 25 | callback(voiceData); 26 | callback = undefined; 27 | } 28 | } 29 | clb(); 30 | if (speechSynthesis.onvoiceschanged !== undefined) speechSynthesis.onvoiceschanged = clb; 31 | } 32 | 33 | populateVoices(); 34 | 35 | export function speak(text, lang, voice, rate=1, pitch=1) { 36 | if (!isSynthAvailable) return; 37 | synth.cancel(); 38 | if (text === '') return; 39 | let utter = new SpeechSynthesisUtterance(text); 40 | utter.onerror = function () { 41 | console.error('SpeechSynthesisUtterance.onerror'); 42 | } 43 | if (lang) utter.lang=lang; 44 | if (lang && voice) utter.voice = voiceData.voices[lang][voice]; 45 | utter.pitch = pitch; 46 | utter.rate = rate; 47 | synth.speak(utter); 48 | } 49 | 50 | export function cancelSpeak() { 51 | if (isSynthAvailable) synth.cancel() 52 | } -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | Vue.use(Vuex); 4 | import { 5 | jLocal, 6 | getItem 7 | } from './utils'; 8 | 9 | var getterHandler = (state) => (field) => { 10 | return state[field]; 11 | } 12 | 13 | const store = new Vuex.Store({ 14 | state: { 15 | useAriaLive: getItem('useAriaLive', false), 16 | useTts: getItem('useTts', false), 17 | vLang: getItem('vLang', null), 18 | vVoice: getItem('vVoice', null), 19 | rate: getItem('rate', 1), 20 | pitch: getItem('pitch', 1), 21 | }, 22 | getters: { 23 | realtimePersistentFields: getterHandler, 24 | commonFields: getterHandler 25 | }, 26 | mutations: { 27 | realtimePersistentFields(state, { 28 | field, 29 | value 30 | }) { 31 | state[field] = value; 32 | jLocal[field] = value; 33 | }, 34 | commonFields(state, { 35 | field, 36 | value 37 | }) { 38 | state[field] = value; 39 | }, 40 | } 41 | }) 42 | export default store; 43 | 44 | export const computedHelper = function (fields, handler, enableWriting = true) { 45 | let obj = {}; 46 | for (let k of fields) { 47 | obj[k] = {}; 48 | obj[k].get = () => { 49 | return store.getters[handler](k); 50 | } 51 | if (enableWriting) { 52 | obj[k].set = v => { 53 | store.commit(handler, { 54 | field: k, 55 | value: v 56 | }); 57 | } 58 | } 59 | } 60 | return obj; 61 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VuePlyr from 'vue-plyr' 3 | Vue.use(VuePlyr, { 4 | plyr: {} 5 | }); 6 | 7 | import 'vue-plyr/dist/vue-plyr.css' 8 | 9 | 10 | 11 | import App from './App.vue'; 12 | import {VueSpeak} from './utils'; 13 | Vue.use(VueSpeak); 14 | 15 | // import { BootstrapVue} from 'bootstrap-vue' 16 | import {BTabs, BTab, BContainer, BRow, BCol, BButton, BDropdown, BDropdownForm, BDropdownItem, BDropdownItemButton, BFormFile, BFormInput, BFormSelect, BFormSelectOption, BFormCheckbox, BFormTextarea, BLink} from 'bootstrap-vue'; 17 | import 'bootstrap/dist/css/bootstrap.css' 18 | import 'bootstrap-vue/dist/bootstrap-vue.css' 19 | // Vue.use(BootstrapVue) 20 | 21 | Vue.component('BContainer', BContainer); 22 | Vue.component('b-tabs', BTabs); 23 | Vue.component('b-tab', BTab); 24 | Vue.component('b-row', BRow); 25 | Vue.component('b-col', BCol); 26 | Vue.component('b-button', BButton); 27 | Vue.component('b-dropdown', BDropdown); 28 | Vue.component('b-dropdown-form', BDropdownForm); 29 | Vue.component('b-dropdown-item', BDropdownItem); 30 | Vue.component('b-dropdown-item-button', BDropdownItemButton); 31 | Vue.component('b-form-file', BFormFile); 32 | Vue.component('b-form-input', BFormInput); 33 | Vue.component('b-form-select', BFormSelect); 34 | Vue.component('b-form-select-option', BFormSelectOption); 35 | Vue.component('b-form-checkbox', BFormCheckbox); 36 | Vue.component('b-form-textarea', BFormTextarea); 37 | Vue.component('b-link', BLink); 38 | // Optionally install the BootstrapVue icon components plugin 39 | // Vue.use(IconsPlugin) 40 | 41 | import store from './store'; 42 | 43 | new Vue({ 44 | store, 45 | render: h => h(App) 46 | }).$mount('#app') 47 | -------------------------------------------------------------------------------- /src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 128 | 129 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import store from './store'; 2 | import { 3 | speak, 4 | cancelSpeak 5 | } from './speak'; 6 | 7 | export const VueSpeak = { 8 | install(Vue) { 9 | Vue.prototype.speak = (text) => { 10 | let s = store.state; 11 | speak(text, s.vLang, s.vVoice, s.rate, s.pitch); 12 | }; 13 | Vue.prototype.cancelSpeak = () => { 14 | cancelSpeak(); 15 | } 16 | } 17 | } 18 | 19 | 20 | const TIME_REG_EXP = /\[*(\d+:)+\d+([.,]+\d+){0,1}/; 21 | 22 | function processCue(t) { 23 | t = t.trim(); 24 | if (!t) return null; 25 | t = t.split("\n"); 26 | let m = { 27 | text: "" 28 | } 29 | for (let k of t) { 30 | if (m.timestamp) m.text += " " + k; 31 | else { 32 | k = k.trim(); 33 | k = k.match(TIME_REG_EXP); 34 | if (!k) continue; 35 | k = k[0].replace("[", ""); 36 | m.timestamp = hourToSeconds(k); 37 | } 38 | } 39 | if (!m.timestamp) return null; 40 | m.text = m.text.substring(1); 41 | return m; 42 | } 43 | 44 | function createTextCue(s, nextTimestamp, sec) { 45 | if (s.timestamp > nextTimestamp) nextTimestamp = s.timestamp + 1; 46 | let start = secondsToHours(s.timestamp); 47 | let end = secondsToHours(nextTimestamp); 48 | let t = ""; 49 | if (sec) t = `${sec}\n`; 50 | t += `${start} --> ${end}\n${s.text}\n\n`; 51 | return t; 52 | } 53 | 54 | export function parseSubs(t) { 55 | t = t.replace(/\r/g, ""); 56 | t = t.split("\n\n"); 57 | let cues = [] 58 | for (let k of t) { 59 | console.log("procesando cue", k); 60 | k = processCue(k); 61 | if (!k) { 62 | continue 63 | console.log("nulo"); 64 | } 65 | cues.push(k); 66 | } 67 | console.log(cues); 68 | return cues; 69 | } 70 | 71 | export function createWebVtt(cues) { 72 | let t = "WEBVTT\n\n"; 73 | let len = cues.length - 1; 74 | if (cues.length < 0) return ""; 75 | if (cues.length == 0) return t + createTextCue(cues[0], cues[0].timestamp + 1); 76 | for (let i = 0; i < len; ++i) { 77 | t += createTextCue(cues[i], cues[i + 1].timestamp - 1); 78 | } 79 | // for the last cue: 80 | t += createTextCue(cues[len], cues[len].timestamp + 1); 81 | return t; 82 | } 83 | 84 | 85 | export function insertAt(array, index, ...elements) { 86 | array.splice(index, 0, ...elements); 87 | } 88 | 89 | export function isBetween(s, prev, next) { 90 | // timestamp must be greater or equals than prev, and less than next. 91 | return s.timestamp >= prev.timestamp && s.timestamp < next.timestamp; 92 | } 93 | 94 | export function hourToSeconds(t) { 95 | t = t.trim().replace(",", ".").split(":").reverse(); 96 | let segs = 0; 97 | for (let i = 0; i < t.length; ++i) { 98 | switch (i) { 99 | case 0: 100 | segs = parseFloat(t[i]); 101 | break; 102 | case 1: 103 | segs += parseFloat(t[i]) * 60; 104 | break; 105 | case 2: 106 | segs += parseFloat(t[i]) * 60 * 60; 107 | break; 108 | } 109 | } 110 | return segs; 111 | } 112 | 113 | export function secondsToHours(t) { 114 | let d = new Date(t * 1000); 115 | return d.toISOString().substr(11, 12); 116 | } 117 | 118 | 119 | /** 120 | * get the nearest mark just before of the given timestamp from the specified array. 121 | * the exception is when the timestamp is less than the first mark, then the first element is returned. 122 | * @param {*} arr a list of marks, each element must contains the timestamp (number) atribute 123 | * @param {*} t the timestamp (number) to search in the list. 124 | * @returns the mark found. 125 | */ 126 | export function findCueByTime(arr, t, getTimestamp) { 127 | if (typeof (getTimestamp) != 'function') { 128 | getTimestamp = e => e.timestamp; 129 | } 130 | if (t == null || t == undefined) return null; 131 | let max = arr.length; 132 | if (max == 0) return null; 133 | if (max == 1 || t <= getTimestamp(arr[0])) return arr[0]; 134 | --max; 135 | if (t >= getTimestamp(arr[max])) return arr[max]; 136 | t = { 137 | timestamp: t 138 | }; 139 | let min = 0; 140 | let cur = {}; 141 | let nextCue = {}; 142 | for (let i = 0; i <= arr.length; ++i) { 143 | let curPos = Math.round((min + max) / 2); 144 | if (curPos == 0) ++curPos; 145 | cur.timestamp = getTimestamp(arr[curPos - 1]); 146 | nextCue.timestamp = getTimestamp(arr[curPos]); 147 | if (isBetween(t, cur, nextCue)) return arr[--curPos]; 148 | if (t.timestamp < cur.timestamp) max = curPos; 149 | else min = curPos; 150 | } 151 | return null; 152 | } 153 | 154 | 155 | export function downloadFile(filename, text) { 156 | //creating an invisible element 157 | var element = document.createElement('a'); 158 | element.setAttribute('href', 159 | 'data:text/plain;charset=utf-8, ' + 160 | encodeURIComponent(text)); 161 | element.setAttribute('download', filename); 162 | element.click(); 163 | document.body.removeChild(element); 164 | } 165 | 166 | export const jLocal = new Proxy({}, { 167 | get(obj, key) { 168 | if (key == "setItem") return (key, value) => localStorage.setItem(key, JSON.stringify(value)); 169 | let cb = key => { 170 | let v = localStorage.getItem(key); 171 | return v !== null ? JSON.parse(v) : v; 172 | }; 173 | return key == "getItem" ? cb : cb(key); 174 | }, 175 | set(obj, key, value) { 176 | localStorage.setItem(key, JSON.stringify(value)); 177 | return true; 178 | } 179 | }); 180 | 181 | export function getItem(key, defaultValue) { 182 | let v = jLocal[key]; 183 | return v !== null ? v : defaultValue; 184 | } 185 | 186 | 187 | export function togglePlay(video, description) { 188 | let p = video.paused; 189 | p? 190 | video.play() : 191 | video.pause(); 192 | if (description) { 193 | p? 194 | description.play() : 195 | description.pause(); 196 | } 197 | } 198 | 199 | export function slowerRate(video) { 200 | if (video.playbackRate <= 0.25) return 201 | video.playbackRate -= 0.25; 202 | } 203 | 204 | export function fasterRate(video) { 205 | if (video.playbackRate >= 4.0) return 206 | video.playbackRate += 0.25; 207 | } 208 | 209 | export function defaultRate(video) { 210 | video.playbackRate = 1.0; 211 | } 212 | 213 | 214 | export class DescPlayer { 215 | constructor(cues, funcSpeak, funcCancelSpeak) { 216 | this.cues = cues; 217 | this.funcSpeak = funcSpeak; 218 | this.funcCancelSpeak = funcCancelSpeak; 219 | this.onPlay = null; 220 | this.onPause = null; 221 | this.onCue = null; 222 | this.offset = 0; 223 | this.curTime = 0; 224 | this.realCurTime = null; 225 | this.paused = true; 226 | this.nextCue = null; 227 | this.nextCueId = null; 228 | this._callExternal = this._callExternal.bind(this); 229 | this.playNextCue = this.playNextCue.bind(this); 230 | this.run = this.run.bind(this); 231 | this.getTimestamp = this.getTimestamp.bind(this); 232 | this.play = this.play.bind(this); 233 | this.pause = this.pause.bind(this); 234 | } 235 | 236 | get currentTime() { 237 | if (this.paused) return this.curTime; 238 | return (new Date().getTime() - this.realCurTime) / 1000 + this.curTime; 239 | } 240 | 241 | set currentTime(v) { 242 | let p = this.paused; 243 | this.pause(); 244 | this.curTime = v; 245 | if (!p) this.play(); 246 | } 247 | 248 | _callExternal(func, ...args) { 249 | if (typeof (func) == 'function') func(...args); 250 | } 251 | 252 | playNextCue() { 253 | this.curTime = this.getTimestamp(this.nextCue); 254 | let p = this.nextCue; 255 | if (p.pos < this.cues.length - 1) { 256 | this.nextCue = this.cues[p.pos + 1]; 257 | this.run(); 258 | } else { 259 | this.nextCue = null; 260 | clearTimeout(this.nextCueId); 261 | this.nextCueId = null; 262 | this.paused = true; 263 | this._callExternal(this.onPause); 264 | } 265 | this._callExternal(this.funcCancelSpeak); 266 | this._callExternal(this.funcSpeak, p.text); 267 | this._callExternal(this.onCue, p); 268 | } 269 | 270 | run() { 271 | this.realCurTime = new Date().getTime(); 272 | this.nextCueId = setTimeout(this.playNextCue, 273 | (this.nextCue.timestamp - this.curTime + this.offset) * 1000); 274 | } 275 | 276 | getTimestamp(e) { 277 | return e.timestamp + this.offset; 278 | } 279 | 280 | play() { 281 | if (!this.pause) return false; 282 | if (this.cues == null || this.cues.length == 0) return false; 283 | if (this.curTime < 0) return false; 284 | let s = findCueByTime(this.cues, this.curTime, this.getTimestamp); 285 | if (this.getTimestamp(s) < this.curTime) { 286 | // detecting if there are more cues after current time. 287 | if (s.pos == this.cues.length - 1) return false; 288 | s = this.cues[s.pos + 1]; 289 | } 290 | if (this.getTimestamp(s) >= this.curTime) { 291 | this.nextCue = s; 292 | this.paused = false; 293 | this.run(); 294 | return true; 295 | } 296 | return false; 297 | } 298 | 299 | pause() { 300 | this.curTime = this.currentTime; 301 | if (this.nextCueId) clearTimeout(this.nextCueId); 302 | this.nextCueId = null; 303 | this.paused = true; 304 | this.funcCancelSpeak(); 305 | } 306 | 307 | 308 | } -------------------------------------------------------------------------------- /src/views/Help.vue: -------------------------------------------------------------------------------- 1 | 296 | 297 | 310 | -------------------------------------------------------------------------------- /src/views/WorkView.vue: -------------------------------------------------------------------------------- 1 | 162 | 507 | 529 | --------------------------------------------------------------------------------