From 5094b0bc70cedbca1620e4d4913ce0f7d55ba28e Mon Sep 17 00:00:00 2001 From: LoveEevee Date: Wed, 4 Nov 2020 03:12:46 +0300 Subject: [PATCH] Add folder dropping, fix rate limits - Add folder drag and drop support - Do expodential retrying if rate limited, allowing upload of very large drive folders - Do not import deleted files - Move the upload buttons to their own line - Notify when no TJA files have been found - Add more translations --- public/src/css/view.css | 30 ++++- public/src/js/abstractfile.js | 4 +- public/src/js/autoscore.js | 1 - public/src/js/controller.js | 4 +- public/src/js/customsongs.js | 196 ++++++++++++++++++++++++++---- public/src/js/gpicker.js | 91 +++++++++++--- public/src/js/importsongs.js | 7 +- public/src/js/songselect.js | 4 +- public/src/js/strings.js | 54 +++++++- public/src/js/viewassets.js | 4 +- public/src/views/customsongs.html | 16 ++- 11 files changed, 345 insertions(+), 66 deletions(-) diff --git a/public/src/css/view.css b/public/src/css/view.css index 9819f52..6971dac 100644 --- a/public/src/css/view.css +++ b/public/src/css/view.css @@ -108,6 +108,14 @@ kbd{ .left-buttons .taibtn{ margin-right: 0.4em; } +.center-buttons{ + display: flex; + justify-content: center; + margin: 1.5em 0; +} +.center-buttons .taibtn{ + margin: 0 0.2em; +} .diag-txt textarea, .diag-txt iframe{ width: 100%; @@ -217,7 +225,8 @@ kbd{ z-index: 1; } #settings-gamepad, -#settings-latency{ +#settings-latency, +#customsongs-error{ display: none; } #settings-gamepad .view{ @@ -289,7 +298,8 @@ kbd{ .latency-buttons span:active{ background-color: #946013; } -.left-buttons .taibtn{ +.left-buttons .taibtn, +.center-buttons .taibtn{ z-index: 1; } .accountpass-form, @@ -403,3 +413,19 @@ kbd{ font-size: 1em; padding: 0.2em; } +#customsongs-error .view, +#dropzone .view{ + width: 600px; +} +#dropzone{ + pointer-events: none; + opacity: 0; + transition: opacity 0.5s; +} +#dropzone .view-content{ + font-size: 2em; + text-align: center; +} +#dropzone.dragover{ + opacity: 1; +} diff --git a/public/src/js/abstractfile.js b/public/src/js/abstractfile.js index 07908d4..93d176a 100644 --- a/public/src/js/abstractfile.js +++ b/public/src/js/abstractfile.js @@ -38,9 +38,9 @@ class RemoteFile{ } } class LocalFile{ - constructor(file){ + constructor(file, path){ this.file = file - this.path = file.webkitRelativePath + this.path = path || file.webkitRelativePath this.url = this.path this.name = file.name } diff --git a/public/src/js/autoscore.js b/public/src/js/autoscore.js index 7345c20..45443f6 100644 --- a/public/src/js/autoscore.js +++ b/public/src/js/autoscore.js @@ -165,7 +165,6 @@ class AutoScore { GetMaxCombo() { var combo = 0; for (var circle of this.circles) { - //alert(this.IsCommonCircle(circle)); if (this.IsCommonCircle(circle) && (!circle.branch || circle.branch.name === "master")) { combo++; } diff --git a/public/src/js/controller.js b/public/src/js/controller.js index 9294df9..645012a 100644 --- a/public/src/js/controller.js +++ b/public/src/js/controller.js @@ -252,8 +252,8 @@ class Controller{ })) } if(songObj.lyricsFile){ - promises.push(songObj.lyricsFile.read().then(event => { - songObj.lyricsData = event.target.result + promises.push(songObj.lyricsFile.read().then(result => { + songObj.lyricsData = result }, () => Promise.resolve()), songObj.lyricsFile.path) } Promise.all(promises).then(resolve) diff --git a/public/src/js/customsongs.js b/public/src/js/customsongs.js index 006661c..b0a2a0a 100644 --- a/public/src/js/customsongs.js +++ b/public/src/js/customsongs.js @@ -6,6 +6,7 @@ class CustomSongs{ this.getElement("view-outer").classList.add("touch-enabled") } this.locked = false + this.mode = "main" var tutorialTitle = this.getElement("view-title") this.setAltText(tutorialTitle, strings.customSongs.title) @@ -40,21 +41,55 @@ class CustomSongs{ this.endButton = this.getElement("view-end-button") this.setAltText(this.endButton, strings.session.cancel) - pageEvents.add(this.endButton, ["mousedown", "touchstart"], this.onEnd.bind(this)) + pageEvents.add(this.endButton, ["mousedown", "touchstart"], event => this.onEnd(event, true)) this.items.push(this.endButton) this.selected = this.items.length - 1 this.loaderDiv = document.createElement("div") this.loaderDiv.innerHTML = assets.pages["loadsong"] var loadingText = this.loaderDiv.querySelector("#loading-text") - loadingText.appendChild(document.createTextNode(strings.loading)) - loadingText.setAttribute("alt", strings.loading) + this.setAltText(loadingText, strings.loading) + + if(DataTransferItem.prototype.webkitGetAsEntry){ + this.dropzone = document.getElementById("dropzone") + var dropContent = this.dropzone.getElementsByClassName("view-content")[0] + dropContent.innerText = strings.customSongs.dropzone + this.dragging = false + pageEvents.add(document, "dragover", event => { + event.preventDefault() + if(!this.locked){ + event.dataTransfer.dropEffect = "copy" + this.dropzone.classList.add("dragover") + this.dragging = true + }else{ + event.dataTransfer.dropEffect = "none" + } + }) + pageEvents.add(document, "dragleave", () => { + this.dropzone.classList.remove("dragover") + this.dragging = false + }) + pageEvents.add(document, "drop", this.filesDropped.bind(this)) + } + + this.errorDiv = document.getElementById("customsongs-error") + pageEvents.add(this.errorDiv, ["mousedown", "touchstart"], event => { + if(event.target === event.currentTarget){ + this.hideError() + } + }) + var errorTitle = this.errorDiv.getElementsByClassName("view-title")[0] + this.setAltText(errorTitle, strings.customSongs.importError) + this.errorContent = this.errorDiv.getElementsByClassName("view-content")[0] + this.errorEnd = this.errorDiv.getElementsByClassName("view-end-button")[0] + this.setAltText(this.errorEnd, strings.tutorial.ok) + pageEvents.add(this.errorEnd, ["mousedown", "touchstart"], () => this.hideError(true)) this.keyboard = new Keyboard({ confirm: ["enter", "space", "don_l", "don_r"], previous: ["left", "up", "ka_l"], next: ["right", "down", "ka_r"], - back: ["escape"] + backEsc: ["escape"] }, this.keyPressed.bind(this)) this.gamepad = new Gamepad({ confirmPad: ["b", "ls", "rs"], @@ -73,9 +108,17 @@ class CustomSongs{ element.setAttribute("alt", text) } localFolder(){ - if(this.locked){ + if(event){ + if(event.type === "touchstart"){ + event.preventDefault() + }else if(event.which !== 1){ + return + } + } + if(this.locked || this.mode !== "main"){ return } + this.changeSelected(this.linkLocalFolder) this.browse.click() } browseChange(event){ @@ -83,6 +126,47 @@ class CustomSongs{ for(var i = 0; i < event.target.files.length; i++){ files.push(new LocalFile(event.target.files[i])) } + this.importLocal(files) + } + filesDropped(event){ + event.preventDefault() + this.dropzone.classList.remove("dragover") + this.dragging = false + if(this.locked){ + return + } + var files = [] + var walk = (entry, path="") => { + return new Promise(resolve => { + if(entry.isFile){ + entry.file(file => { + files.push(new LocalFile(file, path + file.name)) + return resolve() + }, resolve) + }else if(entry.isDirectory){ + var dirReader = entry.createReader() + dirReader.readEntries(entries => { + var dirPromises = [] + for(var i = 0; i < entries.length; i++){ + dirPromises.push(walk(entries[i], path + entry.name + "/")) + } + return Promise.all(dirPromises).then(resolve) + }, resolve) + }else{ + return resolve() + } + }) + } + var dropPromises = [] + for(var i = 0; i < event.dataTransfer.items.length; i++){ + var entry = event.dataTransfer.items[i].webkitGetAsEntry() + if(entry){ + dropPromises.push(walk(entry)) + } + } + Promise.all(dropPromises).then(() => this.importLocal(files)) + } + importLocal(files){ if(!files.length){ return } @@ -94,15 +178,25 @@ class CustomSongs{ this.browse.parentNode.reset() this.locked = false this.loading(false) - if(e !== "cancel"){ + if(e === "nosongs"){ + this.showError(strings.customSongs.noSongs) + }else if(e !== "cancel"){ return Promise.reject(e) } }) } - gdriveFolder(){ - if(this.locked){ + gdriveFolder(event){ + if(event){ + if(event.type === "touchstart"){ + event.preventDefault() + }else if(event.which !== 1){ + return + } + } + if(this.locked || this.mode !== "main"){ return } + this.changeSelected(this.linkGdriveFolder) this.locked = true this.loading(true) var importSongs = new ImportSongs(true) @@ -117,13 +211,17 @@ class CustomSongs{ return gpicker.browse(locked => { this.locked = locked this.loading(locked) + }, error => { + this.showError(error) }) }).then(files => importSongs.load(files)) .then(this.songsLoaded.bind(this)) .catch(e => { this.locked = false this.loading(false) - if(e !== "cancel"){ + if(e === "nosongs"){ + this.showError(strings.customSongs.noSongs) + }else if(e !== "cancel"){ return Promise.reject(e) } }) @@ -154,32 +252,48 @@ class CustomSongs{ return } var selected = this.items[this.selected] - if(name === "confirm" || name === "confirmPad"){ - if(selected === this.endButton){ - this.onEnd() - }else if(name !== "confirmPad"){ - if(selected === this.linkLocalFolder){ - assets.sounds["se_don"].play() - this.localFolder() - }else if(selected === this.linkGdriveFolder){ - assets.sounds["se_don"].play() - this.gdriveFolder() + if(this.mode === "main"){ + if(name === "confirm" || name === "confirmPad"){ + if(selected === this.endButton){ + this.onEnd(null, true) + }else if(name !== "confirmPad"){ + if(selected === this.linkLocalFolder){ + assets.sounds["se_don"].play() + this.localFolder() + }else if(selected === this.linkGdriveFolder){ + assets.sounds["se_don"].play() + this.gdriveFolder() + } + } + }else if(name === "previous" || name === "next"){ + selected.classList.remove("selected") + this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) + this.items[this.selected].classList.add("selected") + assets.sounds["se_ka"].play() + }else if(name === "back" || name === "backEsc"){ + if(!this.dragging || name !== "backEsc"){ + this.onEnd() } } - }else if(name === "previous" || name === "next"){ + }else if(this.mode === "error"){ + if(name === "confirm" || name === "confirmPad" || name === "back" || name === "backEsc"){ + this.hideError(name === "confirm" || name === "confirmPad") + } + } + } + changeSelected(button){ + var selected = this.items[this.selected] + if(selected !== button){ selected.classList.remove("selected") - this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) + this.selected = this.items.findIndex(item => item === button) this.items[this.selected].classList.add("selected") - assets.sounds["se_ka"].play() - }else if(name === "back"){ - this.onEnd() } } mod(length, index){ return ((index % length) + length) % length } - onEnd(event){ - if(this.locked){ + onEnd(event, confirm){ + if(this.locked || this.mode !== "main"){ return } var touched = false @@ -190,13 +304,32 @@ class CustomSongs{ }else if(event.which !== 1){ return } + }else{ + touched = this.touchEnabled } this.clean() - assets.sounds["se_don"].play() + assets.sounds[confirm ? "se_don" : "se_cancel"].play() setTimeout(() => { new SongSelect("customSongs", false, touched) }, 500) } + showError(text){ + if(this.mode === "error"){ + return + } + this.mode = "error" + this.errorContent.innerText = text + this.errorDiv.style.display = "flex" + assets.sounds["se_pause"].play() + } + hideError(confirm){ + if(this.mode !== "error"){ + return + } + this.mode = "main" + this.errorDiv.style.display = "" + assets.sounds[confirm ? "se_don" : "se_cancel"].play() + } clean(){ this.keyboard.clean() this.gamepad.clean() @@ -208,11 +341,20 @@ class CustomSongs{ pageEvents.remove(this.linkGdriveFolder, ["mousedown", "touchstart"]) } pageEvents.remove(this.endButton, ["mousedown", "touchstart"]) + pageEvents.remove(this.errorDiv, ["mousedown", "touchstart"]) + pageEvents.remove(this.errorEnd, ["mousedown", "touchstart"]) + if(DataTransferItem.prototype.webkitGetAsEntry){ + pageEvents.remove(document, ["dragover", "dragleave", "drop"]) + delete this.dropzone + } delete this.browse delete this.linkLocalFolder delete this.linkGdriveFolder delete this.endButton delete this.items delete this.loaderDiv + delete this.errorDiv + delete this.errorContent + delete this.errorEnd } } diff --git a/public/src/js/gpicker.js b/public/src/js/gpicker.js index 65eb2de..776f403 100644 --- a/public/src/js/gpicker.js +++ b/public/src/js/gpicker.js @@ -9,15 +9,17 @@ class Gpicker{ this.resolveQueue = [] this.queueActive = false } - browse(lockedCallback){ + browse(lockedCallback, errorCallback){ return this.loadApi() - .then(() => this.getToken(lockedCallback)) + .then(() => this.getToken(lockedCallback, errorCallback)) .then(() => new Promise((resolve, reject) => { this.displayPicker(data => { if(data.action === "picked"){ var file = data.docs[0] + var folders = [] + var rateLimit = -1 + var lastBatch = 0 var walk = (files, output=[]) => { - var batch = null for(var i = 0; i < files.length; i++){ var path = files[i].path ? files[i].path + "/" : "" var list = files[i].list @@ -27,14 +29,9 @@ class Gpicker{ for(var j = 0; j < list.length; j++){ var file = list[j] if(file.mimeType === this.folder){ - if(!batch){ - batch = gapi.client.newBatch() - } - batch.add(gapi.client.drive.files.list({ - q: "'" + file.id + "' in parents", - orderBy: "name_natural" - }), { - id: path + file.name + folders.push({ + path: path + file.name, + id: file.id }) }else{ output.push(new GdriveFile({ @@ -45,14 +42,64 @@ class Gpicker{ } } } - if(batch){ - return this.queue() - .then(() => batch.then(responses => { - var files = [] - for(var path in responses.result){ - files.push({path: path, list: responses.result[path].result.files}) + var batchList = [] + for(var i = 0; i < folders.length && batchList.length < 100; i++){ + if(!folders[i].listed){ + folders[i].pos = i + folders[i].listed = true + batchList.push(folders[i]) + } + } + if(batchList.length){ + var batch = gapi.client.newBatch() + batchList.forEach(folder => { + var req = { + q: "'" + folder.id + "' in parents and trashed = false", + orderBy: "name_natural" + } + if(folder.pageToken){ + req.pageToken = folder.pageToken + } + batch.add(gapi.client.drive.files.list(req), {id: folder.pos}) + }) + if(lastBatch + batchList.length > 100){ + var waitPromise = this.sleep(1000) + }else{ + var waitPromise = Promise.resolve() + } + return waitPromise.then(() => this.queue()).then(() => batch.then(responses => { + var files = [] + var rateLimited = false + for(var i in responses.result){ + var result = responses.result[i].result + if(result.error){ + if(result.error.errors[0].domain !== "usageLimits"){ + console.warn(result) + }else if(!rateLimited){ + rateLimited = true + rateLimit++ + folders.push({ + path: folders[i].path, + id: folders[i].id, + pageToken: folders[i].pageToken + }) + } + }else{ + if(result.nextPageToken){ + folders.push({ + path: folders[i].path, + id: folders[i].id, + pageToken: result.nextPageToken + }) + } + files.push({path: folders[i].path, list: result.files}) + } + } + if(rateLimited){ + return this.sleep(Math.pow(2, rateLimit) * 1000).then(() => walk(files, output)) + }else{ + return walk(files, output) } - return walk(files, output) })) }else{ return output @@ -84,7 +131,7 @@ class Gpicker{ gapi.client.load("drive", "v3").then(resolve, reject) )) } - getToken(lockedCallback=()=>{}){ + getToken(lockedCallback=()=>{}, errorCallback=()=>{}){ if(this.oauthToken){ return Promise.resolve() } @@ -97,7 +144,7 @@ class Gpicker{ this.auth = gapi.auth2.getAuthInstance() }, e => { if(e.details){ - alert(strings.gpicker.authError.replace("%s", e.details)) + errorCallback(strings.gpicker.authError.replace("%s", e.details)) } return Promise.reject(e) }) @@ -132,6 +179,7 @@ class Gpicker{ .setDeveloperKey(this.apiKey) .setAppId(this.projectNumber) .setOAuthToken(this.oauthToken) + .setLocale(strings.gpicker.locale) .hideTitleBar() .addView(new picker.DocsView("folders") .setLabel(strings.gpicker.myDrive) @@ -184,6 +232,9 @@ class Gpicker{ }) ) } + sleep(time){ + return new Promise(resolve => setTimeout(resolve, time)) + } queue(){ return new Promise(resolve => { this.resolveQueue.push(resolve) diff --git a/public/src/js/importsongs.js b/public/src/js/importsongs.js index cc8c154..2e5b673 100644 --- a/public/src/js/importsongs.js +++ b/public/src/js/importsongs.js @@ -417,10 +417,13 @@ } })) image.id = name - image.src = URL.createObjectURL(file.blob()) + promises.push(file.blob().then(blob => { + image.src = URL.createObjectURL(blob) + })) loader.assetsDiv.appendChild(image) var oldImage = assets.image[id] if(oldImage && oldImage.parentNode){ + URL.revokeObjectURL(oldImage.src) oldImage.parentNode.removeChild(oldImage) } assets.image[id] = image @@ -543,7 +546,7 @@ }else if(Object.keys(this.assetFiles).length){ return Promise.resolve() }else{ - return Promise.reject("cancel") + return Promise.reject("nosongs") } this.clean() } diff --git a/public/src/js/songselect.js b/public/src/js/songselect.js index af8e038..409f291 100644 --- a/public/src/js/songselect.js +++ b/public/src/js/songselect.js @@ -586,7 +586,7 @@ class SongSelect{ }) } }else if(this.state.locked !== 1 || fromP2){ - if(this.songs[this.selectedSong].courses && (this.state.locked === 0 || fromP2)){ + if(this.songs[this.selectedSong].courses && !this.songs[this.selectedSong].unloaded && (this.state.locked === 0 || fromP2)){ this.state.moveMS = ms }else{ this.state.moveMS = ms - this.songSelecting.speed * this.songSelecting.resize @@ -2222,7 +2222,7 @@ class SongSelect{ ] this.draw.layeredText({ ctx: ctx, - text: strings.ok, + text: strings.tutorial.ok, x: _x, y: _y + 18, width: _w, diff --git a/public/src/js/strings.js b/public/src/js/strings.js index 8443f74..62ca46c 100644 --- a/public/src/js/strings.js +++ b/public/src/js/strings.js @@ -1060,7 +1060,11 @@ var translations = { }, customSongs: { title: { + ja: "カスタム曲リスト", en: "Custom Song List", + cn: "自定义歌曲列表", + tw: "自定義歌曲列表", + ko: "맞춤 노래 목록" }, default: { ja: "デフォルト曲リスト", @@ -1075,21 +1079,61 @@ var translations = { ] }, localFolder: { - en: "Local Folder..." + ja: "ローカルフォルダ...", + en: "Local Folder...", + cn: "本地文件夹...", + tw: "本地文件夾...", + ko: "로컬 폴더..." }, gdriveFolder: { - en: "Google Drive..." + ja: "Google ドライブ...", + en: "Google Drive...", + cn: "Google云端硬盘...", + tw: "Google雲端硬碟...", + ko: "구글 드라이브..." + }, + dropzone: { + ja: "ここにファイルをドロップ", + en: "Drop files here", + cn: "将文件拖至此处", + tw: "將文件拖至此處", + ko: "파일을 여기에 드롭" + }, + importError: { + en: "Import Error" + }, + noSongs: { + en: "No Taiko chart files have been found in the provided folder." } }, gpicker: { + locale: { + ja: "ja", + en: "en-GB", + cn: "zh-CN", + tw: "zh-TW", + ko: "ko" + }, myDrive: { - en: "My Drive" + ja: "マイドライブ", + en: "My Drive", + cn: "我的云端硬盘", + tw: "我的雲端硬碟", + ko: "내 드라이브" }, starred: { - en: "Starred" + ja: "スター付き", + en: "Starred", + cn: "已加星标", + tw: "已加星號", + ko: "중요 문서함" }, sharedWithMe: { - en: "Shared with me" + ja: "共有アイテム", + en: "Shared with me", + cn: "与我共享", + tw: "與我共用", + ko: "공유 문서함" }, authError: { en: "Auth error: %s" diff --git a/public/src/js/viewassets.js b/public/src/js/viewassets.js index 5158611..62ca25b 100644 --- a/public/src/js/viewassets.js +++ b/public/src/js/viewassets.js @@ -175,7 +175,9 @@ class ViewAssets{ }) } clean(){ - this.don.clean() + if(this.don){ + this.don.clean() + } delete this.ctx delete this.don delete this.fire diff --git a/public/src/views/customsongs.html b/public/src/views/customsongs.html index 585a233..f7f4dcf 100644 --- a/public/src/views/customsongs.html +++ b/public/src/views/customsongs.html @@ -1,12 +1,24 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+