File size: 5,670 Bytes
e1222dc
66de9c8
e1222dc
 
66de9c8
e1222dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
e1222dc
 
 
 
 
66de9c8
e1222dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
e1222dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import { useCallback, useEffect, useState } from "react";
import { videoVoicesApi, voiceApi } from "../../api/endpoints";
import type { VideoLibraryVoice, VideoSavedVoice, VideoVoicesResponse } from "../../api/types";
import { errorMessage, Loading } from "../ui";
import { PlayButton, useAudio } from "../video/parts";

const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");

/** Play a voice: library voices have a sample link; the workspace's own (Notestack) voices have none, so the Voice
 * page speaks a line in them. */
export function useVoicePlayer(onError: (msg: string) => void) {
  const { playing, play, stop } = useAudio();
  const playNotestack = async (voiceId: string) => {
    if (playing === voiceId) return stop();
    const res = await voiceApi.preview(voiceId).catch((e) => {
      onError(errorMessage(e));
      return null;
    });
    if (res?.url) play(voiceId, res.url);
  };
  const playSaved = (v: VideoSavedVoice) =>
    v.source === "notestack" ? playNotestack(v.voice_id) : play(v.voice_id, v.preview_url);
  return { playing, play, playNotestack, playSaved };
}

/** The Voice page's Speaking voices: the workspace's voices (one list for audio overviews and videos, as many as
 * wanted) and, below, the built-in library to add from. New voices (Add voices) are saved here by the server. */
export function YourVoices({ reloadKey = 0, onAdd }: {
  /** Bumped by the Voice page after a voice was made or cloned, so the list reloads. */
  reloadKey?: number;
  /** Opens the Add voices modal (build, describe or clone). */
  onAdd: () => void;
}) {
  const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
  const [busy, setBusy] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const { playing, play, playSaved } = useVoicePlayer(setError);

  const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
  useEffect(() => {
    load();
  }, [load, reloadKey]);

  async function act(voiceId: string, fn: () => Promise<unknown>) {
    setBusy(voiceId);
    setError(null);
    try {
      await fn();
      await load();
    } catch (e) {
      setError(errorMessage(e));
    } finally {
      setBusy(null);
    }
  }

  const add = (v: VideoLibraryVoice) => act(v.voice_id, () => videoVoicesApi.save(v.voice_id));
  const library = voices?.library.filter((v) => !v.saved) ?? [];

  return (
    <div className="stack">
      <div className="row between wrap manage-top">
        <p className="muted">Your voices read your audio overviews, and the free ones can narrate videos. Add your own, or pick from the library.</p>
        <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
      </div>
      {error && <p className="error-text">{error}</p>}
      {!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}

      {voices && (
        <>
          <section className="stack yv-panel">
            <h3 className="yv-head">Your voices <span className="yv-count mono">{voices.saved.length}</span></h3>
            {voices.saved.length === 0 ? (
              <p className="muted">No voices yet. Add one, or pick from the library below.</p>
            ) : (
              <div className="vw-voice-list vw-voice-list-tight vw-voice-list-four">
                {voices.saved.map((v) => (
                  <div key={v.voice_id} className="vw-vrow">
                    <PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
                                label={v.name} onClick={() => playSaved(v)} />
                    <div className="vw-vpick static">
                      <strong>{v.name}</strong>
                      <span className="muted">
                        {v.source === "notestack" ? "Your voice"
                          : [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}
                      </span>
                    </div>
                    <button className="btn btn-small" disabled={busy === v.voice_id}
                            onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>
                      Remove
                    </button>
                  </div>
                ))}
              </div>
            )}
          </section>

          <section className="stack yv-panel">
            <h3 className="yv-head">Voice library</h3>
            {library.length === 0 ? (
              <p className="muted">Every library voice is in your voices.</p>
            ) : (
              <div className="vw-voice-list vw-voice-list-tight">
                {library.map((v) => (
                  <div key={v.voice_id} className="vw-vrow">
                    <PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
                                onClick={() => play(v.voice_id, v.preview_url)} />
                    <div className="vw-vpick static">
                      <strong>{v.name}</strong>
                      <span className="muted">
                        {[cap(v.gender), cap(v.accent), cap(v.age)].filter(Boolean).join(" • ")}
                        {v.description ? ` - ${v.description}` : ""}
                      </span>
                    </div>
                    <button className="btn btn-small btn-primary" disabled={busy === v.voice_id} onClick={() => add(v)}>
                      Add
                    </button>
                  </div>
                ))}
              </div>
            )}
          </section>
        </>
      )}
    </div>
  );
}