Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Play selected segment #116

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 28 additions & 8 deletions src/static/src/App.js
Expand Up @@ -3,6 +3,7 @@ import Error from "./components/Error.js";
import Header from "./components/Header.js";
import Stats from "./components/Stats.js";
import Settings from "./components/Settings.js";
import Editor from "./components/Editor.js";
import Queue from "./components/Queue.js";
import UploadForm from "./components/UploadForm.js";
import { allowedFileTypes, images } from "./utils/constants.js";
Expand All @@ -12,6 +13,7 @@ const App = () => {
const [image, setImage] = useState({});
const [isDragging, setIsDragging] = useState(false);
const [fileStored, setFileStored] = useState(null);
const [jojoDoc, setJojoDoc] = useState(null);
const [errorMessage, setErrorMessage] = useState("");
const [uploadStatus, setUploadStatus] = useState(null);
const [jobId, setJobId] = useState(null);
Expand All @@ -25,6 +27,26 @@ const App = () => {
setImage(images[nextIndex]);
}, [images]);

const handleFile = (file) => {
if (file.name.endsWith(".jojo")) {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
const doc = JSON.parse(event.target.result);
setJojoDoc(doc);
setUploadStatus("edit");
});

reader.readAsText(file);
return;
}

if (!allowedFileTypes.some((type) => file.type.includes(type)))
return setErrorMessage("Please upload a Jojo or audio/video file");

setFileStored(file);
setUploadStatus("pending");
};

