From b42b246a9977454ca243771b8b2ff47bac475069 Mon Sep 17 00:00:00 2001 From: LoveEevee Date: Thu, 27 May 2021 20:23:19 +0300 Subject: [PATCH] CustomSongs: Restore custom song list after reload Uses the File System Access API supported in some browsers to keep the custom song list between sessions, restoring it back even when the page was closed. --- public/src/js/abstractfile.js | 32 +++++++ public/src/js/assets.js | 3 +- public/src/js/customsongs.js | 157 ++++++++++++++++++++++++---------- public/src/js/idb.js | 51 +++++++++++ public/src/js/loader.js | 1 + public/src/js/main.js | 1 + public/src/js/songselect.js | 14 +-- public/src/js/titlescreen.js | 35 +++++--- 8 files changed, 230 insertions(+), 64 deletions(-) create mode 100644 public/src/js/idb.js diff --git a/public/src/js/abstractfile.js b/public/src/js/abstractfile.js index 93d176a..44b4bbb 100644 --- a/public/src/js/abstractfile.js +++ b/public/src/js/abstractfile.js @@ -4,6 +4,21 @@ function readFile(file, arrayBuffer, encoding){ reader[arrayBuffer ? "readAsArrayBuffer" : "readAsText"](file, encoding) return promise } +function filePermission(file){ + return file.queryPermission().then(response => { + if(response === "granted"){ + return file + }else{ + return file.requestPermission().then(response => { + if(response === "granted"){ + return file + }else{ + return Promise.reject(file) + } + }) + } + }) +} class RemoteFile{ constructor(url){ this.url = url @@ -54,6 +69,23 @@ class LocalFile{ return Promise.resolve(this.file) } } +class FilesystemFile{ + constructor(file, path){ + this.file = file + this.path = path + this.url = this.path + this.name = file.name + } + arrayBuffer(){ + return this.blob().then(blob => blob.arrayBuffer()) + } + read(encoding){ + return this.blob().then(blob => readFile(blob, false, encoding)) + } + blob(){ + return filePermission(this.file).then(file => file.getFile()) + } +} class GdriveFile{ constructor(fileObj){ this.path = fileObj.path diff --git a/public/src/js/assets.js b/public/src/js/assets.js index c8b23cb..f449487 100644 --- a/public/src/js/assets.js +++ b/public/src/js/assets.js @@ -35,7 +35,8 @@ var assets = { "account.js", "lyrics.js", "customsongs.js", - "abstractfile.js" + "abstractfile.js", + "idb.js" ], "css": [ "main.css", diff --git a/public/src/js/customsongs.js b/public/src/js/customsongs.js index 6edc60c..ab98800 100644 --- a/public/src/js/customsongs.js +++ b/public/src/js/customsongs.js @@ -1,12 +1,23 @@ class CustomSongs{ - constructor(touchEnabled){ + constructor(touchEnabled, noPage){ + this.loaderDiv = document.createElement("div") + this.loaderDiv.innerHTML = assets.pages["loadsong"] + var loadingText = this.loaderDiv.querySelector("#loading-text") + this.setAltText(loadingText, strings.loading) + + this.locked = false + this.mode = "main" + + if(noPage){ + this.noPage = true + return + } + this.touchEnabled = touchEnabled loader.changePage("customsongs", true) if(touchEnabled){ 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) @@ -19,7 +30,7 @@ class CustomSongs{ this.items = [] this.linkLocalFolder = document.getElementById("link-localfolder") - this.hasLocal = "webkitdirectory" in HTMLInputElement.prototype && !(/Android|iPhone|iPad/.test(navigator.userAgent)) + this.hasLocal = (typeof showDirectoryPicker === "function" || "webkitdirectory" in HTMLInputElement.prototype) && !(/Android|iPhone|iPad/.test(navigator.userAgent)) this.selected = -1 if(this.hasLocal){ @@ -68,12 +79,7 @@ class CustomSongs{ this.selected = this.items.length - 1 } - this.loaderDiv = document.createElement("div") - this.loaderDiv.innerHTML = assets.pages["loadsong"] - var loadingText = this.loaderDiv.querySelector("#loading-text") - this.setAltText(loadingText, strings.loading) - - if(DataTransferItem.prototype.webkitGetAsEntry){ + if(DataTransferItem.prototype.getAsFileSystemHandle || DataTransferItem.prototype.webkitGetAsEntry){ this.dropzone = document.getElementById("dropzone") var dropContent = this.dropzone.getElementsByClassName("view-content")[0] dropContent.innerText = strings.customSongs.dropzone @@ -142,7 +148,19 @@ class CustomSongs{ return } this.changeSelected(this.linkLocalFolder) - this.browse.click() + if(typeof showDirectoryPicker === "function"){ + return showDirectoryPicker().then(file => { + this.walkFilesystem(file).then(files => this.importLocal(files)).then(e => { + db.setItem("customFolder", file) + }).catch(e => { + if(e !== "cancel"){ + return Promise.reject(e) + } + }) + }, () => {}) + }else{ + this.browse.click() + } } browseChange(event){ var files = [] @@ -151,6 +169,24 @@ class CustomSongs{ } this.importLocal(files) } + walkFilesystem(dir, path=dir.name + "/", output=[]){ + return filePermission(dir).then(dir => { + var values = dir.values() + var walkValues = () => values.next().then(generator => { + if(generator.done){ + return output + } + var file = generator.value + if(file.kind === "directory"){ + return this.walkFilesystem(file, path + file.name + "/", output).then(() => walkValues()) + }else{ + output.push(new FilesystemFile(file, path + file.name)) + return walkValues() + } + }) + return walkValues() + }, () => Promise.resolve()) + } filesDropped(event){ event.preventDefault() this.dropzone.classList.remove("dragover") @@ -158,46 +194,69 @@ class CustomSongs{ 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 allFiles = [] 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)) + var dropLength = event.dataTransfer.items.length + for(var i = 0; i < dropLength; i++){ + var item = event.dataTransfer.items[i] + let promise + if(item.getAsFileSystemHandle){ + promise = item.getAsFileSystemHandle().then(file => { + if(file.kind === "directory"){ + return this.walkFilesystem(file).then(files => { + if(files.length && dropLength === 1){ + db.setItem("customFolder", file) + } + return files + }) + }else{ + return [new FilesystemFile(file, file.name)] + } + }) + }else{ + var entry = item.webkitGetAsEntry() + if(entry){ + promise = this.walkEntry(entry) + } + } + if(promise){ + dropPromises.push(promise.then(files => { + allFiles = allFiles.concat(files) + })) } } - Promise.all(dropPromises).then(() => this.importLocal(files)) + Promise.all(dropPromises).then(() => this.importLocal(allFiles)) + } + walkEntry(entry, path="", output=[]){ + return new Promise(resolve => { + if(entry.isFile){ + entry.file(file => { + output.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(this.walkEntry(entries[i], path + entry.name + "/", output)) + } + return Promise.all(dirPromises).then(resolve) + }, resolve) + }else{ + return resolve() + } + }).then(() => output) } importLocal(files){ if(!files.length){ - return + return Promise.resolve("cancel") } this.locked = true this.loading(true) var importSongs = new ImportSongs() - importSongs.load(files).then(this.songsLoaded.bind(this), e => { + return importSongs.load(files).then(this.songsLoaded.bind(this), e => { this.browse.parentNode.reset() this.locked = false this.loading(false) @@ -315,7 +374,7 @@ class CustomSongs{ var length = songs.length assets.songs = songs assets.customSongs = true - assets.customSelected = 0 + assets.customSelected = this.noPage ? +localStorage.getItem("customSelected") : 0 } assets.sounds["se_don"].play() this.clean() @@ -393,15 +452,18 @@ class CustomSongs{ touched = this.touchEnabled } this.clean() - assets.sounds[confirm ? "se_don" : "se_cancel"].play() - setTimeout(() => { + if(!this.noPage){ + assets.sounds[confirm ? "se_don" : "se_cancel"].play() + } + return new Promise(resolve => setTimeout(() => { new SongSelect("customSongs", false, touched) - }, 500) + resolve() + }, 500)) } showError(text){ this.locked = false this.loading(false) - if(this.mode === "error"){ + if(this.noPage || this.mode === "error"){ return } this.mode = "error" @@ -418,6 +480,10 @@ class CustomSongs{ assets.sounds[confirm ? "se_don" : "se_cancel"].play() } clean(){ + delete this.loaderDiv + if(this.noPage){ + return + } this.keyboard.clean() this.gamepad.clean() pageEvents.remove(this.browse, "change") @@ -443,7 +509,6 @@ class CustomSongs{ delete this.linkPrivacy delete this.endButton delete this.items - delete this.loaderDiv delete this.errorDiv delete this.errorContent delete this.errorEnd diff --git a/public/src/js/idb.js b/public/src/js/idb.js new file mode 100644 index 0000000..6ca2640 --- /dev/null +++ b/public/src/js/idb.js @@ -0,0 +1,51 @@ +class IDB{ + constructor(name, store){ + this.name = name + this.store = store + } + init(){ + if(this.db){ + return Promise.resolve(this.db) + } + var request = indexedDB.open(this.name) + request.onupgradeneeded = event => { + var db = event.target.result + db.createObjectStore(this.store) + } + return this.promise(request).then(result => { + this.db = result + return this.db + }, target => + console.warn("DB error", target) + ) + } + promise(request){ + return new Promise((resolve, reject) => { + return pageEvents.race(request, "success", "error").then(response => { + if(response.type === "success"){ + return resolve(event.target.result) + }else{ + return reject(event.target) + } + }) + }) + } + transaction(method, ...args){ + return this.init().then(db => + db.transaction(this.store, "readwrite").objectStore(this.store)[method](...args) + ).then(this.promise.bind(this)) + } + getItem(name){ + return this.transaction("get", name) + } + setItem(name, value){ + return this.transaction("put", value, name) + } + removeItem(name){ + return this.transaction("delete", name) + } + removeDB(){ + delete this.db + return indexedDB.deleteDatabase(this.name) + } +} diff --git a/public/src/js/loader.js b/public/src/js/loader.js index b077deb..9053d99 100644 --- a/public/src/js/loader.js +++ b/public/src/js/loader.js @@ -252,6 +252,7 @@ class Loader{ settings = new Settings() pageEvents.setKbd() scoreStorage = new ScoreStorage() + db = new IDB("taiko", "store") Promise.all(this.promises).then(() => { if(this.error){ diff --git a/public/src/js/main.js b/public/src/js/main.js index b314147..2077fcd 100644 --- a/public/src/js/main.js +++ b/public/src/js/main.js @@ -90,6 +90,7 @@ var settings var scoreStorage var account = {} var gpicker +var db pageEvents.add(root, ["touchstart", "touchmove", "touchend"], event => { if(event.cancelable && cancelTouch && event.target.tagName !== "SELECT"){ diff --git a/public/src/js/songselect.js b/public/src/js/songselect.js index f775e07..6478ef3 100644 --- a/public/src/js/songselect.js +++ b/public/src/js/songselect.js @@ -210,7 +210,7 @@ class SongSelect{ if(!assets.customSongs && !fromTutorial && !("selectedSong" in localStorage) && !songId){ fromTutorial = touchEnabled ? "about" : "tutorial" } - if(p2.session){ + if(p2.session || assets.customSongs && "customSelected" in localStorage){ fromTutorial = false } @@ -231,7 +231,7 @@ class SongSelect{ if(songIdIndex !== -1){ this.selectedSong = songIdIndex }else if(assets.customSongs){ - this.selectedSong = assets.customSelected + this.selectedSong = Math.min(Math.max(0, assets.customSelected), this.songs.length - 1) }else if((!p2.session || fadeIn) && "selectedSong" in localStorage){ this.selectedSong = Math.min(Math.max(0, localStorage["selectedSong"] |0), this.songs.length - 1) } @@ -508,7 +508,7 @@ class SongSelect{ moveTo = "account" }else if(p2.session && 438 < mouse.x && mouse.x < 834 && mouse.y > 603){ moveTo = "session" - }else if(!p2.session && mouse.x > 641 && mouse.y > 603 && p2.socket.readyState === 1 && !assets.customSongs){ + }else if(!p2.session && mouse.x > 641 && mouse.y > 603 && p2.socket && p2.socket.readyState === 1 && !assets.customSongs){ moveTo = "session" }else{ var moveTo = this.songSelMouse(mouse.x, mouse.y) @@ -739,6 +739,7 @@ class SongSelect{ try{ if(assets.customSongs){ assets.customSelected = this.selectedSong + localStorage["customSelected"] = this.selectedSong }else{ localStorage["selectedSong"] = this.selectedSong } @@ -832,7 +833,7 @@ class SongSelect{ this.state.moveHover = null }else{ localStorage["selectedSong"] = this.selectedSong - + this.playSound("se_don") this.clean() setTimeout(() => { @@ -850,6 +851,8 @@ class SongSelect{ setTimeout(() => { new SongSelect("customSongs", false, this.touchEnabled) }, 500) + localStorage.removeItem("customSelected") + db.removeItem("customFolder") pageEvents.send("import-songs-default") }else{ localStorage["selectedSong"] = this.selectedSong @@ -1174,6 +1177,7 @@ class SongSelect{ this.state.locked = 2 if(assets.customSongs){ assets.customSelected = this.selectedSong + localStorage["customSelected"] = this.selectedSong }else if(!p2.session){ try{ localStorage["selectedSong"] = this.selectedSong @@ -2097,7 +2101,7 @@ class SongSelect{ ctx.lineTo(x + 4, y + 4) ctx.lineTo(x + 4, y + h) ctx.fill() - if(screen !== "difficulty" && p2.socket.readyState === 1 && !assets.customSongs){ + if(screen !== "difficulty" && p2.socket && p2.socket.readyState === 1 && !assets.customSongs){ var elapsed = (ms - this.state.screenMS) % 3100 var fade = 1 if(!p2.session && screen === "song"){ diff --git a/public/src/js/titlescreen.js b/public/src/js/titlescreen.js index aa98e4d..d1d3b2a 100644 --- a/public/src/js/titlescreen.js +++ b/public/src/js/titlescreen.js @@ -1,6 +1,7 @@ class Titlescreen{ constructor(songId){ this.songId = songId + db.getItem("customFolder").then(folder => this.customFolder = folder) if(!songId){ loader.changePage("titlescreen", false) @@ -50,7 +51,7 @@ class Titlescreen{ onPressed(pressed, name){ if(pressed){ - if(name === "gamepadConfirm" && snd.buffer.context.state === "suspended"){ + if(name === "gamepadConfirm" && (snd.buffer.context.state === "suspended" || this.customFolder)){ return } this.titleScreen.style.cursor = "auto" @@ -62,18 +63,28 @@ class Titlescreen{ goNext(fromP2){ if(p2.session && !fromP2){ p2.send("songsel") - }else if(fromP2 || localStorage.getItem("tutorial") === "true"){ - if(this.touched){ - localStorage.setItem("tutorial", "true") - } - pageEvents.remove(p2, "message") - setTimeout(() => { - new SongSelect(false, false, this.touched, this.songId) - }, 500) }else{ - setTimeout(() => { - new SettingsView(this.touched, true, this.songId) - }, 500) + if(fromP2 || this.customFolder || localStorage.getItem("tutorial") === "true"){ + if(this.touched){ + localStorage.setItem("tutorial", "true") + } + pageEvents.remove(p2, "message") + if(this.customFolder && !fromP2 && !assets.customSongs){ + var customSongs = new CustomSongs(this.touched, true) + customSongs.walkFilesystem(this.customFolder).then(files => customSongs.importLocal(files)).catch(() => { + db.removeItem("customFolder") + new SongSelect(false, false, this.touched, this.songId) + }) + }else{ + setTimeout(() => { + new SongSelect(false, false, this.touched, this.songId) + }, 500) + } + }else{ + setTimeout(() => { + new SettingsView(this.touched, true, this.songId) + }, 500) + } } } setLang(lang, noEvent){