diff --git a/packages/studio-web/src/app/app.component.html b/packages/studio-web/src/app/app.component.html index 9d6757f5..74476173 100644 --- a/packages/studio-web/src/app/app.component.html +++ b/packages/studio-web/src/app/app.component.html @@ -1,2 +1,33 @@ + + + ReadAlong Studio + + + + -
@readalongs/studio-web version: {{ version }}
+ diff --git a/packages/studio-web/src/app/app.component.sass b/packages/studio-web/src/app/app.component.sass index 64ec5a06..d5af050b 100644 --- a/packages/studio-web/src/app/app.component.sass +++ b/packages/studio-web/src/app/app.component.sass @@ -7,3 +7,9 @@ align-items: center justify-content: space-between align-content:space-around + +.nav__button + align-items: right + +.nav-spacer + flex: 1 1 auto \ No newline at end of file diff --git a/packages/studio-web/src/app/app.component.ts b/packages/studio-web/src/app/app.component.ts index 701f2ca2..daffe2b6 100644 --- a/packages/studio-web/src/app/app.component.ts +++ b/packages/studio-web/src/app/app.component.ts @@ -1,7 +1,8 @@ -import { Subject } from "rxjs"; - +import { Subject, takeUntil } from "rxjs"; +import { MatDialogRef, MatDialog } from "@angular/material/dialog"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { environment } from "../environments/environment"; +import { Router } from "@angular/router"; @Component({ selector: "app-root", templateUrl: "./app.component.html", @@ -10,8 +11,26 @@ import { environment } from "../environments/environment"; export class AppComponent implements OnDestroy, OnInit { unsubscribe$ = new Subject(); version = environment.packageJson.singleFileBundleVersion; - constructor() {} - ngOnInit(): void {} + currentURL = "/"; + constructor( + private dialog: MatDialog, + public router: Router, + ) {} + ngOnInit(): void { + this.router.events.pipe(takeUntil(this.unsubscribe$)).subscribe((event) => { + if (event.type === 1) { + this.currentURL = event.url; + } + }); + } + + openPrivacyDialog(): void { + this.dialog.open(PrivacyDialog, { + width: "50vw", + maxWidth: "50vw", // maxWidth is required to force material to use justify-content: flex-start + minWidth: "50vw", + }); + } ngOnDestroy(): void { this.unsubscribe$.next(); @@ -20,3 +39,26 @@ export class AppComponent implements OnDestroy, OnInit { ngAfterViewInit() {} } + +@Component({ + selector: "privacy-dialog", + templateUrl: "privacy-dialog.html", +}) +export class PrivacyDialog { + analyticsExcluded = + window.localStorage.getItem("plausible_ignore") === "true"; + constructor(public dialogRef: MatDialogRef) {} + ngOnInit() { + this.dialogRef.updateSize("100%"); + } + + toggleAnalytics() { + if (this.analyticsExcluded) { + window.localStorage.removeItem("plausible_ignore"); + } else { + window.localStorage.setItem("plausible_ignore", "true"); + } + this.analyticsExcluded = + window.localStorage.getItem("plausible_ignore") === "true"; + } +} diff --git a/packages/studio-web/src/app/app.module.ts b/packages/studio-web/src/app/app.module.ts index 11677665..8e291425 100644 --- a/packages/studio-web/src/app/app.module.ts +++ b/packages/studio-web/src/app/app.module.ts @@ -15,7 +15,8 @@ import { UploadComponent } from "./upload/upload.component"; import { TextFormatDialogComponent } from "./text-format-dialog/text-format-dialog.component"; import { NgxRAWebComponentModule } from "@readalongs/ngx-web-component"; import { defineCustomElements } from "@readalongs/web-component/loader"; -import { StudioComponent, PrivacyDialog } from "./studio/studio.component"; +import { StudioComponent } from "./studio/studio.component"; +import { PrivacyDialog } from "./app.component"; import { ErrorPageComponent } from "./error-page/error-page.component"; import { EditorComponent } from "./editor/editor.component"; import { SharedModule } from "./shared/shared.module"; diff --git a/packages/studio-web/src/app/demo/demo.component.html b/packages/studio-web/src/app/demo/demo.component.html index 4cdb469f..c826398f 100644 --- a/packages/studio-web/src/app/demo/demo.component.html +++ b/packages/studio-web/src/app/demo/demo.component.html @@ -1,6 +1,5 @@
- -
+

-
+
-
- - - - +
+
+ + + + +
-
diff --git a/packages/studio-web/src/app/demo/demo.component.spec.ts b/packages/studio-web/src/app/demo/demo.component.spec.ts index 95d7e6d4..52b0f51a 100644 --- a/packages/studio-web/src/app/demo/demo.component.spec.ts +++ b/packages/studio-web/src/app/demo/demo.component.spec.ts @@ -38,10 +38,10 @@ describe("DemoComponent", () => { }); it(`should have as title 'Title'`, () => { - expect(component.slots.title).toEqual("Title"); + expect(component.studioService.slots.title).toEqual("Title"); }); it(`should have as subtitle 'SubTitle'`, () => { - expect(component.slots.subtitle).toEqual("Subtitle"); + expect(component.studioService.slots.subtitle).toEqual("Subtitle"); }); }); diff --git a/packages/studio-web/src/app/demo/demo.component.ts b/packages/studio-web/src/app/demo/demo.component.ts index 03ec811a..b783f0a6 100644 --- a/packages/studio-web/src/app/demo/demo.component.ts +++ b/packages/studio-web/src/app/demo/demo.component.ts @@ -1,10 +1,13 @@ -import { Observable, Subject } from "rxjs"; +import { Subject } from "rxjs"; -import { Component, Input, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { Components } from "@readalongs/web-component/loader"; import { B64Service } from "../b64.service"; -import { ReadAlongSlots } from "../ras.service"; +import { StudioService } from "../studio/studio.service"; +import { DownloadService } from "../shared/download/download.service"; +import { SupportedOutputs } from "../ras.service"; +import { ToastrService } from "ngx-toastr"; @Component({ selector: "app-demo", @@ -12,17 +15,15 @@ import { ReadAlongSlots } from "../ras.service"; styleUrls: ["./demo.component.sass"], }) export class DemoComponent implements OnDestroy, OnInit { - @Input() b64Inputs: [string, Document]; - @Input() render$: Observable; @ViewChild("readalong") readalong!: Components.ReadAlong; - slots: ReadAlongSlots = { - title: $localize`Title`, - subtitle: $localize`Subtitle`, - }; language: "eng" | "fra" | "spa" = "eng"; unsubscribe$ = new Subject(); - - constructor(public b64Service: B64Service) { + constructor( + public b64Service: B64Service, + public studioService: StudioService, + private downloadService: DownloadService, + private toastr: ToastrService, + ) { // If we do more languages, this should be a lookup table if ($localize.locale == "fr") { this.language = "fra"; @@ -33,8 +34,41 @@ export class DemoComponent implements OnDestroy, OnInit { ngOnInit(): void {} - ngOnDestroy(): void { + ngAfterViewInit(): void {} + download(download_type: SupportedOutputs) { + if ( + this.studioService.b64Inputs$.value && + this.studioService.b64Inputs$.value[1] + ) { + this.downloadService.download( + download_type, + this.studioService.b64Inputs$.value[0], + this.studioService.b64Inputs$.value[1], + this.studioService.slots, + this.readalong, + ); + } else { + this.toastr.error($localize`Download failed.`, $localize`Sorry!`, { + timeOut: 10000, + }); + } + } + + async ngOnDestroy() { this.unsubscribe$.next(); this.unsubscribe$.complete(); + // Save translations, images and all other edits to the studio service when we exit + if (this.studioService.b64Inputs$.value[1]) { + await this.downloadService.updateTranslations( + this.studioService.b64Inputs$.value[1], + this.readalong, + ); + await this.downloadService.updateImages( + this.studioService.b64Inputs$.value[1], + true, + "image", + this.readalong, + ); + } } } diff --git a/packages/studio-web/src/app/editor/editor.component.html b/packages/studio-web/src/app/editor/editor.component.html index d937aaa9..3964fe78 100644 --- a/packages/studio-web/src/app/editor/editor.component.html +++ b/packages/studio-web/src/app/editor/editor.component.html @@ -1,16 +1,4 @@ -
-
- -
-
+
@@ -18,18 +6,25 @@

Welcome to the ReadAlong Studio Editor

+

+ This is a tool to help you edit your existing 'readalongs'. To get + started, click on the tour button below, and follow the steps. +

+
+ +
-
+
@@ -67,7 +62,7 @@

- + Audio Toolbar diff --git a/packages/studio-web/src/app/editor/editor.component.ts b/packages/studio-web/src/app/editor/editor.component.ts index 158ad475..152d5336 100644 --- a/packages/studio-web/src/app/editor/editor.component.ts +++ b/packages/studio-web/src/app/editor/editor.component.ts @@ -1,6 +1,6 @@ import WaveSurfer from "wavesurfer.js"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; -import { BehaviorSubject, takeUntil, Subject, combineLatest, take } from "rxjs"; + +import { takeUntil, Subject, take } from "rxjs"; import { AfterViewInit, Component, @@ -9,11 +9,9 @@ import { OnInit, ViewChild, } from "@angular/core"; -import SegmentsPlugin, { Segment } from "./segments"; -import { ReadAlongSlots } from "../ras.service"; +import SegmentsPlugin from "./segments"; import { Alignment, Components } from "@readalongs/web-component/loader"; import { B64Service } from "../b64.service"; -import { ToastrService } from "ngx-toastr"; import { FileService } from "../file.service"; import { readalong_editor_intro, @@ -28,6 +26,10 @@ import { readalong_editor_fix_text, } from "../shepherd.steps"; import { ShepherdService } from "../shepherd.service"; +import { EditorService } from "./editor.service"; +import { DownloadService } from "../shared/download/download.service"; +import { SupportedOutputs } from "../ras.service"; +import { ToastrService } from "ngx-toastr"; @Component({ selector: "app-editor", templateUrl: "./editor.component.html", @@ -37,44 +39,20 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { @ViewChild("wavesurferContainer") wavesurferContainer!: ElementRef; wavesurfer: WaveSurfer; @ViewChild("readalongContainer") readalongContainerElement: ElementRef; - audioControl$ = new FormControl(null, Validators.required); - rasControl$ = new FormControl(null, Validators.required); + readalong: Components.ReadAlong; - slots: ReadAlongSlots = { - title: "Title", - subtitle: "Subtitle", - }; + language: "eng" | "fra" | "spa" = "eng"; - audioB64Control$ = new FormControl(null, Validators.required); - public uploadFormGroup = this._formBuilder.group({ - audio: this.audioControl$, - ras: this.rasControl$, - audioB64: this.audioB64Control$, - }); + unsubscribe$ = new Subject(); constructor( - private _formBuilder: FormBuilder, public b64Service: B64Service, private fileService: FileService, - private toastr: ToastrService, public shepherdService: ShepherdService, - ) { - this.audioControl$.valueChanges - .pipe(takeUntil(this.unsubscribe$)) - .subscribe((audioFile) => { - // If an audio file is loaded, then load the blob to wave surfer and clear any segments - if (audioFile) { - this.wavesurfer.loadBlob(audioFile); - this.wavesurfer.clearSegments(); - this.fileService - .readFileAsData$(audioFile) - .pipe(take(1)) - .subscribe((audiob64) => { - this.audioB64Control$.setValue(audiob64); - }); - } - }); - } + public editorService: EditorService, + private toastr: ToastrService, + private downloadService: DownloadService, + ) {} async ngAfterViewInit(): Promise { this.wavesurfer = WaveSurfer.create({ @@ -91,29 +69,22 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { height: 200, minPxPerSec: 300, // FIXME: uncertain about this }); + this.loadAudioIntoWavesurferElement(); + + // reload the temporary saved blob from the service + if (this.editorService.temporaryBlob) { + this.onRasFileSelected({ + target: { files: [this.editorService.temporaryBlob] }, + }); + } + this.wavesurfer.on("segment-updated", async (segment, e) => { // Each time a segment is updated we have to update it in both the demo ReadAlong // as well as the XML. This is faster than just editing the XML and asking the // ReadAlong to re-render, which would create all the new audio sprites again etc // when we are just editing a single element at a time. if (e.action == "contentEdited") { - // Update Demo text - let readalongContainerElement = - await this.readalong.getReadAlongElement(); - let changedSegment = - readalongContainerElement.shadowRoot?.getElementById(segment.data.id); - if (changedSegment) { - changedSegment.innerText = segment.data.text; - } - // Update XML text - if (this.rasControl$.value) { - changedSegment = this.rasControl$.value.getElementById( - segment.data.id, - ); - if (changedSegment) { - changedSegment.innerText = segment.data.text; - } - } + this.setReadAlongText(segment.data.id, segment.data.text); } if (e.action == "resize") { // Update Demo Alignments (uses milliseconds) @@ -125,10 +96,11 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { const new_al: Alignment = {}; new_al[segment.data.id] = [start_ms, dur_ms]; // Update XML alignments (uses seconds) - if (this.rasControl$.value) { - let changedSegment = this.rasControl$.value.getElementById( - segment.data.id, - ); + if (this.editorService.rasControl$.value) { + let changedSegment = + this.editorService.rasControl$.value.getElementById( + segment.data.id, + ); if (changedSegment) { changedSegment.setAttribute("time", segment.start); changedSegment.setAttribute("dur", dur.toString()); @@ -142,49 +114,87 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { segment.play(); }); } + ngOnInit(): void {} - ngOnDestroy(): void { - this.unsubscribe$.next(); - this.unsubscribe$.complete(); + async ngOnDestroy() { + // Save translations, images and all other edits to a temporary blob before destroying component + // We just re-use the download service method here for simplicity and reload from this when + // navigating back to the editor + if ( + this.editorService.rasControl$.value && + this.editorService.audioB64Control$.value + ) { + this.editorService.temporaryBlob = + await this.downloadService.createSingleFileBlob( + this.editorService.rasControl$.value, + this.readalong, + this.editorService.slots, + this.editorService.audioB64Control$.value, + ); + } } - async onRasFileSelected(event: any) { - let file: File = event.target.files[0]; - const text = await file.text(); - await this.parseReadalong(text); + download(download_type: SupportedOutputs) { + if ( + this.editorService.audioB64Control$.value && + this.editorService.rasControl$.value + ) { + this.downloadService.download( + download_type, + this.editorService.audioB64Control$.value, + this.editorService.rasControl$.value, + this.editorService.slots, + this.readalong, + ); + } else { + this.toastr.error($localize`Download failed.`, $localize`Sorry!`, { + timeOut: 10000, + }); + } } - async parseReadalong(text: string): Promise { - const parser = new DOMParser(); - const readalong = parser.parseFromString(text, "text/html"); - this.rasControl$.setValue(readalong); - const element = readalong.querySelector("read-along"); - if (element === null) return null; - // Oh, there's an audio file, okay, try to load it - const audio = element.getAttribute("audio"); - if (audio !== null) { - const reply = await fetch(audio); - // Did that work? Great! - if (reply.ok) { - const blob = await reply.blob(); - this.audioControl$.setValue( - new File([blob], "test-audio.webm", { type: "audio/webm" }), - ); - } + async setReadAlongText(id: string, text: string) { + // Update Demo text + let readalongContainerElement = await this.readalong.getReadAlongElement(); + let changedSegment = + readalongContainerElement.shadowRoot?.getElementById(id); + if (changedSegment) { + changedSegment.innerText = text; } - // Is read-along linked (including data URI) or embedded? - const href = element.getAttribute("href"); - if (href === null) { - this.createSegments(element); - } else { - const reply = await fetch(href); - if (reply.ok) { - const text2 = await reply.text(); - // FIXME: potential zip-bombing? - this.parseReadalong(text2); + // Update XML text + if (this.editorService.rasControl$.value) { + changedSegment = this.editorService.rasControl$.value.getElementById(id); + if (changedSegment) { + changedSegment.innerText = text; } } - let readalongBody = readalong.querySelector("body")?.innerHTML; + } + + loadAudioIntoWavesurferElement() { + if (this.editorService.audioControl$.value) { + this.wavesurfer.loadBlob(this.editorService.audioControl$.value); + this.wavesurfer.clearSegments(); + this.fileService + .readFileAsData$(this.editorService.audioControl$.value) + .pipe(take(1)) + .subscribe((audiob64) => { + this.editorService.audioB64Control$.setValue(audiob64); + }); + } + if (this.editorService.rasControl$.value) { + this.createSegments(this.editorService.rasControl$.value); + } + } + + async onRasFileSelected(event: any) { + let file: File = event.target.files[0]; + const text = await file.text(); + const readalong = await this.parseReadalong(text); + this.loadAudioIntoWavesurferElement(); + this.renderReadalong(readalong); + } + + async renderReadalong(readalongBody: string | undefined) { if (readalongBody) { this.readalongContainerElement.nativeElement.innerHTML = readalongBody; const rasElement = @@ -198,22 +208,29 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { let subtitleSlot = rasElement.querySelector( "span[slot='read-along-subheader']", ); + if (titleSlot) { - this.slots.title = titleSlot.innerText; + if (this.editorService.slots.title) { + titleSlot.innerText = this.editorService.slots.title; + } + // this.editorService.slots.title = titleSlot.innerText; titleSlot.setAttribute("contenteditable", true); // Because we're just loading this from the single-file HTML, it's cumbersome to // use Angular event input event listeners like we do in the demo titleSlot.addEventListener( "input", - (ev: any) => (this.slots.title = ev.target?.innerHTML), + (ev: any) => (this.editorService.slots.title = ev.target?.innerHTML), ); } if (subtitleSlot) { - this.slots.subtitle = subtitleSlot.innerText; + if (this.editorService.slots.subtitle) { + subtitleSlot.innerText = this.editorService.slots.subtitle; + } subtitleSlot.setAttribute("contenteditable", true); subtitleSlot.addEventListener( "input", - (ev: any) => (this.slots.subtitle = ev.target?.innerHTML), + (ev: any) => + (this.editorService.slots.subtitle = ev.target?.innerHTML), ); } // Make Editable @@ -230,10 +247,66 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { } }); } - return readalong; } - createSegments(element: Element) { + async parseReadalong(text: string): Promise { + const parser = new DOMParser(); + const readalong = parser.parseFromString(text, "text/html"); + const element = readalong.querySelector("read-along"); + + if (element === undefined || element === null) { + return undefined; + } + + // Store the element as parsed XML + // Create missing body element + const body = document.createElement("body"); + body.id = "t0b0"; + const textNode = element.children[0]; + if (textNode) { + while (textNode.hasChildNodes()) { + // @ts-ignore + body.appendChild(textNode.firstChild); + } + textNode.appendChild(body); + } + const serializer = new XMLSerializer(); + const xmlString = serializer.serializeToString(element); + this.editorService.rasControl$.setValue( + parser.parseFromString(xmlString, "text/xml"), + ); // re-parse as XML + + // Oh, there's an audio file, okay, try to load it + const audio = element.getAttribute("audio"); + + if (audio !== null) { + const reply = await fetch(audio); + // Did that work? Great! + if (reply.ok) { + const blob = await reply.blob(); + this.editorService.audioControl$.setValue( + new File([blob], "test-audio.webm", { type: "audio/webm" }), + ); + } + } + // Is read-along linked (including data URI) or embedded? + const href = element.getAttribute("href"); + if (href === null) { + if (this.editorService.rasControl$.value) { + this.createSegments(this.editorService.rasControl$.value); + } + } else { + const reply = await fetch(href); + if (reply.ok) { + const text2 = await reply.text(); + // FIXME: potential zip-bombing? + this.parseReadalong(text2); + } + } + return readalong.querySelector("body")?.innerHTML; + } + + createSegments(element: Document) { this.wavesurfer.clearSegments(); for (const w of Array.from(element.querySelectorAll("w[id]"))) { const wordText = w.textContent; diff --git a/packages/studio-web/src/app/editor/editor.service.spec.ts b/packages/studio-web/src/app/editor/editor.service.spec.ts new file mode 100644 index 00000000..71f4b1ba --- /dev/null +++ b/packages/studio-web/src/app/editor/editor.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { EditorService } from "./editor.service"; + +describe("EditorService", () => { + let service: EditorService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/packages/studio-web/src/app/editor/editor.service.ts b/packages/studio-web/src/app/editor/editor.service.ts new file mode 100644 index 00000000..0ebec504 --- /dev/null +++ b/packages/studio-web/src/app/editor/editor.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; +import { ReadAlongSlots } from "../ras.service"; + +@Injectable({ + providedIn: "root", +}) +export class EditorService { + audioControl$ = new FormControl(null, Validators.required); + rasControl$ = new FormControl(null, Validators.required); + audioB64Control$ = new FormControl(null, Validators.required); + slots: ReadAlongSlots = { + title: $localize`Title`, + subtitle: $localize`Subtitle`, + }; + uploadFormGroup = this._formBuilder.group({ + audio: this.audioControl$, + ras: this.rasControl$, + audioB64: this.audioB64Control$, + }); + temporaryBlob: Blob | undefined = undefined; + constructor(private _formBuilder: FormBuilder) {} +} diff --git a/packages/studio-web/src/app/studio/privacy-dialog.html b/packages/studio-web/src/app/privacy-dialog.html similarity index 100% rename from packages/studio-web/src/app/studio/privacy-dialog.html rename to packages/studio-web/src/app/privacy-dialog.html diff --git a/packages/studio-web/src/app/shared/download/download.component.ts b/packages/studio-web/src/app/shared/download/download.component.ts index 256e09af..8cb11a9d 100644 --- a/packages/studio-web/src/app/shared/download/download.component.ts +++ b/packages/studio-web/src/app/shared/download/download.component.ts @@ -1,10 +1,5 @@ -import { Component, Input } from "@angular/core"; -import { MaterialModule } from "../../material.module"; -import { ReadAlongSlots, SupportedOutputs } from "../../ras.service"; -import { FormsModule } from "@angular/forms"; -import { BrowserModule } from "@angular/platform-browser"; -import { Components } from "@readalongs/web-component/loader"; -import { DownloadService } from "./download.service"; +import { Component, EventEmitter, Output } from "@angular/core"; +import { SupportedOutputs } from "../../ras.service"; @Component({ selector: "ras-shared-download", @@ -12,10 +7,7 @@ import { DownloadService } from "./download.service"; styleUrl: "./download.component.sass", }) export class DownloadComponent { - @Input() slots: ReadAlongSlots; - @Input() b64Audio: string; - @Input() rasXML: Document; - @Input() readalong: Components.ReadAlong; + @Output() downloadButtonClicked = new EventEmitter(); outputFormats = [ { value: SupportedOutputs.html, display: $localize`Offline HTML` }, { value: SupportedOutputs.zip, display: $localize`Web Bundle` }, @@ -25,15 +17,9 @@ export class DownloadComponent { { value: SupportedOutputs.vtt, display: $localize`WebVTT Subtitles` }, ]; selectedOutputFormat: SupportedOutputs = SupportedOutputs.html; - constructor(private downloadService: DownloadService) {} + constructor() {} download() { - this.downloadService.download( - this.selectedOutputFormat, - this.b64Audio, - this.rasXML, - this.slots, - this.readalong, - ); + this.downloadButtonClicked.emit(this.selectedOutputFormat); } } diff --git a/packages/studio-web/src/app/studio/studio.component.html b/packages/studio-web/src/app/studio/studio.component.html index f10e0455..6d051784 100644 --- a/packages/studio-web/src/app/studio/studio.component.html +++ b/packages/studio-web/src/app/studio/studio.component.html @@ -4,50 +4,44 @@ (selectionChange)="selectionChange($event)" >
-
-

- Welcome to ReadAlong Studio -

-

- This is a tool to help you make your own interactive 'readalong' - that highlights words as they are spoken. Have a look at - - launch - this example in East Cree - to get a better idea of what these are. -

-

- To get started making your own, read our - privacy and data sovereignty policy, then take a tour by clicking on the tour button below, and follow - the steps below. -

-
-
- -
+
+
+

+ This is a tool to help you make your own interactive 'readalong' + that highlights words as they are spoken. Have a look at + + launch + this example in East Cree + to get a better idea of what these are. +

+

+ To get started making your own, click on the tour button below, + and follow the steps. +

+
+ +
@@ -60,10 +54,6 @@

--> - + diff --git a/packages/studio-web/src/app/studio/studio.component.spec.ts b/packages/studio-web/src/app/studio/studio.component.spec.ts index 8f88fa7a..0bf8d97f 100644 --- a/packages/studio-web/src/app/studio/studio.component.spec.ts +++ b/packages/studio-web/src/app/studio/studio.component.spec.ts @@ -46,13 +46,4 @@ describe("StudioComponent", () => { const app = fixture.componentInstance; expect(app.title).toEqual("readalong-studio"); }); - - it("should have an h1", () => { - const fixture = TestBed.createComponent(StudioComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector("h1")?.textContent).toContain( - "Welcome to ReadAlong Studio", - ); - }); }); diff --git a/packages/studio-web/src/app/studio/studio.component.ts b/packages/studio-web/src/app/studio/studio.component.ts index 0be8e5ee..99ba2392 100644 --- a/packages/studio-web/src/app/studio/studio.component.ts +++ b/packages/studio-web/src/app/studio/studio.component.ts @@ -4,13 +4,10 @@ import { Segment } from "soundswallower"; import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { FormGroup } from "@angular/forms"; -import { MatDialog, MatDialogRef } from "@angular/material/dialog"; import { Meta } from "@angular/platform-browser"; import { MatStepper } from "@angular/material/stepper"; import { Title } from "@angular/platform-browser"; -import { B64Service } from "../b64.service"; import { createAlignedXML, SoundswallowerService, @@ -40,6 +37,8 @@ import { DemoComponent } from "../demo/demo.component"; import { UploadComponent } from "../upload/upload.component"; import { StepperSelectionEvent } from "@angular/cdk/stepper"; import { HttpErrorResponse } from "@angular/common/http"; +import { DownloadService } from "../shared/download/download.service"; +import { StudioService } from "./studio.service"; @Component({ selector: "studio-component", @@ -47,10 +46,7 @@ import { HttpErrorResponse } from "@angular/common/http"; styleUrls: ["./studio.component.sass"], }) export class StudioComponent implements OnDestroy, OnInit { - firstFormGroup: any; title = "readalong-studio"; - b64Inputs$ = new Subject<[string, Document]>(); - render$ = new BehaviorSubject(false); @ViewChild("upload", { static: false }) upload?: UploadComponent; @ViewChild("demo", { static: false }) demo?: DemoComponent; @ViewChild("stepper") private stepper: MatStepper; @@ -58,9 +54,10 @@ export class StudioComponent implements OnDestroy, OnInit { private route: ActivatedRoute; constructor( private titleService: Title, + private downloadService: DownloadService, + public studioService: StudioService, private router: Router, private fileService: FileService, - private dialog: MatDialog, private meta: Meta, public shepherdService: ShepherdService, private ssjsService: SoundswallowerService, @@ -129,26 +126,32 @@ export class StudioComponent implements OnDestroy, OnInit { }); } - ngOnDestroy(): void { + async ngOnDestroy() { + // step us back to the previously left step + this.studioService.lastStepperIndex = this.stepper.selectedIndex; this.unsubscribe$.next(); this.unsubscribe$.complete(); } selectionChange(event: StepperSelectionEvent) { if (event.selectedIndex === 0) { - this.render$.next(false); + this.studioService.render$.next(false); } else if (event.selectedIndex === 1) { - this.render$.next(true); + this.studioService.render$.next(true); } } - ngAfterViewInit() {} + ngAfterViewInit() { + if (this.stepper.selectedIndex < this.studioService.lastStepperIndex) { + this.stepper.next(); + } + } formIsDirty() { return ( - this.upload?.audioControl$.value !== null || - this.upload?.textControl$.value !== null || - this.upload?.$textInput + this.studioService.audioControl$.value !== null || + this.studioService.textControl$.value !== null || + this.studioService.$textInput ); } @@ -164,24 +167,24 @@ export class StudioComponent implements OnDestroy, OnInit { text_file_step["when"] = { show: () => { if (this.upload) { - this.upload.inputMethod.text = "upload"; + this.studioService.inputMethod.text = "upload"; } }, hide: () => { if (this.upload) { - this.upload.inputMethod.text = "edit"; + this.studioService.inputMethod.text = "edit"; } }, }; audio_file_step["when"] = { show: () => { if (this.upload) { - this.upload.inputMethod.audio = "upload"; + this.studioService.inputMethod.audio = "upload"; } }, hide: () => { if (this.upload) { - this.upload.inputMethod.audio = "mic"; + this.studioService.inputMethod.audio = "mic"; } }, }; @@ -200,9 +203,9 @@ export class StudioComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribe$)) .subscribe((audioFile) => { if (!(audioFile instanceof HttpErrorResponse) && this.upload) { - this.upload.$textInput.next("Hello world!"); - this.upload.inputMethod.text = "edit"; - this.upload.audioControl$.setValue(audioFile); + this.studioService.$textInput.next("Hello world!"); + this.studioService.inputMethod.text = "edit"; + this.studioService.audioControl$.setValue(audioFile); this.upload?.nextStep(); this.stepper.animationDone.pipe(take(1)).subscribe(() => { // We can only attach to the shadow dom once it's been created, so unfortunately we need to define the steps like this. @@ -277,17 +280,6 @@ export class StudioComponent implements OnDestroy, OnInit { this.shepherdService.start(); } - openPrivacyDialog(): void { - this.dialog.open(PrivacyDialog, { - width: "50vw", - maxHeight: "90vh", - }); - } - - formChanged(formGroup: FormGroup) { - this.firstFormGroup = formGroup; - } - stepChange(event: any[]) { if (event[0] === "aligned") { const aligned_xml = createAlignedXML(event[2], event[3] as Segment); @@ -297,32 +289,9 @@ export class StudioComponent implements OnDestroy, OnInit { ]) .pipe(takeUntil(this.unsubscribe$)) .subscribe((x: any) => { - this.b64Inputs$.next(x); + this.studioService.b64Inputs$.next(x); this.stepper.next(); }); } } } - -@Component({ - selector: "privacy-dialog", - templateUrl: "privacy-dialog.html", -}) -export class PrivacyDialog { - analyticsExcluded = - window.localStorage.getItem("plausible_ignore") === "true"; - constructor(public dialogRef: MatDialogRef) {} - ngOnInit() { - this.dialogRef.updateSize("100%"); - } - - toggleAnalytics() { - if (this.analyticsExcluded) { - window.localStorage.removeItem("plausible_ignore"); - } else { - window.localStorage.setItem("plausible_ignore", "true"); - } - this.analyticsExcluded = - window.localStorage.getItem("plausible_ignore") === "true"; - } -} diff --git a/packages/studio-web/src/app/studio/studio.service.spec.ts b/packages/studio-web/src/app/studio/studio.service.spec.ts new file mode 100644 index 00000000..24f8e7ec --- /dev/null +++ b/packages/studio-web/src/app/studio/studio.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { StudioService } from "./studio.service"; + +describe("StudioService", () => { + let service: StudioService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StudioService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/packages/studio-web/src/app/studio/studio.service.ts b/packages/studio-web/src/app/studio/studio.service.ts new file mode 100644 index 00000000..48b3898a --- /dev/null +++ b/packages/studio-web/src/app/studio/studio.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@angular/core"; +import { ReadAlongSlots } from "../ras.service"; +import { Subject, BehaviorSubject } from "rxjs"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; + +export enum langMode { + generic = "generic", + specific = "specific", +} + +@Injectable({ + providedIn: "root", +}) +export class StudioService { + slots: ReadAlongSlots = { + title: $localize`Title`, + subtitle: $localize`Subtitle`, + }; + lastStepperIndex: number = 0; + temporaryBlob: Blob | undefined = undefined; + b64Inputs$ = new BehaviorSubject<[string, Document | null]>(["", null]); + render$ = new BehaviorSubject(false); + langMode$ = new BehaviorSubject(langMode.generic); + langControl$ = new FormControl( + { value: "und", disabled: this.langMode$.value !== "specific" }, + Validators.required, + ); + textControl$ = new FormControl(null, Validators.required); + audioControl$ = new FormControl( + null, + Validators.required, + ); + $textInput = new BehaviorSubject(""); + public uploadFormGroup = this._formBuilder.group({ + lang: this.langControl$, + text: this.textControl$, + audio: this.audioControl$, + }); + inputMethod = { + audio: "mic", + text: "edit", + }; + constructor(private _formBuilder: FormBuilder) { + this.langMode$.subscribe((chosenLangMode) => { + if (chosenLangMode === langMode.generic) { + this.langControl$.disable(); + } else { + this.langControl$.enable(); + } + }); + } +} diff --git a/packages/studio-web/src/app/upload/upload.component.html b/packages/studio-web/src/app/upload/upload.component.html index 4ebe0ac1..6d17492b 100644 --- a/packages/studio-web/src/app/upload/upload.component.html +++ b/packages/studio-web/src/app/upload/upload.component.html @@ -21,7 +21,7 @@

Text

(change)="toggleTextInput($event)" name="inputMethod" aria-label="Input Method" - [value]="inputMethod.text" + [value]="studioService.inputMethod.text" > WriteText

-
+
@@ -58,7 +58,7 @@

Text

-
+

@@ -78,7 +78,7 @@

Text