mirror of
https://github.com/schlagmichdoch/PairDrop.git
synced 2025-06-17 03:35:09 -04:00
move files for node only implementation
This commit is contained in:
parent
72f3bb0e7c
commit
947e088aeb
28 changed files with 27 additions and 0 deletions
38
public/scripts/clipboard.js
Normal file
38
public/scripts/clipboard.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Polyfill for Navigator.clipboard.writeText
|
||||
if (!navigator.clipboard) {
|
||||
navigator.clipboard = {
|
||||
writeText: text => {
|
||||
|
||||
// A <span> contains the text to copy
|
||||
const span = document.createElement('span');
|
||||
span.textContent = text;
|
||||
span.style.whiteSpace = 'pre'; // Preserve consecutive spaces and newlines
|
||||
|
||||
// Paint the span outside the viewport
|
||||
span.style.position = 'absolute';
|
||||
span.style.left = '-9999px';
|
||||
span.style.top = '-9999px';
|
||||
|
||||
const win = window;
|
||||
const selection = win.getSelection();
|
||||
win.document.body.appendChild(span);
|
||||
|
||||
const range = win.document.createRange();
|
||||
selection.removeAllRanges();
|
||||
range.selectNode(span);
|
||||
selection.addRange(range);
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = win.document.execCommand('copy');
|
||||
} catch (err) {
|
||||
return Promise.error();
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
span.remove();
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
597
public/scripts/network.js
Normal file
597
public/scripts/network.js
Normal file
|
@ -0,0 +1,597 @@
|
|||
window.URL = window.URL || window.webkitURL;
|
||||
window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
|
||||
|
||||
class ServerConnection {
|
||||
|
||||
constructor() {
|
||||
this._connect();
|
||||
Events.on('beforeunload', e => this._disconnect());
|
||||
Events.on('pagehide', e => this._disconnect());
|
||||
document.addEventListener('visibilitychange', e => this._onVisibilityChange());
|
||||
Events.on('online', this._reconnect);
|
||||
}
|
||||
|
||||
_connect() {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
if (this._isConnected() || this._isConnecting()) return;
|
||||
const ws = new WebSocket(this._endpoint());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = _ => console.log('WS: server connected');
|
||||
ws.onmessage = e => this._onMessage(e.data);
|
||||
ws.onclose = _ => this._onDisconnect();
|
||||
ws.onerror = e => this._onError(e);
|
||||
this._socket = ws;
|
||||
|
||||
Events.on('force-disconnect', this._onForceDisconnect);
|
||||
}
|
||||
|
||||
_onMessage(msg) {
|
||||
msg = JSON.parse(msg);
|
||||
console.log('WS:', msg);
|
||||
switch (msg.type) {
|
||||
case 'peers':
|
||||
Events.fire('peers', msg.peers);
|
||||
break;
|
||||
case 'peer-joined':
|
||||
Events.fire('peer-joined', msg.peer);
|
||||
break;
|
||||
case 'peer-left':
|
||||
Events.fire('peer-left', msg.peerId);
|
||||
break;
|
||||
case 'signal':
|
||||
Events.fire('signal', msg);
|
||||
break;
|
||||
case 'ping':
|
||||
this.send({ type: 'pong' });
|
||||
break;
|
||||
case 'display-name':
|
||||
Events.fire('display-name', msg);
|
||||
break;
|
||||
default:
|
||||
console.error('WS: unknown message type', msg);
|
||||
}
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (!this._isConnected()) return;
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
_endpoint() {
|
||||
// hack to detect if deployment or development environment
|
||||
const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws';
|
||||
const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback';
|
||||
return protocol + '://' + location.host + location.pathname + 'server' + webrtc;
|
||||
}
|
||||
|
||||
_disconnect() {
|
||||
this.send({ type: 'disconnect' });
|
||||
this._socket.onclose = null;
|
||||
this._socket.close();
|
||||
Events.fire('disconnect');
|
||||
}
|
||||
|
||||
_onDisconnect() {
|
||||
console.log('WS: server disconnected');
|
||||
Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...');
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = setTimeout(this._connect, 5000);
|
||||
Events.fire('disconnect');
|
||||
}
|
||||
|
||||
_onForceDisconnect() {
|
||||
document.cookie = "peerid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
this._disconnect();
|
||||
this._connect();
|
||||
}
|
||||
|
||||
_onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
_isConnected() {
|
||||
return this._socket && this._socket.readyState === this._socket.OPEN;
|
||||
}
|
||||
|
||||
_isConnecting() {
|
||||
return this._socket && this._socket.readyState === this._socket.CONNECTING;
|
||||
}
|
||||
|
||||
_reconnect() {
|
||||
console.log("reconnect")
|
||||
this._disconnect();
|
||||
this._connect();
|
||||
}
|
||||
|
||||
_onError(e) {
|
||||
console.error(e);
|
||||
this._onForceDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
class Peer {
|
||||
|
||||
constructor(serverConnection, peerId) {
|
||||
this._server = serverConnection;
|
||||
this._peerId = peerId;
|
||||
this._filesQueue = [];
|
||||
this._busy = false;
|
||||
}
|
||||
|
||||
sendJSON(message) {
|
||||
this._send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
sendFiles(files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
this._filesQueue.push(files[i]);
|
||||
}
|
||||
if (this._busy) return;
|
||||
this._dequeueFile();
|
||||
}
|
||||
|
||||
_dequeueFile() {
|
||||
if (!this._filesQueue.length) return;
|
||||
this._busy = true;
|
||||
const file = this._filesQueue.shift();
|
||||
this._sendFile(file);
|
||||
}
|
||||
|
||||
_sendFile(file) {
|
||||
this.sendJSON({
|
||||
type: 'header',
|
||||
name: file.name,
|
||||
mime: file.type,
|
||||
size: file.size
|
||||
});
|
||||
this._chunker = new FileChunker(file,
|
||||
chunk => this._send(chunk),
|
||||
offset => this._onPartitionEnd(offset));
|
||||
this._chunker.nextPartition();
|
||||
}
|
||||
|
||||
_onPartitionEnd(offset) {
|
||||
this.sendJSON({ type: 'partition', offset: offset });
|
||||
}
|
||||
|
||||
_onReceivedPartitionEnd(offset) {
|
||||
this.sendJSON({ type: 'partition-received', offset: offset });
|
||||
}
|
||||
|
||||
_sendNextPartition() {
|
||||
if (!this._chunker || this._chunker.isFileEnd()) return;
|
||||
this._chunker.nextPartition();
|
||||
}
|
||||
|
||||
_sendProgress(progress) {
|
||||
this.sendJSON({ type: 'progress', progress: progress });
|
||||
}
|
||||
|
||||
_onMessage(message) {
|
||||
if (typeof message !== 'string') {
|
||||
this._onChunkReceived(message);
|
||||
return;
|
||||
}
|
||||
message = JSON.parse(message);
|
||||
console.log('RTC:', message);
|
||||
switch (message.type) {
|
||||
case 'header':
|
||||
this._onFileHeader(message);
|
||||
break;
|
||||
case 'partition':
|
||||
this._onReceivedPartitionEnd(message);
|
||||
break;
|
||||
case 'partition-received':
|
||||
this._sendNextPartition();
|
||||
break;
|
||||
case 'progress':
|
||||
this._onDownloadProgress(message.progress);
|
||||
break;
|
||||
case 'file-transfer-complete':
|
||||
this._onFileTransferCompleted();
|
||||
break;
|
||||
case 'message-transfer-complete':
|
||||
this._onMessageTransferCompleted();
|
||||
break;
|
||||
case 'text':
|
||||
this._onTextReceived(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onFileHeader(header) {
|
||||
this._lastProgress = 0;
|
||||
this._digester = new FileDigester({
|
||||
name: header.name,
|
||||
mime: header.mime,
|
||||
size: header.size
|
||||
}, file => this._onFileReceived(file));
|
||||
}
|
||||
|
||||
_onChunkReceived(chunk) {
|
||||
if(!(chunk.byteLength || chunk.size)) return;
|
||||
|
||||
this._digester.unchunk(chunk);
|
||||
const progress = this._digester.progress;
|
||||
this._onDownloadProgress(progress);
|
||||
|
||||
// occasionally notify sender about our progress
|
||||
if (progress - this._lastProgress < 0.01) return;
|
||||
this._lastProgress = progress;
|
||||
this._sendProgress(progress);
|
||||
}
|
||||
|
||||
_onDownloadProgress(progress) {
|
||||
Events.fire('file-progress', { sender: this._peerId, progress: progress });
|
||||
}
|
||||
|
||||
_onFileReceived(proxyFile) {
|
||||
Events.fire('file-received', proxyFile);
|
||||
this.sendJSON({ type: 'file-transfer-complete' });
|
||||
}
|
||||
|
||||
_onFileTransferCompleted() {
|
||||
this._onDownloadProgress(1);
|
||||
this._reader = null;
|
||||
this._busy = false;
|
||||
this._dequeueFile();
|
||||
Events.fire('notify-user', 'File transfer completed.');
|
||||
}
|
||||
|
||||
_onMessageTransferCompleted() {
|
||||
Events.fire('notify-user', 'Message transfer completed.');
|
||||
}
|
||||
|
||||
sendText(text) {
|
||||
const unescaped = btoa(unescape(encodeURIComponent(text)));
|
||||
this.sendJSON({ type: 'text', text: unescaped });
|
||||
}
|
||||
|
||||
_onTextReceived(message) {
|
||||
const escaped = decodeURIComponent(escape(atob(message.text)));
|
||||
Events.fire('text-received', { text: escaped, sender: this._peerId });
|
||||
this.sendJSON({ type: 'message-transfer-complete' });
|
||||
}
|
||||
}
|
||||
|
||||
class RTCPeer extends Peer {
|
||||
|
||||
constructor(serverConnection, peerId) {
|
||||
super(serverConnection, peerId);
|
||||
if (!peerId) return; // we will listen for a caller
|
||||
this._connect(peerId, true);
|
||||
}
|
||||
|
||||
_connect(peerId, isCaller) {
|
||||
if (!this._conn) this._openConnection(peerId, isCaller);
|
||||
|
||||
if (isCaller) {
|
||||
this._openChannel();
|
||||
} else {
|
||||
this._conn.ondatachannel = e => this._onChannelOpened(e);
|
||||
}
|
||||
}
|
||||
|
||||
_openConnection(peerId, isCaller) {
|
||||
this._isCaller = isCaller;
|
||||
this._peerId = peerId;
|
||||
this._conn = new RTCPeerConnection(RTCPeer.config);
|
||||
this._conn.onicecandidate = e => this._onIceCandidate(e);
|
||||
this._conn.onconnectionstatechange = e => this._onConnectionStateChange(e);
|
||||
this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e);
|
||||
}
|
||||
|
||||
_openChannel() {
|
||||
const channel = this._conn.createDataChannel('data-channel', {
|
||||
ordered: true,
|
||||
reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable
|
||||
});
|
||||
channel.onopen = e => this._onChannelOpened(e);
|
||||
this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e));
|
||||
}
|
||||
|
||||
_onDescription(description) {
|
||||
// description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400');
|
||||
this._conn.setLocalDescription(description)
|
||||
.then(_ => this._sendSignal({ sdp: description }))
|
||||
.catch(e => this._onError(e));
|
||||
}
|
||||
|
||||
_onIceCandidate(event) {
|
||||
if (!event.candidate) return;
|
||||
this._sendSignal({ ice: event.candidate });
|
||||
}
|
||||
|
||||
onServerMessage(message) {
|
||||
if (!this._conn) this._connect(message.sender, false);
|
||||
|
||||
if (message.sdp) {
|
||||
this._conn.setRemoteDescription(new RTCSessionDescription(message.sdp))
|
||||
.then( _ => {
|
||||
if (message.sdp.type === 'offer') {
|
||||
return this._conn.createAnswer()
|
||||
.then(d => this._onDescription(d));
|
||||
}
|
||||
})
|
||||
.catch(e => this._onError(e));
|
||||
} else if (message.ice) {
|
||||
this._conn.addIceCandidate(new RTCIceCandidate(message.ice));
|
||||
}
|
||||
}
|
||||
|
||||
_onChannelOpened(event) {
|
||||
console.log('RTC: channel opened with', this._peerId);
|
||||
Events.fire('peer-connected', this._peerId);
|
||||
const channel = event.channel || event.target;
|
||||
channel.binaryType = 'arraybuffer';
|
||||
channel.onmessage = e => this._onMessage(e.data);
|
||||
channel.onclose = e => this._onChannelClosed();
|
||||
this._channel = channel;
|
||||
}
|
||||
|
||||
_onChannelClosed() {
|
||||
console.log('RTC: channel closed', this._peerId);
|
||||
Events.fire('peer-disconnected', this._peerId);
|
||||
if (!this._isCaller) return;
|
||||
this._connect(this._peerId, true); // reopen the channel
|
||||
}
|
||||
|
||||
_onConnectionStateChange(e) {
|
||||
console.log('RTC: state changed:', this._conn.connectionState);
|
||||
switch (this._conn.connectionState) {
|
||||
case 'disconnected':
|
||||
this._onChannelClosed();
|
||||
break;
|
||||
case 'failed':
|
||||
this._conn = null;
|
||||
this._onChannelClosed();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onIceConnectionStateChange() {
|
||||
switch (this._conn.iceConnectionState) {
|
||||
case 'failed':
|
||||
console.error('ICE Gathering failed');
|
||||
Events.fire('force-disconnect');
|
||||
break;
|
||||
default:
|
||||
console.log('ICE Gathering', this._conn.iceConnectionState);
|
||||
}
|
||||
}
|
||||
|
||||
_onError(error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
_send(message) {
|
||||
if (!this._channel) return this.refresh();
|
||||
this._channel.send(message);
|
||||
}
|
||||
|
||||
_sendSignal(signal) {
|
||||
signal.type = 'signal';
|
||||
signal.to = this._peerId;
|
||||
this._server.send(signal);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
// check if channel is open. otherwise create one
|
||||
if (this._isConnected() || this._isConnecting()) return;
|
||||
this._connect(this._peerId, this._isCaller);
|
||||
}
|
||||
|
||||
_isConnected() {
|
||||
return this._channel && this._channel.readyState === 'open';
|
||||
}
|
||||
|
||||
_isConnecting() {
|
||||
return this._channel && this._channel.readyState === 'connecting';
|
||||
}
|
||||
}
|
||||
|
||||
class PeersManager {
|
||||
|
||||
constructor(serverConnection) {
|
||||
this.peers = {};
|
||||
this._server = serverConnection;
|
||||
Events.on('signal', e => this._onMessage(e.detail));
|
||||
Events.on('peers', e => this._onPeers(e.detail));
|
||||
Events.on('files-selected', e => this._onFilesSelected(e.detail));
|
||||
Events.on('send-text', e => this._onSendText(e.detail));
|
||||
Events.on('peer-left', e => this._onPeerLeft(e.detail));
|
||||
Events.on('disconnect', this._clearPeers);
|
||||
}
|
||||
|
||||
_onMessage(message) {
|
||||
if (!this.peers[message.sender]) {
|
||||
this.peers[message.sender] = new RTCPeer(this._server);
|
||||
}
|
||||
this.peers[message.sender].onServerMessage(message);
|
||||
}
|
||||
|
||||
_onPeers(peers) {
|
||||
peers.forEach(peer => {
|
||||
if (this.peers[peer.id]) {
|
||||
this.peers[peer.id].refresh();
|
||||
return;
|
||||
}
|
||||
if (window.isRtcSupported && peer.rtcSupported) {
|
||||
this.peers[peer.id] = new RTCPeer(this._server, peer.id);
|
||||
} else {
|
||||
this.peers[peer.id] = new WSPeer(this._server, peer.id);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendTo(peerId, message) {
|
||||
this.peers[peerId].send(message);
|
||||
}
|
||||
|
||||
_onFilesSelected(message) {
|
||||
this.peers[message.to].sendFiles(message.files);
|
||||
}
|
||||
|
||||
_onSendText(message) {
|
||||
this.peers[message.to].sendText(message.text);
|
||||
}
|
||||
|
||||
_onPeerLeft(peerId) {
|
||||
const peer = this.peers[peerId];
|
||||
delete this.peers[peerId];
|
||||
if (!peer || !peer._peer) return;
|
||||
peer._peer.close();
|
||||
}
|
||||
|
||||
_clearPeers() {
|
||||
if (this.peers) {
|
||||
Object.keys(this.peers).forEach(peerId => this._onPeerLeft(peerId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WSPeer extends Peer {
|
||||
_send(message) {
|
||||
message.to = this._peerId;
|
||||
this._server.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
class FileChunker {
|
||||
|
||||
constructor(file, onChunk, onPartitionEnd) {
|
||||
this._chunkSize = 64000; // 64 KB
|
||||
this._maxPartitionSize = 1e6; // 1 MB
|
||||
this._offset = 0;
|
||||
this._partitionSize = 0;
|
||||
this._file = file;
|
||||
this._onChunk = onChunk;
|
||||
this._onPartitionEnd = onPartitionEnd;
|
||||
this._reader = new FileReader();
|
||||
this._reader.addEventListener('load', e => this._onChunkRead(e.target.result));
|
||||
}
|
||||
|
||||
nextPartition() {
|
||||
this._partitionSize = 0;
|
||||
this._readChunk();
|
||||
}
|
||||
|
||||
_readChunk() {
|
||||
const chunk = this._file.slice(this._offset, this._offset + this._chunkSize);
|
||||
this._reader.readAsArrayBuffer(chunk);
|
||||
}
|
||||
|
||||
_onChunkRead(chunk) {
|
||||
this._offset += chunk.byteLength;
|
||||
this._partitionSize += chunk.byteLength;
|
||||
this._onChunk(chunk);
|
||||
if (this.isFileEnd()) return;
|
||||
if (this._isPartitionEnd()) {
|
||||
this._onPartitionEnd(this._offset);
|
||||
return;
|
||||
}
|
||||
this._readChunk();
|
||||
}
|
||||
|
||||
repeatPartition() {
|
||||
this._offset -= this._partitionSize;
|
||||
this._nextPartition();
|
||||
}
|
||||
|
||||
_isPartitionEnd() {
|
||||
return this._partitionSize >= this._maxPartitionSize;
|
||||
}
|
||||
|
||||
isFileEnd() {
|
||||
return this._offset >= this._file.size;
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return this._offset / this._file.size;
|
||||
}
|
||||
}
|
||||
|
||||
class FileDigester {
|
||||
|
||||
constructor(meta, callback) {
|
||||
this._buffer = [];
|
||||
this._bytesReceived = 0;
|
||||
this._size = meta.size;
|
||||
this._mime = meta.mime || 'application/octet-stream';
|
||||
this._name = meta.name;
|
||||
this._callback = callback;
|
||||
}
|
||||
|
||||
unchunk(chunk) {
|
||||
this._buffer.push(chunk);
|
||||
this._bytesReceived += chunk.byteLength || chunk.size;
|
||||
const totalChunks = this._buffer.length;
|
||||
this.progress = this._bytesReceived / this._size;
|
||||
if (isNaN(this.progress)) this.progress = 1
|
||||
|
||||
if (this._bytesReceived < this._size) return;
|
||||
// we are done
|
||||
let blob = new Blob(this._buffer, { type: this._mime });
|
||||
this._callback({
|
||||
name: this._name,
|
||||
mime: this._mime,
|
||||
size: this._size,
|
||||
blob: blob
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Events {
|
||||
static fire(type, detail) {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail: detail }));
|
||||
}
|
||||
|
||||
static on(type, callback) {
|
||||
return window.addEventListener(type, callback, false);
|
||||
}
|
||||
|
||||
static off(type, callback) {
|
||||
return window.removeEventListener(type, callback, false);
|
||||
}
|
||||
}
|
||||
|
||||
RTCPeer.config = {
|
||||
'sdpSemantics': 'unified-plan',
|
||||
// iceServers: [
|
||||
// {
|
||||
// urls: 'stun:127.0.0.1:3478',
|
||||
// },
|
||||
// {
|
||||
// urls: 'turn:127.0.0.1:3478',
|
||||
// username: 'snapdrop',
|
||||
// credential: 'ifupvrwelijmoyjxmefcsvfxxmcphvxo'
|
||||
// }
|
||||
// ]
|
||||
iceServers: [
|
||||
{
|
||||
urls: "stun:relay.metered.ca:80",
|
||||
},
|
||||
{
|
||||
urls: "turn:relay.metered.ca:80",
|
||||
username: "411061cd290de7ca6cc1a753",
|
||||
credential: "CuCIGdVfA9Gias1E",
|
||||
},
|
||||
{
|
||||
urls: "turn:relay.metered.ca:443",
|
||||
username: "411061cd290de7ca6cc1a753",
|
||||
credential: "CuCIGdVfA9Gias1E",
|
||||
},
|
||||
],
|
||||
// iceServers: [
|
||||
// {
|
||||
// urls: 'stun:stun.l.google.com:19302'
|
||||
// },
|
||||
// {
|
||||
// urls: 'turn:om.wulingate.com',
|
||||
// username: 'hmzJ0OHZivkod703',
|
||||
// credential: 'KDF04PBYD9xHAp0s'
|
||||
// },
|
||||
// ]
|
||||
}
|
37
public/scripts/theme.js
Normal file
37
public/scripts/theme.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
(function(){
|
||||
|
||||
// Select the button
|
||||
const btnTheme = document.getElementById('theme');
|
||||
// Check for dark mode preference at the OS level
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// Get the user's theme preference from local storage, if it's available
|
||||
const currentTheme = localStorage.getItem('theme');
|
||||
// If the user's preference in localStorage is dark...
|
||||
if (currentTheme == 'dark') {
|
||||
// ...let's toggle the .dark-theme class on the body
|
||||
document.body.classList.toggle('dark-theme');
|
||||
// Otherwise, if the user's preference in localStorage is light...
|
||||
} else if (currentTheme == 'light') {
|
||||
// ...let's toggle the .light-theme class on the body
|
||||
document.body.classList.toggle('light-theme');
|
||||
}
|
||||
|
||||
// Listen for a click on the button
|
||||
btnTheme.addEventListener('click', function() {
|
||||
// If the user's OS setting is dark and matches our .dark-theme class...
|
||||
if (prefersDarkScheme.matches) {
|
||||
// ...then toggle the light mode class
|
||||
document.body.classList.toggle('light-theme');
|
||||
// ...but use .dark-theme if the .light-theme class is already on the body,
|
||||
var theme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
|
||||
} else {
|
||||
// Otherwise, let's do the same thing, but for .dark-theme
|
||||
document.body.classList.toggle('dark-theme');
|
||||
var theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light';
|
||||
}
|
||||
// Finally, let's save the current preference to localStorage to keep using it
|
||||
localStorage.setItem('theme', theme);
|
||||
});
|
||||
|
||||
})();
|
666
public/scripts/ui.js
Normal file
666
public/scripts/ui.js
Normal file
|
@ -0,0 +1,666 @@
|
|||
const $ = query => document.getElementById(query);
|
||||
const $$ = query => document.body.querySelector(query);
|
||||
const isURL = text => /^((https?:\/\/|www)[^\s]+)/g.test(text.toLowerCase());
|
||||
window.isDownloadSupported = (typeof document.createElement('a').download !== 'undefined');
|
||||
window.isProductionEnvironment = !window.location.host.startsWith('localhost');
|
||||
window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
// set display name
|
||||
Events.on('display-name', e => {
|
||||
const me = e.detail.message;
|
||||
const $displayName = $('displayName')
|
||||
$displayName.textContent = 'You are known as ' + me.displayName;
|
||||
$displayName.title = me.deviceName;
|
||||
});
|
||||
|
||||
class PeersUI {
|
||||
|
||||
constructor() {
|
||||
Events.on('peer-joined', e => this._onPeerJoined(e.detail));
|
||||
Events.on('peer-left', e => this._onPeerLeft(e.detail));
|
||||
Events.on('peer-connected', e => this._onPeerConnected(e.detail));
|
||||
Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail));
|
||||
Events.on('peers', e => this._onPeers(e.detail));
|
||||
Events.on('file-progress', e => this._onFileProgress(e.detail));
|
||||
Events.on('paste', e => this._onPaste(e));
|
||||
Events.on('offline', () => this._clearPeers());
|
||||
this.peers = {};
|
||||
}
|
||||
|
||||
_onPeerJoined(peer) {
|
||||
if (this.peers[peer.id]) return; // peer already exists
|
||||
this.peers[peer.id] = peer;
|
||||
}
|
||||
|
||||
_onPeerConnected(peerId) {
|
||||
if(this.peers[peerId])
|
||||
new PeerUI(this.peers[peerId]);
|
||||
}
|
||||
|
||||
_onPeers(peers) {
|
||||
this._clearPeers();
|
||||
peers.forEach(peer => this._onPeerJoined(peer));
|
||||
}
|
||||
|
||||
_onPeerDisconnected(peerId) {
|
||||
const $peer = $(peerId);
|
||||
if (!$peer) return;
|
||||
$peer.remove();
|
||||
}
|
||||
|
||||
_onPeerLeft(peerId) {
|
||||
this._onPeerDisconnected(peerId);
|
||||
delete this.peers[peerId];
|
||||
}
|
||||
|
||||
_onFileProgress(progress) {
|
||||
const peerId = progress.sender || progress.recipient;
|
||||
const $peer = $(peerId);
|
||||
if (!$peer) return;
|
||||
$peer.ui.setProgress(progress.progress);
|
||||
}
|
||||
|
||||
_clearPeers() {
|
||||
const $peers = $$('x-peers').innerHTML = '';
|
||||
Object.keys(this.peers).forEach(peerId => delete this.peers[peerId]);
|
||||
}
|
||||
|
||||
_onPaste(e) {
|
||||
const files = e.clipboardData.files || e.clipboardData.items
|
||||
.filter(i => i.type.indexOf('image') > -1)
|
||||
.map(i => i.getAsFile());
|
||||
const peers = document.querySelectorAll('x-peer');
|
||||
// send the pasted image content to the only peer if there is one
|
||||
// otherwise, select the peer somehow by notifying the client that
|
||||
// "image data has been pasted, click the client to which to send it"
|
||||
// not implemented
|
||||
if (files.length > 0 && peers.length === 1) {
|
||||
Events.fire('files-selected', {
|
||||
files: files,
|
||||
to: $$('x-peer').id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PeerUI {
|
||||
|
||||
html() {
|
||||
return `
|
||||
<label class="column center" title="Click to send files or right click to send a text">
|
||||
<input type="file" multiple>
|
||||
<x-icon shadow="1">
|
||||
<svg class="icon"><use xlink:href="#"/></svg>
|
||||
</x-icon>
|
||||
<div class="progress">
|
||||
<div class="circle"></div>
|
||||
<div class="circle right"></div>
|
||||
</div>
|
||||
<div class="name font-subheading"></div>
|
||||
<div class="device-name font-body2"></div>
|
||||
<div class="status font-body2"></div>
|
||||
</label>`
|
||||
}
|
||||
|
||||
constructor(peer) {
|
||||
this._peer = peer;
|
||||
this._initDom();
|
||||
this._bindListeners(this.$el);
|
||||
$$('x-peers').appendChild(this.$el);
|
||||
setTimeout(e => window.animateBackground(false), 1750); // Stop animation
|
||||
}
|
||||
|
||||
_initDom() {
|
||||
const el = document.createElement('x-peer');
|
||||
el.id = this._peer.id;
|
||||
el.innerHTML = this.html();
|
||||
el.ui = this;
|
||||
el.querySelector('svg use').setAttribute('xlink:href', this._icon());
|
||||
el.querySelector('.name').textContent = this._displayName();
|
||||
el.querySelector('.device-name').textContent = this._deviceName();
|
||||
this.$el = el;
|
||||
this.$progress = el.querySelector('.progress');
|
||||
}
|
||||
|
||||
_bindListeners(el) {
|
||||
el.querySelector('input').addEventListener('change', e => this._onFilesSelected(e));
|
||||
el.addEventListener('drop', e => this._onDrop(e));
|
||||
el.addEventListener('dragend', e => this._onDragEnd(e));
|
||||
el.addEventListener('dragleave', e => this._onDragEnd(e));
|
||||
el.addEventListener('dragover', e => this._onDragOver(e));
|
||||
el.addEventListener('contextmenu', e => this._onRightClick(e));
|
||||
el.addEventListener('touchstart', e => this._onTouchStart(e));
|
||||
el.addEventListener('touchend', e => this._onTouchEnd(e));
|
||||
// prevent browser's default file drop behavior
|
||||
Events.on('dragover', e => e.preventDefault());
|
||||
Events.on('drop', e => e.preventDefault());
|
||||
}
|
||||
|
||||
_displayName() {
|
||||
return this._peer.name.displayName;
|
||||
}
|
||||
|
||||
_deviceName() {
|
||||
return this._peer.name.deviceName;
|
||||
}
|
||||
|
||||
_icon() {
|
||||
const device = this._peer.name.device || this._peer.name;
|
||||
if (device.type === 'mobile') {
|
||||
return '#phone-iphone';
|
||||
}
|
||||
if (device.type === 'tablet') {
|
||||
return '#tablet-mac';
|
||||
}
|
||||
return '#desktop-mac';
|
||||
}
|
||||
|
||||
_onFilesSelected(e) {
|
||||
const $input = e.target;
|
||||
const files = $input.files;
|
||||
Events.fire('files-selected', {
|
||||
files: files,
|
||||
to: this._peer.id
|
||||
});
|
||||
$input.value = null; // reset input
|
||||
}
|
||||
|
||||
setProgress(progress) {
|
||||
if (progress > 0) {
|
||||
this.$el.setAttribute('transfer', '1');
|
||||
}
|
||||
if (progress > 0.5) {
|
||||
this.$progress.classList.add('over50');
|
||||
} else {
|
||||
this.$progress.classList.remove('over50');
|
||||
}
|
||||
const degrees = `rotate(${360 * progress}deg)`;
|
||||
this.$progress.style.setProperty('--progress', degrees);
|
||||
if (progress >= 1) {
|
||||
this.setProgress(0);
|
||||
this.$el.removeAttribute('transfer');
|
||||
}
|
||||
}
|
||||
|
||||
_onDrop(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
Events.fire('files-selected', {
|
||||
files: files,
|
||||
to: this._peer.id
|
||||
});
|
||||
this._onDragEnd();
|
||||
}
|
||||
|
||||
_onDragOver() {
|
||||
this.$el.setAttribute('drop', 1);
|
||||
}
|
||||
|
||||
_onDragEnd() {
|
||||
this.$el.removeAttribute('drop');
|
||||
}
|
||||
|
||||
_onRightClick(e) {
|
||||
e.preventDefault();
|
||||
Events.fire('text-recipient', this._peer.id);
|
||||
}
|
||||
|
||||
_onTouchStart(e) {
|
||||
this._touchStart = Date.now();
|
||||
this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610);
|
||||
}
|
||||
|
||||
_onTouchEnd(e) {
|
||||
if (Date.now() - this._touchStart < 500) {
|
||||
clearTimeout(this._touchTimer);
|
||||
} else { // this was a long tap
|
||||
if (e) e.preventDefault();
|
||||
Events.fire('text-recipient', this._peer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Dialog {
|
||||
constructor(id) {
|
||||
this.$el = $(id);
|
||||
this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', e => this.hide()))
|
||||
this.$el.querySelectorAll('[role="textbox"]').forEach((el) => {
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
})
|
||||
this.$autoFocus = this.$el.querySelector('[autofocus]');
|
||||
}
|
||||
|
||||
show() {
|
||||
this.$el.setAttribute('show', 1);
|
||||
if (this.$autoFocus) this.$autoFocus.focus();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.$el.removeAttribute('show');
|
||||
document.activeElement.blur();
|
||||
window.blur();
|
||||
}
|
||||
}
|
||||
|
||||
class ReceiveDialog extends Dialog {
|
||||
|
||||
constructor() {
|
||||
super('receiveDialog');
|
||||
Events.on('file-received', e => {
|
||||
this._nextFile(e.detail);
|
||||
window.blop.play();
|
||||
});
|
||||
this._filesQueue = [];
|
||||
}
|
||||
|
||||
_nextFile(nextFile) {
|
||||
if (nextFile) this._filesQueue.push(nextFile);
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
const file = this._filesQueue.shift();
|
||||
this._displayFile(file);
|
||||
}
|
||||
|
||||
_dequeueFile() {
|
||||
if (!this._filesQueue.length) { // nothing to do
|
||||
this._busy = false;
|
||||
return;
|
||||
}
|
||||
// dequeue next file
|
||||
setTimeout(_ => {
|
||||
this._busy = false;
|
||||
this._nextFile();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
_displayFile(file) {
|
||||
const $a = this.$el.querySelector('#download');
|
||||
const url = URL.createObjectURL(file.blob);
|
||||
$a.href = url;
|
||||
$a.download = file.name;
|
||||
|
||||
if(this._autoDownload()){
|
||||
$a.click()
|
||||
return
|
||||
}
|
||||
if(file.mime.split('/')[0] === 'image'){
|
||||
console.log('the file is image');
|
||||
this.$el.querySelector('.preview').style.visibility = 'inherit';
|
||||
this.$el.querySelector("#img-preview").src = url;
|
||||
}
|
||||
|
||||
this.$el.querySelector('#fileName').textContent = file.name;
|
||||
this.$el.querySelector('#fileSize').textContent = this._formatFileSize(file.size);
|
||||
this.show();
|
||||
|
||||
if (window.isDownloadSupported) return;
|
||||
// fallback for iOS
|
||||
$a.target = '_blank';
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => $a.href = reader.result;
|
||||
reader.readAsDataURL(file.blob);
|
||||
}
|
||||
|
||||
_formatFileSize(bytes) {
|
||||
if (bytes >= 1e9) {
|
||||
return (Math.round(bytes / 1e8) / 10) + ' GB';
|
||||
} else if (bytes >= 1e6) {
|
||||
return (Math.round(bytes / 1e5) / 10) + ' MB';
|
||||
} else if (bytes > 1000) {
|
||||
return Math.round(bytes / 1000) + ' KB';
|
||||
} else {
|
||||
return bytes + ' Bytes';
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.$el.querySelector('.preview').style.visibility = 'hidden';
|
||||
this.$el.querySelector("#img-preview").src = "";
|
||||
super.hide();
|
||||
this._dequeueFile();
|
||||
}
|
||||
|
||||
|
||||
_autoDownload(){
|
||||
return !this.$el.querySelector('#autoDownload').checked
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SendTextDialog extends Dialog {
|
||||
constructor() {
|
||||
super('sendTextDialog');
|
||||
Events.on('text-recipient', e => this._onRecipient(e.detail))
|
||||
this.$text = this.$el.querySelector('#textInput');
|
||||
const button = this.$el.querySelector('form');
|
||||
button.addEventListener('submit', e => this._send(e));
|
||||
}
|
||||
|
||||
_onRecipient(recipient) {
|
||||
this._recipient = recipient;
|
||||
this._handleShareTargetText();
|
||||
this.show();
|
||||
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
|
||||
range.selectNodeContents(this.$text);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
}
|
||||
|
||||
_handleShareTargetText() {
|
||||
if (!window.shareTargetText) return;
|
||||
this.$text.textContent = window.shareTargetText;
|
||||
window.shareTargetText = '';
|
||||
}
|
||||
|
||||
_send(e) {
|
||||
e.preventDefault();
|
||||
Events.fire('send-text', {
|
||||
to: this._recipient,
|
||||
text: this.$text.innerText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ReceiveTextDialog extends Dialog {
|
||||
constructor() {
|
||||
super('receiveTextDialog');
|
||||
Events.on('text-received', e => this._onText(e.detail))
|
||||
this.$text = this.$el.querySelector('#text');
|
||||
const $copy = this.$el.querySelector('#copy');
|
||||
copy.addEventListener('click', _ => this._onCopy());
|
||||
}
|
||||
|
||||
_onText(e) {
|
||||
this.$text.innerHTML = '';
|
||||
const text = e.text;
|
||||
if (isURL(text)) {
|
||||
const $a = document.createElement('a');
|
||||
$a.href = text;
|
||||
$a.target = '_blank';
|
||||
$a.textContent = text;
|
||||
this.$text.appendChild($a);
|
||||
} else {
|
||||
this.$text.textContent = text;
|
||||
}
|
||||
this.show();
|
||||
window.blop.play();
|
||||
}
|
||||
|
||||
async _onCopy() {
|
||||
await navigator.clipboard.writeText(this.$text.textContent);
|
||||
Events.fire('notify-user', 'Copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
class Toast extends Dialog {
|
||||
constructor() {
|
||||
super('toast');
|
||||
Events.on('notify-user', e => this._onNotfiy(e.detail));
|
||||
}
|
||||
|
||||
_onNotfiy(message) {
|
||||
this.$el.textContent = message;
|
||||
this.show();
|
||||
setTimeout(_ => this.hide(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Notifications {
|
||||
|
||||
constructor() {
|
||||
// Check if the browser supports notifications
|
||||
if (!('Notification' in window)) return;
|
||||
|
||||
// Check whether notification permissions have already been granted
|
||||
if (Notification.permission !== 'granted') {
|
||||
this.$button = $('notification');
|
||||
this.$button.removeAttribute('hidden');
|
||||
this.$button.addEventListener('click', e => this._requestPermission());
|
||||
}
|
||||
Events.on('text-received', e => this._messageNotification(e.detail.text));
|
||||
Events.on('file-received', e => this._downloadNotification(e.detail.name));
|
||||
}
|
||||
|
||||
_requestPermission() {
|
||||
Notification.requestPermission(permission => {
|
||||
if (permission !== 'granted') {
|
||||
Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error');
|
||||
return;
|
||||
}
|
||||
this._notify('Even more snappy sharing!');
|
||||
this.$button.setAttribute('hidden', 1);
|
||||
});
|
||||
}
|
||||
|
||||
_notify(message, body) {
|
||||
const config = {
|
||||
body: body,
|
||||
icon: '/images/logo_transparent_128x128.png',
|
||||
}
|
||||
let notification;
|
||||
try {
|
||||
notification = new Notification(message, config);
|
||||
} catch (e) {
|
||||
// Android doesn't support "new Notification" if service worker is installed
|
||||
if (!serviceWorker || !serviceWorker.showNotification) return;
|
||||
notification = serviceWorker.showNotification(message, config);
|
||||
}
|
||||
|
||||
// Notification is persistent on Android. We have to close it manually
|
||||
const visibilitychangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
notification.close();
|
||||
Events.off('visibilitychange', visibilitychangeHandler);
|
||||
}
|
||||
};
|
||||
Events.on('visibilitychange', visibilitychangeHandler);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
_messageNotification(message) {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
if (isURL(message)) {
|
||||
const notification = this._notify(message, 'Click to open link');
|
||||
this._bind(notification, e => window.open(message, '_blank', null, true));
|
||||
} else {
|
||||
const notification = this._notify(message, 'Click to copy text');
|
||||
this._bind(notification, e => this._copyText(message, notification));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_downloadNotification(message) {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
const notification = this._notify(message, 'Click to download');
|
||||
if (!window.isDownloadSupported) return;
|
||||
this._bind(notification, e => this._download(notification));
|
||||
}
|
||||
}
|
||||
|
||||
_download(notification) {
|
||||
document.querySelector('x-dialog [download]').click();
|
||||
notification.close();
|
||||
}
|
||||
|
||||
_copyText(message, notification) {
|
||||
notification.close();
|
||||
if (!navigator.clipboard.writeText(message)) return;
|
||||
this._notify('Copied text to clipboard');
|
||||
}
|
||||
|
||||
_bind(notification, handler) {
|
||||
if (notification.then) {
|
||||
notification.then(e => serviceWorker.getNotifications().then(notifications => {
|
||||
serviceWorker.addEventListener('notificationclick', handler);
|
||||
}));
|
||||
} else {
|
||||
notification.onclick = handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NetworkStatusUI {
|
||||
|
||||
constructor() {
|
||||
Events.on('offline', this._showOfflineMessage);
|
||||
Events.on('online', this._showOnlineMessage);
|
||||
if (!navigator.onLine) this._showOfflineMessage();
|
||||
}
|
||||
|
||||
_showOfflineMessage() {
|
||||
Events.fire('notify-user', 'You are offline');
|
||||
}
|
||||
|
||||
_showOnlineMessage() {
|
||||
Events.fire('notify-user', 'You are back online');
|
||||
}
|
||||
}
|
||||
|
||||
class WebShareTargetUI {
|
||||
constructor() {
|
||||
const parsedUrl = new URL(window.location);
|
||||
const title = parsedUrl.searchParams.get('title');
|
||||
const text = parsedUrl.searchParams.get('text');
|
||||
const url = parsedUrl.searchParams.get('url');
|
||||
|
||||
let shareTargetText = title ? title : '';
|
||||
shareTargetText += text ? shareTargetText ? ' ' + text : text : '';
|
||||
|
||||
if(url) shareTargetText = url; // We share only the Link - no text. Because link-only text becomes clickable.
|
||||
|
||||
if (!shareTargetText) return;
|
||||
window.shareTargetText = shareTargetText;
|
||||
history.pushState({}, 'URL Rewrite', '/');
|
||||
console.log('Shared Target Text:', '"' + shareTargetText + '"');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Snapdrop {
|
||||
constructor() {
|
||||
const server = new ServerConnection();
|
||||
const peers = new PeersManager(server);
|
||||
const peersUI = new PeersUI();
|
||||
Events.on('load', e => {
|
||||
const receiveDialog = new ReceiveDialog();
|
||||
const sendTextDialog = new SendTextDialog();
|
||||
const receiveTextDialog = new ReceiveTextDialog();
|
||||
const toast = new Toast();
|
||||
const notifications = new Notifications();
|
||||
const networkStatusUI = new NetworkStatusUI();
|
||||
const webShareTargetUI = new WebShareTargetUI();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const snapdrop = new Snapdrop();
|
||||
|
||||
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(serviceWorker => {
|
||||
console.log('Service Worker registered');
|
||||
window.serviceWorker = serviceWorker
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
// don't display install banner when installed
|
||||
return e.preventDefault();
|
||||
} else {
|
||||
const btn = document.querySelector('#install')
|
||||
btn.hidden = false;
|
||||
btn.onclick = _ => e.prompt();
|
||||
return e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Background Animation
|
||||
Events.on('load', () => {
|
||||
let c = document.createElement('canvas');
|
||||
document.body.appendChild(c);
|
||||
let style = c.style;
|
||||
style.width = '100%';
|
||||
style.position = 'absolute';
|
||||
style.zIndex = -1;
|
||||
style.top = 0;
|
||||
style.left = 0;
|
||||
let ctx = c.getContext('2d');
|
||||
let x0, y0, w, h, dw;
|
||||
|
||||
function init() {
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
let offset = h > 380 ? 100 : 65;
|
||||
offset = h > 800 ? 116 : offset;
|
||||
x0 = w / 2;
|
||||
y0 = h - offset;
|
||||
dw = Math.max(w, h, 1000) / 13;
|
||||
drawCircles();
|
||||
}
|
||||
window.onresize = init;
|
||||
|
||||
function drawCircle(radius) {
|
||||
ctx.beginPath();
|
||||
let color = Math.round(255 * (1 - radius / Math.max(w, h)));
|
||||
ctx.strokeStyle = 'rgba(' + color + ',' + color + ',' + color + ',0.1)';
|
||||
ctx.arc(x0, y0, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 2;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
|
||||
function drawCircles() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
drawCircle(dw * i + step % dw);
|
||||
}
|
||||
step += 1;
|
||||
}
|
||||
|
||||
let loading = true;
|
||||
|
||||
function animate() {
|
||||
if (loading || step % dw < dw - 5) {
|
||||
requestAnimationFrame(function() {
|
||||
drawCircles();
|
||||
animate();
|
||||
});
|
||||
}
|
||||
}
|
||||
window.animateBackground = function(l) {
|
||||
loading = l;
|
||||
animate();
|
||||
};
|
||||
init();
|
||||
animate();
|
||||
});
|
||||
|
||||
Notifications.PERMISSION_ERROR = `
|
||||
Notifications permission has been blocked
|
||||
as the user has dismissed the permission prompt several times.
|
||||
This can be reset in Page Info
|
||||
which can be accessed by clicking the lock icon next to the URL.`;
|
||||
|
||||
document.body.onclick = e => { // safari hack to fix audio
|
||||
document.body.onclick = null;
|
||||
if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return;
|
||||
blop.play();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue