Skip to content

Commit a7270d4

Browse files
committed
Add support for loading and importing songs on web
This completes the file I/O part of the web version. It still needs to be tested with browsers and on a real server, but in principle this works as well as it can given the limitations. I will probably have to create a custom HTML shell to handle browser support exceptions and such.
1 parent 74fe20e commit a7270d4

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

globals/Controller.gd

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ var instrument_themes: Dictionary = {
6969
var _file_dialog: FileDialog = null
7070
var _file_dialog_finalize_callable: Callable = Callable()
7171
var _file_dialog_was_playing: bool = false
72+
var _file_dialog_web: FileDialogNativeWeb = null
7273
var _info_popup: InfoPopup = null
7374
var _controls_blocker: PopupManager.PopupControl = null
7475

@@ -104,9 +105,12 @@ func _ready() -> void:
104105
func _notification(what: int) -> void:
105106
if what == NOTIFICATION_WM_CLOSE_REQUEST:
106107
io_manager.check_song_on_exit()
108+
107109
elif what == NOTIFICATION_PREDELETE:
108110
if is_instance_valid(_file_dialog):
109111
_file_dialog.queue_free()
112+
if is_instance_valid(_file_dialog_web):
113+
_file_dialog_web.free()
110114
if is_instance_valid(_info_popup):
111115
_info_popup.queue_free()
112116
if is_instance_valid(_controls_blocker):
@@ -245,6 +249,22 @@ func _finalize_file_dialog() -> void:
245249
settings_manager.set_last_opened_folder(_file_dialog.current_dir)
246250

247251

252+
# Godot doesn't support native file dialogs on web yet.
253+
func get_file_dialog_web() -> FileDialogNativeWeb:
254+
if not _file_dialog_web:
255+
_file_dialog_web = FileDialogNativeWeb.new()
256+
_file_dialog_web.canceled.connect(_clear_file_dialog_web_connections)
257+
258+
_file_dialog_web.clear_filters()
259+
return _file_dialog_web
260+
261+
262+
func _clear_file_dialog_web_connections() -> void:
263+
var connections := _file_dialog_web.file_selected.get_connections()
264+
for connection: Dictionary in connections:
265+
_file_dialog_web.file_selected.disconnect(connection["callable"])
266+
267+
248268
func get_info_popup() -> InfoPopup:
249269
if not _info_popup:
250270
_info_popup = INFO_POPUP_SCENE.instantiate()

globals/IOManager.gd

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ func create_new_song_safe() -> void:
8181
# Ceol loading and saving.
8282

8383
func load_ceol_song() -> void:
84+
if OS.has_feature("web"):
85+
var load_dialog_web := Controller.get_file_dialog_web()
86+
load_dialog_web.add_filter(".ceol")
87+
load_dialog_web.file_selected.connect(_load_ceol_song_confirmed, CONNECT_ONE_SHOT)
88+
89+
load_dialog_web.popup()
90+
return
91+
8492
var load_dialog := Controller.get_file_dialog()
8593
load_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
8694
load_dialog.title = "Load .ceol Song"
@@ -198,6 +206,14 @@ func check_song_on_exit(always_confirm: bool = false) -> void:
198206
# External format import.
199207

200208
func import_mid_song() -> void:
209+
if OS.has_feature("web"):
210+
var import_dialog_web := Controller.get_file_dialog_web()
211+
import_dialog_web.add_filter(".mid")
212+
import_dialog_web.file_selected.connect(_import_mid_song_confirmed, CONNECT_ONE_SHOT)
213+
214+
import_dialog_web.popup()
215+
return
216+
201217
var import_dialog := Controller.get_file_dialog()
202218
import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
203219
import_dialog.title = "Import .mid File"

utils/FileDialogNativeWeb.gd

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
###################################################
2+
# Part of Bosca Ceoil Blue #
3+
# Copyright (c) 2024 Yuri Sizov and contributors #
4+
# Provided under MIT #
5+
###################################################
6+
7+
# Godot doesn't support native file dialogs on web, but we can fake them with
8+
# input elements.
9+
#
10+
# This class exposes a set of properties, signals, and methods to cover the
11+
# basic uses for loading files into the app. Internally, it creates an input
12+
# element via JavaScriptBridge and manipulates it to create a file dialog
13+
# when the user interacts with the page.
14+
#
15+
# It is possible to move parts of this logic to the front-end with some custom
16+
# HTML shell, but everything is simple enough for us to implement via JS proxy.
17+
# Though that means that the code here is poorly typed.
18+
19+
class_name FileDialogNativeWeb extends Object
20+
21+
signal file_selected(path: String)
22+
signal canceled()
23+
24+
var _document: JavaScriptObject = null
25+
var _element: JavaScriptObject = null
26+
# We must keep references around, otherwise they get silently destroyed.
27+
# JavaScriptBridge doesn't tick the reference counter, it seems.
28+
var _event_handlers: Array[JavaScriptObject] = []
29+
30+
31+
func _init() -> void:
32+
if not OS.has_feature("web"):
33+
printerr("FileDialogNativeWeb: Called in a non-web context!")
34+
return
35+
36+
_document = JavaScriptBridge.get_interface("document")
37+
_element = _document.createElement("input")
38+
_element.type = "file"
39+
40+
_add_event_handler(_element, "change", _file_selected)
41+
_add_event_handler(_element, "cancel", _dialog_cancelled)
42+
_document.body.appendChild(_element)
43+
44+
45+
func _notification(what: int) -> void:
46+
if what == NOTIFICATION_PREDELETE:
47+
if is_instance_valid(_element):
48+
_element.remove()
49+
50+
51+
func add_filter(filter: String) -> void:
52+
if not is_instance_valid(_element):
53+
return
54+
55+
if _element.accept.is_empty():
56+
_element.accept = filter
57+
else:
58+
_element.accept += ", " + filter
59+
60+
61+
func clear_filters() -> void:
62+
_element.accept = ""
63+
64+
65+
func popup() -> void:
66+
_element.click()
67+
68+
69+
# Event handlers.
70+
71+
# Wrapper that simplifies attaching event handlers to JavaScript objects.
72+
func _add_event_handler(object: JavaScriptObject, event: String, callback: Callable) -> void:
73+
var callback_ref := JavaScriptBridge.create_callback(func(args: Array) -> void:
74+
callback.call(args[0]) # The event object.
75+
)
76+
_event_handlers.push_back(callback_ref)
77+
78+
object.addEventListener(event, callback_ref)
79+
80+
81+
func _file_selected(_event: JavaScriptObject) -> void:
82+
if _element.files.length > 0:
83+
# When a file is selected, we aren't done. The file must be loaded into memory
84+
# as a buffer. Technically, a Blob can be converted directly, but it's an
85+
# async method, and that's a can of worms I don't want to touch.
86+
# If this was JavaScript, FileReader would be more verbose, but here it's the
87+
# simpler option.
88+
89+
var file_name: String = _element.files[0].name.get_file()
90+
var file_reader: JavaScriptObject = JavaScriptBridge.create_object("FileReader")
91+
_add_event_handler(file_reader, "load", _file_loaded.bind(file_name))
92+
file_reader.readAsArrayBuffer(_element.files[0])
93+
94+
95+
func _file_loaded(event: JavaScriptObject, filename: String) -> void:
96+
# When the reader loads the file, we stash it into the virtual file system, so
97+
# we can then use our standard flow to read and load it into the app.
98+
99+
# We don't care about conflicts, it's all temporary anyway.
100+
var path := "/tmp/" + filename
101+
102+
# The result is a JS ArrayBuffer, which cannot be used directly. We construct a
103+
# Uint8Array, which is effectively a byte array. Then we create a proper byte array
104+
# out of it.
105+
var buffer := PackedByteArray()
106+
var byte_array: Variant = JavaScriptBridge.create_object("Uint8Array", event.target.result)
107+
for i: int in byte_array.byteLength:
108+
buffer.push_back(byte_array[i]) # This only works if the byte_array is untyped. Some indexing operators missing?
109+
110+
# Create the temporary file.
111+
112+
var file := FileWrapper.new()
113+
var error := file.open(path, FileAccess.WRITE)
114+
if error != OK:
115+
printerr("FileDialogNativeWeb: Failed to open the file at '%s' for writing (code %d)." % [ path, error ])
116+
return
117+
118+
error = file.write_buffer_contents(buffer)
119+
if error != OK:
120+
printerr("FileDialogNativeWeb: Failed to write to the file at '%s' (code %d)." % [ path, error ])
121+
return
122+
123+
file._handler.close() # Clean up to avoid issues during the next step.
124+
125+
# Success!
126+
file_selected.emit(path)
127+
128+
129+
func _dialog_cancelled(_event: JavaScriptObject) -> void:
130+
canceled.emit()

0 commit comments

Comments
 (0)