const handleDragEvent = (event) => {
event.preventDefault();
event.stopPropagation();
Expand All @@ -51,11 +73,8 @@ const App = () => {

const file = files[0];

if (!allowedFileTypes.some((type) => file.type.includes(type)))
return setErrorMessage("Please upload a valid audio or video file");
handleFile(file);

setFileStored(file);
setUploadStatus("pending");
break;

default:
Expand All @@ -79,6 +98,10 @@ const App = () => {
return html`<${DragAndDrop} />`;
}

if (uploadStatus === "edit") {
return html`<${Editor} jojoDoc=${jojoDoc} />`;
}

if (uploadStatus === "pending") {
return html`<${Settings}
fileStored=${fileStored}
Expand Down Expand Up @@ -117,10 +140,7 @@ const App = () => {
<main class="main">
<${UploadForm}
onChange=${(file) => {
if (!allowedFileTypes.some((type) => file.type.includes(type)))
return setErrorMessage("Please upload a valid audio or video file");
setFileStored(file);
setUploadStatus("pending");
handleFile(file);
}}
accentColor=${image.accentColor}
/>
Expand Down
55 changes: 55 additions & 0 deletions src/static/src/components/AudioPlayer.js
@@ -0,0 +1,55 @@
const AudioPlayer = ({ audio, cursor }) => {
const { useState, useEffect } = preact;

let audioCtx = new AudioContext();
const offlineCtx = new OfflineAudioContext(2, 44100 * 40, 44100);
const [blob, setBlob] = useState();
let isPlaying = false;

useEffect(() => {
const audioElement = document.getElementById("audio-editor");

if (!isPlaying && cursor.length > 0) {
const clip = new AudioBufferSourceNode(audioCtx, {
buffer: blob,
});

clip.connect(audioCtx.destination);
clip.start(0, cursor.cursor, cursor.length);
isPlaying = true;

return () => {
isPlaying = false;
try {
clip.stop();
} catch (err) {}
};
}
}, [blob, cursor]);

useEffect(() => {
const audioElement = document.getElementById("audio-editor");
if (!blob) {
window.pulse("track", "engagementEvent", {
type: "Engagement",
action: "Click",
object: {
type: "UIElement",
"@id": `sdrn:jojo:page:editor:element:audioplayer`,
name: "File uploaded",
},
});
const b = new Blob([audio], { type: audio.type });
audioElement.src = window.URL.createObjectURL(b);
audioElement.controls = true;

b.arrayBuffer()
.then((buffer) => audioCtx.decodeAudioData(buffer))
.then(setBlob);
}
}, [audio]);

return html`<audio id="audio-editor"></audio>`;
};

export default AudioPlayer;
162 changes: 162 additions & 0 deletions src/static/src/components/Editor.js
@@ -0,0 +1,162 @@
import Table from "./Table.js";
import AudioPlayer from "./AudioPlayer.js";
import { PlusIcon } from "./icons/index.js";
import toTimeString from "../utils/toTimeString.js";
import { allowedFileTypes } from "../utils/constants.js";

const UploadForm = ({ onChange, accentColor }) => {
return html`
<form
class="upload-file"
onchange=${(event) => {
const file = event.target.files[0];
onChange(file);
}}
>
<label
for="file-upload"
id="file-upload-button"
style=${{ backgroundColor: accentColor }}
class="file-upload-button"
>
<input
id="file-upload"
name="file-dropzone-upload"
type="file"
class="sr-only"
/>
<${PlusIcon} />
Choose audio file
</label>
</form>
`;
};

const AudioOrUpload = ({ audio, cursor, setAudio }) => {
if (audio) {
return html`<${AudioPlayer} cursor=${cursor} audio=${audio} /><br />`;
}
return html`<${UploadForm}
onChange=${(file) => {
if (allowedFileTypes.some((type) => file.type.includes(type))) {
setAudio(file);
return;
}
alert("Please choose a valid audio or video file");
}}
/>`;
};

const Editor = ({ jojoDoc }) => {
const { useState, useEffect } = preact;
const [cursor, setCursor] = useState({ cursor: 0.0, length: 0.0 });
const [audio, setAudio] = useState();

useEffect(() => {
window.pulse("trackPageView", {
object: {
id: "editor",
type: "Page",
name: "Editor",
},
});
}, []);

const download = async (type) => {
const a = document.createElement("a");
a.download = (audio ? audio.name : "Transcription") + "." + type;
const data = async () => {
switch (type) {
case "jojo":
return JSON.stringify(jojoDoc);
break;

case "srt":
return jojoDoc.segments
.map((segment, index) => {
return [
index + 1,
toTimeString(segment.timeStart / 100) +
",000 --> " +
toTimeString(segment.timeEnd / 100) +
",000",
segment.text.replace("-->", ""),
].join("\n");
})
.join("\n\n");
break;

case "txt":
return jojoDoc.segments
.map(
(segment, index) =>
`${toTimeString(segment.timeStart / 100)} | ${segment.text}`
)
.join("\n");
break;

case "csv":
return [
"Time;Transcription",
...jojoDoc.segments.map(
(segment, index) =>
`${toTimeString(
segment.timeStart / 100
)};${segment.text.replace(";", "")}`
),
].join("\n");
break;

default:
break;
}
};
a.href =
"data:application/octet-stream;charset=utf-8;base64," +
btoa(await data());
a.click();
};

return html`<div>
<main class="editor">
<div class="file-info">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12.75 4.75h-5a2 2 0 0 0-2 2v10.5c0 1.1.9 2 2 2h8.5a2 2 0 0 0 2-2v-7m-5.5-5.5v3.5c0 1.1.9 2 2 2h3.5m-5.5-5.5 5.5 5.5"
></path>
</svg>
<p>${audio ? audio.name : "No audio file."}</p>
</div>
<br />
<${AudioOrUpload} audio=${audio} cursor=${cursor} setAudio=${setAudio} />
<br />
<div id="save">
<button onclick=${() => download("jojo")}>Save (.jojo)</button>
<div>
<button onclick=${() => download("srt")}>.srt</button>
<button onclick=${() => download("txt")}>.txt</button>
<button onclick=${() => download("csv")}>.csv</button>
</div>
</div>
</main>
<div class="table-container">
<${Table}
hasAudio=${audio}
jojoDoc=${jojoDoc}
setCursor=${setCursor}
audio=${audio}
/>
</div>
</div>`;
};

export default Editor;
100 changes: 100 additions & 0 deletions src/static/src/components/Table.js
@@ -0,0 +1,100 @@
import toTimeString from "../utils/toTimeString.js";
import { PlayIcon } from "./icons/index.js";

const Table = ({ jojoDoc, hasAudio = false, setCursor }) => {
const { useState, useEffect } = preact;
const [editElement, setEditElement] = useState();
const text = jojoDoc.segments;

useEffect(() => {
let el = editElement;
if (!el) return () => {};

const id = el.dataset.id;
const listener = () => {
const inputedText = el.querySelector("[contenteditable]").innerText;
const textRef = text.find((t) => t.id == id);
if (textRef.text !== inputedText) {
textRef.text = inputedText;
}
};

el.classList.toggle("selected");
el.addEventListener("focusout", listener, false);

return () => {
el.classList.remove("selected");
el.removeEventListener("focusout", listener);
};
}, [editElement]);

useEffect(() => {
const transcriptionTable = document.getElementById("transcription");
const clickListener = (event) => {
const el = event.target;
const tr = el.closest("tr");
const playBtn = el.closest("svg");
if (playBtn && playBtn.nodeName === "svg") {
if (playBtn.classList.contains("playing")) {
setCursor({
cursor: 0,
length: 0,
});
playBtn.classList.toggle("playing");
} else {
playBtn.classList.toggle("playing");
setCursor({
cursor: parseFloat(tr.dataset.cursor) || 0,
length: parseFloat(tr.dataset.length) || 0,
});
}
}
};

const focusListener = (event) => {
const tr = event.target.closest("tr");
if (tr.nodeName !== "TR") return;
setEditElement(tr);
};

transcriptionTable.addEventListener("click", clickListener);
transcriptionTable.addEventListener("focus", focusListener, true);

return () => {
transcriptionTable.removeEventListener("click", clickListener);
transcriptionTable.removeEventListener("focus", focusListener);
};
}, []);

const rows = text.map(
(t) =>
html`<tr
key="${t.id}"
data-cursor="${t.timeStart / 100}"
data-length="${(t.timeEnd - t.timeStart) / 100}"
data-id="${t.id}"
>
${hasAudio &&
html`<td class="play-button-cell">
<${PlayIcon} />
</td>`}
<td>${toTimeString(t.timeStart / 100)}</td>
<td contenteditable="true" spellcheck="true">${t.text}</td>
</tr>`
);

return html`
<table id="transcription" cellpadding="10" cellspacing="5">
<tr>
${hasAudio &&
html`<th>
<b>Play</b>
</th>`}
<th><b>Time</b></th>
<th align="left"><b>Transcription</b></th>
</tr>
${rows}
</table>
`;
};
export default Table;