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.
This commit is contained in:
LoveEevee 2021-05-27 20:23:19 +03:00
parent 1fceaadc7d
commit b42b246a99
8 changed files with 230 additions and 64 deletions

View File

@ -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

View File

@ -35,7 +35,8 @@ var assets = {
"account.js",
"lyrics.js",
"customsongs.js",
"abstractfile.js"
"abstractfile.js",
"idb.js"
],
"css": [
"main.css",

View File

@ -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

51
public/src/js/idb.js Normal file
View File

@ -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)
}
}

View File

@ -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){

View File

@ -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"){

View File

@ -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"){

View File

@ -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){