diff --git a/CHANGELOG.md b/CHANGELOG.md index 491b44a..cc27188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - UNRELEASED ### Changed - Caption Studio now reads audio files directly from project root, or user specified audio directory +- Caption studio now allows for saving/opening caption JSON files directly from projects ### Added - This CHANGELOG - VUE front end renderer diff --git a/package-lock.json b/package-lock.json index 3b5ccec..c394521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "springroll-studio", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "springroll-studio", - "version": "0.0.1", + "version": "1.0.0", "hasInstallScript": true, "dependencies": { "core-js": "^3.6.5", diff --git a/package.json b/package.json index 6b447b1..2989f28 100644 --- a/package.json +++ b/package.json @@ -88,4 +88,4 @@ "last 2 versions", "not dead" ] -} \ No newline at end of file +} diff --git a/src/contents/index.js b/src/contents/index.js index 1ba4af1..04c0f5c 100644 --- a/src/contents/index.js +++ b/src/contents/index.js @@ -9,6 +9,10 @@ export const EVENTS = { UPDATE_TEMPLATE_CREATION_LOG: 'updateTemplateCreateLog', PROJECT_CREATION_COMPLETE: 'projectCreationComplete', UPDATE_AUDIO_LOCATION: 'updateAudioLocation', + SAVE_CAPTION_DATA: 'saveCaptionData', + CLEAR_CAPTION_DATA: 'clearCaptionData', + OPEN_TEMPLATE_DIALOG: 'openTemplateDialog', + OPEN_CAPTION_FILE: 'openCaptionFile', }; export const DIALOGS = { diff --git a/src/main/index.js b/src/main/index.js index bc87e7b..df3acb0 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,9 +1,10 @@ 'use strict'; -import { app, protocol, BrowserWindow } from 'electron'; +import { app, protocol, BrowserWindow, Menu, MenuItem, ipcMain } from 'electron'; import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import { studio } from './studio'; +import { template, captionStudioTemplate } from './studio/menus/AppMenuTemplate'; const isDevelopment = process.env.NODE_ENV !== 'production'; @@ -16,6 +17,16 @@ protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } } ]); +// Create menus from templates +const menu = Menu.buildFromTemplate(template); +const captionStudioMenu = Menu.buildFromTemplate(captionStudioTemplate); +Menu.setApplicationMenu(menu); + +//on caption studio open or close, set the appropriate menu +ipcMain.on('captionStudio', (event, page) => { + Menu.setApplicationMenu(page ? captionStudioMenu : menu); +}); + /** * Creates electron window */ diff --git a/src/main/studio/index.js b/src/main/studio/index.js index 04fdc24..d961573 100644 --- a/src/main/studio/index.js +++ b/src/main/studio/index.js @@ -59,6 +59,7 @@ class SpringRollStudio { if (paths !== undefined) { projectInfo.location = paths[0]; captionInfo.audioLocation = paths[0]; //when the project location changes also change the default audio files directory + captionInfo.captionLocation = ''; } break; diff --git a/src/main/studio/menus/AppMenuTemplate.js b/src/main/studio/menus/AppMenuTemplate.js new file mode 100644 index 0000000..3c71a0c --- /dev/null +++ b/src/main/studio/menus/AppMenuTemplate.js @@ -0,0 +1,340 @@ +import { app } from 'electron'; +import { EVENTS } from '../../../contents'; + +const isMac = process.platform === 'darwin'; + + +export const template = [ + // { role: 'appMenu' } + ...(isMac ? [{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }] : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + { + label: 'Open...', + accelerator: isMac ? 'Cmd+O' : 'Cntrl+O', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { projectInfo, captionInfo } = require('../storage'); + const options = { + title: 'Select SpringRoll Project', + defaultPath: projectInfo.location, + properties: ['openDirectory'] + }; + + const paths = dialog.showOpenDialogSync(BrowserWindow.getFocusedWindow(), options); + if (paths !== undefined) { + projectInfo.location = paths[0]; + captionInfo.audioLocation = paths[0]; //when the project location changes also change the default audio files directory + } + } + }, + { + label: 'New Project', + accelerator: isMac ? 'Cmd+N' : 'Cntrl+N', + click: async () => { + const { BrowserWindow } = require('electron'); + BrowserWindow.getFocusedWindow().webContents.send(EVENTS.OPEN_TEMPLATE_DIALOG, true); + } + }, + { type: 'separator' }, + { + label: 'Choose Audio Directory', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { captionInfo } = require('../storage'); + const audio_options = { + title: 'Select SpringRoll Project Audio Files Location', + defaultPath: captionInfo.audioLocation, + properties: ['openDirectory'] + }; + const window = BrowserWindow.getFocusedWindow(); + const audio_paths = dialog.showOpenDialogSync(window, audio_options); + if (audio_paths !== undefined) { + captionInfo.audioLocation = audio_paths[0]; + window.webContents.send(EVENTS.UPDATE_AUDIO_LOCATION); + } + } + }, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' } + ] + }, + // { role: 'editMenu' } + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + ...(isMac ? [ + { role: 'pasteAndMatchStyle' }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, + { + label: 'Speech', + submenu: [ + { role: 'startSpeaking' }, + { role: 'stopSpeaking' } + ] + } + ] : [ + { role: 'delete' }, + { type: 'separator' }, + { role: 'selectAll' } + ]) + ] + }, + // { role: 'viewMenu' } + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { + label: 'Preview Game', + accelerator: isMac ? 'Alt+Cmd+P' : 'Alt+Shift+P', + }, + { + label: 'Caption Studio', + accelerator: isMac ? 'Alt+Cmd+C' : 'Alt+Shift+C', + }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + // { role: 'windowMenu' } + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac ? [ + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' } + ] : [ + { role: 'close' } + ]) + ] + }, +]; + +/** + * Menu structure used in CaptionStudio page + */ +export const captionStudioTemplate = [ + // { role: 'appMenu' } + ...(isMac ? [{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }] : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + { + label: 'Choose Audio Directory', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { captionInfo } = require('../storage'); + const audio_options = { + title: 'Select SpringRoll Project Audio Files Location', + defaultPath: captionInfo.audioLocation, + properties: ['openDirectory'] + }; + const window = BrowserWindow.getFocusedWindow(); + const audio_paths = dialog.showOpenDialogSync(window, audio_options); + if (audio_paths !== undefined) { + captionInfo.audioLocation = audio_paths[0]; + window.webContents.send(EVENTS.UPDATE_AUDIO_LOCATION); + } + } + }, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' } + + ] + }, + // { role: 'editMenu' } + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + ...(isMac ? [ + { role: 'pasteAndMatchStyle' }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, + { + label: 'Speech', + submenu: [ + { role: 'startSpeaking' }, + { role: 'stopSpeaking' } + ] + } + ] : [ + { role: 'delete' }, + { type: 'separator' }, + { role: 'selectAll' } + ]) + ] + }, + // { role: 'viewMenu' } + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + // { role: 'windowMenu' } + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac ? [ + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' } + ] : [ + { role: 'close' } + ]) + ] + }, + { + label: 'Caption Studio', + submenu: [ + { + label: 'Save Captions', + accelerator: process.platform === 'darwin' ? 'Cmd+S' : 'Cntrl+S', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { captionInfo } = require('../storage'); + + const window = BrowserWindow.getFocusedWindow(); + + if (captionInfo.captionLocation) { + window.webContents.send(EVENTS.SAVE_CAPTION_DATA); + + } else { + const options = { + title: 'Save As', + defaultPath: captionInfo.audioLocation + '/captions.json', + properties: ['createDirectory'], + filters: [ + {name: 'JSON', extensions: ['json']} + ] + }; + + dialog.showSaveDialog(window, options).then(({ canceled, filePath }) => { + if (filePath !== undefined && !canceled) { + captionInfo.captionLocation = filePath; + window.webContents.send(EVENTS.SAVE_CAPTION_DATA, filePath); + } + }); + } + } + }, + { + label: 'Save Captions As...', + accelerator: process.platform === 'darwin' ? 'Cmd+Shift+S' : 'Cntrl+Shift+S', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { captionInfo } = require('../storage'); + const options = { + title: 'Save As', + defaultPath: captionInfo.captionLocation, + properties: ['createDirectory'], + filters: [ + {name: 'JSON', extensions: ['json']} + ] + }; + const window = BrowserWindow.getFocusedWindow(); + + dialog.showSaveDialog(window, options).then(({ canceled, filePath }) => { + if (filePath !== undefined && !canceled) { + captionInfo.captionLocation = filePath; + window.webContents.send(EVENTS.SAVE_CAPTION_DATA, filePath); + } + }); + } + }, + { + label: 'Open Caption File', + accelerator: isMac ? 'Cmd+O' : 'Cntrl+O', + click: async () => { + const { dialog, BrowserWindow } = require('electron'); + const { captionInfo } = require('../storage'); + + const window = BrowserWindow.getFocusedWindow(); + const options = { + title: 'Open Caption File', + defaultPath: captionInfo.captionLocation, + properties: ['openFile'], + filters: [ + { name: 'JSON', extensions: [ 'json' ] } + ] + }; + const caption_path = dialog.showOpenDialogSync(window, options); + if (caption_path !== undefined) { + captionInfo.captionLocation = caption_path[0]; + window.webContents.send(EVENTS.OPEN_CAPTION_FILE, caption_path[0]); + } + } + }, + { type: 'separator' }, + { + label: 'Clear Captions', + click: () => { + const BrowserWindow = require('electron'); + BrowserWindow.webContents.getFocusedWebContents().send(EVENTS.CLEAR_CAPTION_DATA); + } + }, + ] + }, +]; + diff --git a/src/main/studio/storage/CaptionInfo.js b/src/main/studio/storage/CaptionInfo.js index 88f7da8..f75951a 100644 --- a/src/main/studio/storage/CaptionInfo.js +++ b/src/main/studio/storage/CaptionInfo.js @@ -38,6 +38,22 @@ class CaptionInfo { } store.dispatch('setCaptionLocation', { captionLocation: val }); } + /** + * Returns whether or not there are unsaved changes in caption studio + * @readonly + * @memberof CaptionInfo + */ + get isUnsavedChanges() { return store.state.captionInfo.isUnsavedChanges; } + /** + * Sets the value of isUnsavedChanges + * @memberof CaptionInfo + */ + set isUnsavedChanges(val) { + if (typeof val !== 'boolean') { + throw new Error(`[CaptionInfo] Caption file location must be a boolean. [val = ${typeof val}]`); + } + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: val }); + } } /** diff --git a/src/renderer/class/CaptionManager.js b/src/renderer/class/CaptionManager.js index 533fc3f..a8613e0 100644 --- a/src/renderer/class/CaptionManager.js +++ b/src/renderer/class/CaptionManager.js @@ -1,4 +1,5 @@ import { EventBus } from './EventBus'; +import store from '../store'; /** * Class that controls the creation, and management, of captions in the CaptionStudio Component. @@ -13,7 +14,7 @@ class CaptionManager { this.data = {}; //All caption data, organized by file name. this.activeCaption = undefined; //currently active caption name, created by stripping the file extension of the active file. this.activeIndex = 0; //the index of the currently active caption. - this.file = new File([], 'NO_FILE'); //Currently active file, selected in the FileDirectory component + this.file = {}; //Currently active file, selected in the FileDirectory component this.currentTime = 0; //current time of the waveform component EventBus.$on('caption_update', this.updateActiveCaption.bind(this)); EventBus.$on('caption_reset', this.reset.bind(this)); @@ -68,9 +69,17 @@ class CaptionManager { * Caption. */ onJSONUpdate($event, $origin = '') { + if ($origin !== 'userOpen') { + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: true }); + } Object.keys($event).forEach((key) => { $event[key].forEach((caption, index) => { + + if (!this.data[key]) { + this.data[key] = [this.template]; + } + const current = this.data[key]; this.data[key][index] = { @@ -81,6 +90,10 @@ class CaptionManager { }); }); + if ($origin === 'userOpen') { + this.emitOpenedJSON(); + return; + } this.currentCaptionIndex.edited = true; this.emitCurrent($origin); this.emitData($origin); @@ -114,6 +127,7 @@ class CaptionManager { * and creates a new empty caption. Also "saves" the previously active caption in the data object. */ addIndex($origin = '') { + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: true }); this.data[this.activeCaption].push(this.template); this.activeIndex++; EventBus.$emit('file_captioned', { name: this.file.name, isCaptioned: true }); @@ -128,6 +142,7 @@ class CaptionManager { * simply upates the currently active caption with whatever new data is provided. */ updateActiveCaption({ content, start, end }, $origin = '') { + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: true }); const current = this.currentCaptionIndex; this.data[this.activeCaption][this.activeIndex] = { @@ -144,6 +159,7 @@ class CaptionManager { * Removes all captions from the data object and resets the active caption back to it's initial state. */ reset() { + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: true }); this.data = {}; this.activeIndex = 0; this.activeCaption = ''; @@ -178,6 +194,7 @@ class CaptionManager { * Used to delete a single caption. Uses the index to look up which caption should be removed. Almost always will be the current caption. */ removeAtIndex($origin = '') { + store.dispatch('setIsUnsavedChanges', { isUnsavedChanges: true }); if ('undefined' === typeof this.currentCaption[this.activeIndex]) { return; } @@ -214,6 +231,12 @@ class CaptionManager { emitData( $origin = '' ) { EventBus.$emit('caption_data', this.data, $origin); } + /** + * Emits the entirety of the current data object, which contains all caption files, and associated captions. + */ + emitOpenedJSON( $origin = '' ) { + EventBus.$emit('caption_data_opened', this.data, $origin); + } /** * Combines emitCurrent and emitData, as well as emitting the file_selected event. @@ -228,7 +251,7 @@ class CaptionManager { * */ get lastIndex() { - return this.currentCaption.length - 1 || 0; + return this.currentCaption?.length - 1 || 0; } /** diff --git a/src/renderer/class/Directory.js b/src/renderer/class/Directory.js index 58d1bd8..9abbb0f 100644 --- a/src/renderer/class/Directory.js +++ b/src/renderer/class/Directory.js @@ -107,9 +107,7 @@ export default class Directory { * @memberof Directory */ selectByFile(file) { - //const index = this.getFileIndex(file); - - const index = this.files.indexOf(file); + const index = this.getFileIndex(file); if (-1 === index) { return; diff --git a/src/renderer/class/FileProcessor.js b/src/renderer/class/FileProcessor.js index 16683e6..708f085 100644 --- a/src/renderer/class/FileProcessor.js +++ b/src/renderer/class/FileProcessor.js @@ -1,5 +1,6 @@ import Directory from './Directory'; import store from '../store/'; +import { EventBus } from '../class/EventBus'; const fs = require('fs'); const path = require('path'); const FileType = require('file-type'); @@ -33,7 +34,7 @@ class FileProcessor { this.setNameFilter(nameFilter); this.directory = new Directory(); this.hasFiles = false; - this.parentDirectoryName = path.basename(store.state.captionInfo.audioLocation); + this.parentDirectoryName = store.state.captionInfo.audioLocation ? path.basename(store.state.captionInfo.audioLocation) : ''; } /** @@ -46,7 +47,9 @@ class FileProcessor { this.parentDirectoryName = path.basename(store.state.captionInfo.audioLocation); this.clear(); + const files = await this.generateFileList(store.state.captionInfo.audioLocation); + EventBus.$emit('file_list_generated', files); for (let i = 0, l = files.length; i < l; i++) { if ( @@ -87,7 +90,6 @@ class FileProcessor { } } } - return arrayOfFiles; } diff --git a/src/renderer/components/CaptionPreview.vue b/src/renderer/components/CaptionPreview.vue index 981c415..c5dca47 100644 --- a/src/renderer/components/CaptionPreview.vue +++ b/src/renderer/components/CaptionPreview.vue @@ -61,6 +61,7 @@ export default { this.setup(); EventBus.$on('caption_changed', this.setActiveCaption); EventBus.$on('caption_data', this.loadCaptionData); + EventBus.$on('caption_data_opened', this.loadJSONData); EventBus.$on('time_current', this.onTimeChange); EventBus.$on('caption_reset', this.setup); }, @@ -70,6 +71,7 @@ export default { destroyed() { EventBus.$off('caption_changed', this.setActiveCaption); EventBus.$off('caption_data', this.loadCaptionData); + EventBus.$off('caption_data_opened', this.loadJSONData); EventBus.$off('time_current', this.onTimeChange); EventBus.$off('caption_reset', this.setup); }, @@ -115,6 +117,13 @@ export default { this.data[this.name][this.index].start ); }, + /** + * + */ + loadJSONData($event) { + this.data = $event; + this.captionPlayer.captions = CaptionFactory.createCaptionMap($event); + }, /** * */ diff --git a/src/renderer/components/FileDirectory.vue b/src/renderer/components/FileDirectory.vue index a331fe0..4dbf502 100644 --- a/src/renderer/components/FileDirectory.vue +++ b/src/renderer/components/FileDirectory.vue @@ -111,6 +111,7 @@ export default { EventBus.$off('previous_file', this.previousFile); EventBus.$off('file_captioned', this.onFileCaptionChange); EventBus.$off('json_file_selected', this.jsonEmit); + console.log('destroyed!'); }, methods: { /** diff --git a/src/renderer/components/FileExplorer.vue b/src/renderer/components/FileExplorer.vue index a7c03c0..29ae9dd 100644 --- a/src/renderer/components/FileExplorer.vue +++ b/src/renderer/components/FileExplorer.vue @@ -1,28 +1,50 @@