nixamp 0.52.1 → 0.53.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/ai-label.d.ts +50 -4
  2. package/dist/ai-label.js +221 -36
  3. package/dist/ai-radio.js +14 -2
  4. package/dist/ai-text.d.ts +5 -0
  5. package/dist/ai-text.js +43 -1
  6. package/dist/server.js +11 -6
  7. package/dist/station-routes.d.ts +2 -0
  8. package/dist/station-routes.js +5 -0
  9. package/dist/stations.d.ts +5 -0
  10. package/dist/stations.js +4 -0
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +218 -32
  13. package/src/ai-radio.ts +13 -2
  14. package/src/ai-text.ts +26 -1
  15. package/src/server.ts +11 -6
  16. package/src/station-routes.ts +7 -0
  17. package/src/stations.ts +5 -0
  18. package/web/dist/assets/{ar-Cmon75WL.js → ar-CHGRWAbA.js} +1 -1
  19. package/web/dist/assets/{cs-BRaPWZ-l.js → cs-DG2hfiKN.js} +1 -1
  20. package/web/dist/assets/{da-DzwX-95l.js → da-KT2He0jg.js} +1 -1
  21. package/web/dist/assets/{de-Dp5yZesS.js → de-DXU2uKKW.js} +1 -1
  22. package/web/dist/assets/{es-DI_neNna.js → es-Bc1KFlo2.js} +1 -1
  23. package/web/dist/assets/{fi-RizsR0Qs.js → fi-B_tEGfuT.js} +1 -1
  24. package/web/dist/assets/{fr-Du-Xj-FG.js → fr-DJYssjeG.js} +1 -1
  25. package/web/dist/assets/{hi-K3VZUZDS.js → hi-D9gdzu7j.js} +1 -1
  26. package/web/dist/assets/{hu-s6bvR7Qx.js → hu-CjndMTE_.js} +1 -1
  27. package/web/dist/assets/{id-BtqFTjdZ.js → id-DYaJe6gl.js} +1 -1
  28. package/web/dist/assets/{it-DlTM2OMR.js → it-fihgylYf.js} +1 -1
  29. package/web/dist/assets/{main-DaqdNlZC.js → main-CEkEtWRE.js} +2 -2
  30. package/web/dist/assets/{nl-KW4bKgIq.js → nl-Bh_6FjCg.js} +1 -1
  31. package/web/dist/assets/{radio-Ct2xxni3.css → radio-CtAne8M1.css} +1 -1
  32. package/web/dist/assets/{radio-B8n_n-WD.js → radio-ZiViiR-a.js} +1 -1
  33. package/web/dist/assets/{ru-CZhm9rUN.js → ru-BO7vJb22.js} +1 -1
  34. package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
  35. package/web/dist/assets/{station-CrAZxHcX.js → station-CvUUHCvu.js} +1 -1
  36. package/web/dist/assets/{sv-Bs6WYhqU.js → sv-zP8tkRkk.js} +1 -1
  37. package/web/dist/assets/{uk-OcuLYdVU.js → uk-Bqwr1bUL.js} +1 -1
  38. package/web/dist/assets/{vi-BX_K6R8j.js → vi-B_gIUmc5.js} +1 -1
  39. package/web/dist/assets/{zh-CEzbVQ57.js → zh-DhCVKyZF.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/radio.html +9 -4
  42. package/web/dist/station.html +6 -2
  43. package/web/dist/sw.js +26 -26
  44. package/web/dist/vod.html +2 -2
  45. package/web/dist/watch.html +2 -2
  46. /package/web/dist/assets/{vod-CrvTO3FN.js → vod-BSo-pl8M.js} +0 -0
  47. /package/web/dist/assets/{watch-itlvgIf8.js → watch-DBSszFQm.js} +0 -0
package/src/ai-label.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { execFile } from "node:child_process";
30
30
  import { randomBytes } from "node:crypto";
31
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
32
32
  import { dirname, join, resolve, sep } from "node:path";
33
33
  import { aiKey } from "./ai-keys.ts";
34
34
  import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
@@ -51,6 +51,10 @@ export interface LabelSong {
51
51
  lyricsFile: string;
52
52
  /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
53
53
  video?: string;
54
+ /** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
55
+ videoTagged?: boolean;
56
+ /** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
57
+ videoVersion?: number;
54
58
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
55
59
  made: string;
56
60
  composedAt: number;
@@ -518,11 +522,98 @@ export async function workersAiCover(prompt: string, env: Record<string, string
518
522
  return image;
519
523
  }
520
524
 
521
- /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
525
+ const STORYBOARD_SYSTEM = [
526
+ "You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
527
+ "Keep the same main character(s), setting and color palette across scenes so it reads as one video; vary the shots (wide, close-up, action, detail).",
528
+ "Each prompt: cinematic, photorealistic or stylized to suit the genre, at most 300 characters. No text, letters, logos, real people or real brands.",
529
+ "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
530
+ ].join(" ");
531
+
532
+ /** Image prompts for a song's video, one a scene, from its words and mood. */
533
+ export async function writeStoryboard(
534
+ artist: Pick<LabelArtist, "name" | "genre" | "sound">,
535
+ album: Pick<LabelAlbum, "title" | "concept">,
536
+ song: Pick<LabelSong, "title" | "style" | "lyrics">,
537
+ scenes: number,
538
+ ask: typeof askJson = askJson,
539
+ ): Promise<string[]> {
540
+ const answer = await ask({
541
+ system: STORYBOARD_SYSTEM,
542
+ prompt: [
543
+ `Song: "${song.title}" by ${artist.name} (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
544
+ `Mood and arrangement: ${song.style}`,
545
+ song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
546
+ `Exactly ${scenes} scenes.`,
547
+ ].filter(Boolean).join("\n"),
548
+ maxTokens: 2500,
549
+ timeoutMs: 90_000,
550
+ });
551
+ const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
552
+ const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
553
+ if (prompts.length < 2) throw new Error("the model wrote no storyboard");
554
+ return prompts;
555
+ }
556
+
557
+ /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
558
+ function titleFont(): string {
559
+ for (const file of ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"]) {
560
+ try { if (statSync(file).isFile()) return file; } catch { /* next */ }
561
+ }
562
+ return "";
563
+ }
564
+
565
+ /** ffmpeg at the lowest priority, so the stations it shares the machine with come first. */
566
+ function runLow(ffmpeg: string[], args: string[], timeout: number, what: string): Promise<void> {
567
+ const nice = process.platform === "linux" ? (() => { try { return statSync("/usr/bin/nice").isFile(); } catch { return false; } })() : false;
568
+ const [bin, rest] = nice ? ["/usr/bin/nice", ["-n", "19", ffmpeg[0]!, ...args]] : [ffmpeg[0]!, args];
569
+ return new Promise((done, fail) => {
570
+ execFile(bin, rest, { timeout, maxBuffer: 4 * 1024 * 1024 }, (error) => (error ? fail(new Error(`${what}: ${error.message}`)) : done()));
571
+ });
572
+ }
573
+
574
+ /**
575
+ * A song's music video from its storyboard: each scene's picture moves (a
576
+ * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
577
+ * the artist and title come up at the start, and the MP3's audio and tags
578
+ * (title, artist, album, words) are copied in. 720p H.264.
579
+ */
580
+ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }): Promise<void> {
581
+ const fade = 1;
582
+ const count = input.images.length;
583
+ const each = input.seconds / count + fade;
584
+ const frames = Math.max(25, Math.round(each * 25));
585
+ const moves = [
586
+ `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
587
+ `z='1.15':x='(iw-iw/zoom)*on/${frames}':y='ih/2-(ih/zoom/2)'`,
588
+ `z='1.15-0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
589
+ `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
590
+ ];
591
+ const parts: string[] = [];
592
+ input.images.forEach((_, i) => {
593
+ parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[i % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
594
+ });
595
+ let last = "v0";
596
+ for (let i = 1; i < count; i++) {
597
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
598
+ last = `x${i}`;
599
+ }
600
+ const font = titleFont();
601
+ const card = `${input.artist} — ${input.title}`.replace(/[\\':%]/g, " ").slice(0, 80);
602
+ parts.push(font
603
+ ? `[${last}]drawtext=fontfile=${font}:text='${card}':fontcolor=white:fontsize=44:x=60:y=h-110:enable='between(t,0.5,6)':alpha='if(lt(t,1.5),t-0.5,if(gt(t,5),6-t,1))':shadowcolor=black@0.7:shadowx=2:shadowy=2,format=yuv420p[v]`
604
+ : `[${last}]format=yuv420p[v]`);
605
+ const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
606
+ ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
607
+ "-i", input.audio, "-filter_complex", parts.join(";"),
608
+ "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
609
+ "-movflags", "+faststart+use_metadata_tags", input.out];
610
+ return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
611
+ }
612
+
522
613
  /**
523
- * A song's music video: its album cover over a blurred copy of itself, the
524
- * song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
525
- * Without a cover, a dark plate.
614
+ * A song's music video without a storyboard: its album cover over a blurred
615
+ * copy of itself, the song's waveform along the bottom, 720p H.264 with the
616
+ * MP3's audio and tags copied in. Without a cover, a dark plate.
526
617
  */
527
618
  export function renderVideo(ffmpeg: string[], audio: string, cover: string, out: string): Promise<void> {
528
619
  const picture = cover ? ["-loop", "1", "-framerate", "25", "-i", cover] : ["-f", "lavfi", "-i", "color=c=0x0d1410:s=1280x720:r=25"];
@@ -534,12 +625,21 @@ export function renderVideo(ffmpeg: string[], audio: string, cover: string, out:
534
625
  "[a][viz]overlay=0:H-160,format=yuv420p[v]",
535
626
  ].join(";");
536
627
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", ...picture, "-i", audio, "-filter_complex", graph,
537
- "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest", "-movflags", "+faststart", out];
628
+ "-map", "[v]", "-map", "1:a", "-map_metadata", "1", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest",
629
+ "-movflags", "+faststart+use_metadata_tags", out];
630
+ return runLow(ffmpeg, args, 20 * 60_000, "the video failed");
631
+ }
632
+
633
+ /** A video made before videos carried tags, given its song's: streams copied, nothing re-encoded. */
634
+ export function retagVideo(ffmpeg: string[], video: string, audio: string, out: string): Promise<void> {
635
+ const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", audio,
636
+ "-map", "0:v", "-map", "0:a", "-map_metadata", "1", "-c", "copy", "-movflags", "+faststart+use_metadata_tags", out];
538
637
  return new Promise((done, fail) => {
539
- execFile(ffmpeg[0]!, args, { timeout: 20 * 60_000 }, (error) => (error ? fail(new Error(`the video failed: ${error.message}`)) : done()));
638
+ execFile(ffmpeg[0]!, args, { timeout: 5 * 60_000 }, (error) => (error ? fail(new Error(`retagging the video failed: ${error.message}`)) : done()));
540
639
  });
541
640
  }
542
641
 
642
+ /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
543
643
  function tagFile(ffmpeg: string[], from: string, to: string, tags: Record<string, string>, cover: string): Promise<void> {
544
644
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
545
645
  if (cover) args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
@@ -574,6 +674,10 @@ export interface AiLabelDeps {
574
674
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
575
675
  /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
576
676
  video?: (audio: string, cover: string, out: string) => Promise<void>;
677
+ /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
678
+ storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
679
+ /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
680
+ image?: (prompt: string) => Promise<Buffer | null>;
577
681
  /** The day's pick (artist, genre, new or not); Math.random by default. */
578
682
  random?: () => number;
579
683
  now?: () => number;
@@ -596,6 +700,8 @@ export class AiLabel {
596
700
  private filming: Promise<number> | null = null;
597
701
  /** Songs whose video failed since this server started. */
598
702
  private videoFailed = new Set<string>();
703
+ /** Old videos already tried as storyboards since this server started. */
704
+ private upgradeTried = new Set<string>();
599
705
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
600
706
  private dailyTriedAt = 0;
601
707
  /** Genre stations are kept in step one change at a time. */
@@ -865,37 +971,104 @@ export class AiLabel {
865
971
  return total;
866
972
  }
867
973
 
974
+ /**
975
+ * One pass: songs without a video first, then old videos given their
976
+ * song's tags (seconds each), then old cover-and-waveform videos made again
977
+ * as storyboards.
978
+ */
868
979
  private async filmPass(): Promise<number> {
980
+ const songs = this.list().flatMap((artist) => artist.albums
981
+ .filter((album) => album.cover || album.coverTried)
982
+ .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
983
+ const missing = songs.filter(({ song }) => !song.video);
984
+ const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
985
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
869
986
  let made = 0;
870
- {
871
- for (const artist of this.list()) {
872
- for (const album of artist.albums) {
873
- if (!album.cover && !album.coverTried) continue;
874
- for (const song of album.songs) {
875
- if (!song.file || song.video || this.videoFailed.has(song.file) || !this.settings.videos) continue;
876
- const out = join(album.folder, `${songBase(song)}.mp4`);
877
- const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
878
- try {
879
- await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, partial);
880
- renameSync(partial, out);
881
- song.video = out;
882
- made += 1;
883
- this.save();
884
- // On the air as soon as it is made, not after the whole pass.
885
- await this.placeVideos().catch(() => undefined);
886
- } catch (error) {
887
- this.videoFailed.add(song.file);
888
- this.log(`no video for "${song.title}": ${(error as Error).message}`);
889
- } finally {
890
- rmSync(partial, { force: true });
891
- }
892
- }
893
- }
987
+ for (const { album, song } of untagged) {
988
+ if (!this.settings.videos) break;
989
+ // Made before videos carried tags: give it its song's, in place.
990
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.retag.mp4`);
991
+ try {
992
+ await retagVideo(this.deps.ffmpeg, song.video!, song.file, partial);
993
+ renameSync(partial, song.video!);
994
+ song.videoTagged = true;
995
+ made += 1;
996
+ this.save();
997
+ } catch (error) {
998
+ this.log(`could not tag the video of "${song.title}": ${(error as Error).message}`);
999
+ song.videoTagged = true; // a storyboard remake brings the tags anyway
1000
+ } finally {
1001
+ rmSync(partial, { force: true });
1002
+ }
1003
+ }
1004
+ for (const { artist, album, song } of [...missing, ...old]) {
1005
+ if (!this.settings.videos) break;
1006
+ // An old video is only made again as a storyboard, and tried once a run.
1007
+ const upgrade = Boolean(song.video);
1008
+ if (upgrade) this.upgradeTried.add(song.file);
1009
+ const out = join(album.folder, `${songBase(song)}.mp4`);
1010
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
1011
+ const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
1012
+ try {
1013
+ song.videoVersion = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1014
+ renameSync(partial, out);
1015
+ song.video = out;
1016
+ song.videoTagged = true;
1017
+ made += 1;
1018
+ this.save();
1019
+ // On the air as soon as it is made, not after the whole pass.
1020
+ await this.placeVideos().catch(() => undefined);
1021
+ } catch (error) {
1022
+ if (!upgrade) this.videoFailed.add(song.file);
1023
+ this.log(`no ${upgrade ? "storyboard " : ""}video for "${song.title}": ${(error as Error).message}`);
1024
+ } finally {
1025
+ rmSync(partial, { force: true });
1026
+ rmSync(scenes, { recursive: true, force: true });
894
1027
  }
895
1028
  }
896
1029
  return made;
897
1030
  }
898
1031
 
1032
+ /**
1033
+ * One song's video: a storyboard of scenes when a model can write it and an
1034
+ * image model can draw it (version 2), else the cover-and-waveform kind (1).
1035
+ */
1036
+ private async filmSong(artist: LabelArtist, album: LabelAlbum, song: LabelSong, scenes: string, out: string, storyOnly = false): Promise<number> {
1037
+ let seconds = song.seconds;
1038
+ try {
1039
+ const probed = await this.deps.probe(song.file);
1040
+ if (probed.duration > 5) seconds = probed.duration;
1041
+ } catch { /* the asked-for length */ }
1042
+ const images: string[] = [];
1043
+ if (this.canAsk()) {
1044
+ try {
1045
+ const count = Math.max(4, Math.min(16, Math.round(seconds / 9)));
1046
+ const prompts = await writeStoryboard(artist, album, song, count, this.deps.ask ?? askJson);
1047
+ mkdirSync(scenes, { recursive: true });
1048
+ const draw = this.deps.image ?? ((prompt: string) => workersAiCover(prompt));
1049
+ // A few at a time: the image model is quick, the machine is shared.
1050
+ for (let i = 0; i < prompts.length; i += 3) {
1051
+ const drawn = await Promise.all(prompts.slice(i, i + 3).map((prompt) => draw(`${prompt} Cinematic 16:9 music video still, no text.`).catch(() => null)));
1052
+ drawn.forEach((picture, j) => {
1053
+ if (!picture) return;
1054
+ const file = join(scenes, `${String(i + j).padStart(2, "0")}.jpg`);
1055
+ writeFileSync(file, picture);
1056
+ images.push(file);
1057
+ });
1058
+ }
1059
+ } catch (error) {
1060
+ this.log(`no storyboard for "${song.title}" (${(error as Error).message}); the cover video instead`);
1061
+ }
1062
+ }
1063
+ if (images.length >= 2) {
1064
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1065
+ return 2;
1066
+ }
1067
+ if (storyOnly) throw new Error("no storyboard could be drawn; the video it has stays");
1068
+ await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1069
+ return 1;
1070
+ }
1071
+
899
1072
  /** The label's music videos as one TV station: free to watch, with its breaks. */
900
1073
  private placeVideos(): Promise<void> {
901
1074
  const next = this.placing.then(async () => {
@@ -987,7 +1160,8 @@ export class AiLabel {
987
1160
  return "composed";
988
1161
  }
989
1162
  // Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
990
- const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
1163
+ // A paused artist's unfinished album waits without holding a slot.
1164
+ const inProgress = this.list().filter((one) => one.enabled && one.albums.some((album) => !album.doneAt)).length + this.planning.size;
991
1165
  if (inProgress >= Math.max(1, this.settings.parallel)) return "idle";
992
1166
  if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft()) return "idle";
993
1167
  const due = this.list()
@@ -1256,6 +1430,18 @@ export class AiLabel {
1256
1430
  return null;
1257
1431
  }
1258
1432
 
1433
+ /** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
1434
+ audioFor(file: string): string {
1435
+ if (!file.endsWith(".mp4")) return file;
1436
+ for (const artist of this.artists.values()) {
1437
+ for (const album of artist.albums) {
1438
+ const song = album.songs.find((one) => one.video === file);
1439
+ if (song) return song.file;
1440
+ }
1441
+ }
1442
+ return file;
1443
+ }
1444
+
1259
1445
  /** A released song's MP3, when this grant's scope covers it. */
1260
1446
  songFile(artistKey: string, albumKey: string, n: number): string {
1261
1447
  return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
package/src/ai-radio.ts CHANGED
@@ -115,14 +115,25 @@ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch, m
115
115
  ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
116
116
  }),
117
117
  });
118
- let response = await ask(prompt);
118
+ // Too many at once (several songs are made in parallel): wait and ask again, a few times.
119
+ const patient = async (text: string): Promise<Response> => {
120
+ let answer = await ask(text);
121
+ for (const wait of [15_000, 30_000, 60_000]) {
122
+ if (answer.status !== 429) break;
123
+ await answer.body?.cancel().catch(() => undefined);
124
+ await new Promise((done) => setTimeout(done, wait));
125
+ answer = await ask(text);
126
+ }
127
+ return answer;
128
+ };
129
+ let response = await patient(prompt);
119
130
  if (response.status === 400) {
120
131
  // A brief it will not make (an artist's name, a lyric) comes back
121
132
  // with one it will; take it once.
122
133
  const body = await response.json().catch(() => ({})) as { detail?: { status?: string; data?: { prompt_suggestion?: string } } };
123
134
  const suggestion = body.detail?.data?.prompt_suggestion;
124
135
  if (!suggestion) throw new Error(`elevenlabs refused the brief (${body.detail?.status ?? "HTTP 400"})`);
125
- response = await ask(suggestion);
136
+ response = await patient(suggestion);
126
137
  }
127
138
  if (response.status === 401 || response.status === 402) throw new OutOfCredit(`elevenlabs answered HTTP ${response.status}: the key or the plan's credit`);
128
139
  if (!response.ok) {
package/src/ai-text.ts CHANGED
@@ -35,9 +35,34 @@ export class NoModel extends Error {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Raw line breaks and tabs inside JSON strings, escaped: models writing song
40
+ * words often break the lines for real inside the quotes, which JSON refuses.
41
+ */
42
+ export function escapeInStrings(text: string): string {
43
+ let out = "";
44
+ let inString = false;
45
+ let escaped = false;
46
+ for (const char of text) {
47
+ if (inString) {
48
+ if (escaped) escaped = false;
49
+ else if (char === "\\") escaped = true;
50
+ else if (char === "\"") inString = false;
51
+ else if (char === "\n") { out += "\\n"; continue; }
52
+ else if (char === "\r") continue;
53
+ else if (char === "\t") { out += "\\t"; continue; }
54
+ } else if (char === "\"") inString = true;
55
+ out += char;
56
+ }
57
+ return out;
58
+ }
59
+
38
60
  /** The first JSON object in a model's answer. */
39
61
  export function firstObject(text: string): Record<string, unknown> {
40
- const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
62
+ const fenced = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
63
+ const cleaned = (() => {
64
+ try { JSON.parse(fenced); return fenced; } catch { return escapeInStrings(fenced); }
65
+ })();
41
66
  try {
42
67
  const parsed = JSON.parse(cleaned) as unknown;
43
68
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
package/src/server.ts CHANGED
@@ -3165,6 +3165,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3165
3165
  },
3166
3166
  trackFor: (file) => trackFor(engine, file),
3167
3167
  ...(options.library ? { isVideo: (file: string) => options.library!.index.itemAt(file)?.video === true } : {}),
3168
+ ...(options.library ? { durationOf: (file: string) => options.library!.index.itemAt(file)?.duration ?? 0 } : {}),
3168
3169
  base: `${scheme}://${request.headers.host ?? "localhost"}`,
3169
3170
  publicBase: options.stations.publicBase?.() ?? "",
3170
3171
  json,
@@ -7370,7 +7371,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7370
7371
  stations.holders = holderStore;
7371
7372
  // Covers read once per file (and its size and time), a few hundred kept.
7372
7373
  const covers = new Map<string, { key: string; picture: { bytes: Buffer; type: string } | null }>();
7373
- stations.cover = async (file) => {
7374
+ stations.cover = async (playing) => {
7375
+ // A label music video's cover is its song's.
7376
+ const file = aiLabel.audioFor(playing);
7374
7377
  let key = "";
7375
7378
  try { const info = statSync(file); key = `${info.size}:${info.mtimeMs}`; } catch { return null; }
7376
7379
  const hit = covers.get(file);
@@ -7383,7 +7386,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7383
7386
  };
7384
7387
  // The words of what is on: the file's own tag, else nichedb.dev's.
7385
7388
  const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
7386
- stations.lyrics = (_station, file) => lyricsFinder.find(file);
7389
+ stations.lyrics = (_station, file) => lyricsFinder.find(aiLabel.audioFor(file));
7387
7390
  // Our own songs' words, back to nichedb, when a key is set: on any station
7388
7391
  // change, after a library scan, and once at start.
7389
7392
  const lyricsPublisher = new LyricsPublisher({
@@ -7409,9 +7412,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7409
7412
  let stamp = "";
7410
7413
  try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
7411
7414
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
7412
- const words = await lyricsFinder.find(file).catch(() => null);
7415
+ // A label music video is told about as its song: the MP3 holds the tags and the words.
7416
+ const audio = aiLabel.audioFor(file);
7417
+ const words = await lyricsFinder.find(audio).catch(() => null);
7413
7418
  // A label song is for sale on nixamp.com: the song, its album, everything by the artist.
7414
- const song = aiLabel.songAt(file);
7419
+ const song = aiLabel.songAt(audio);
7415
7420
  const here = stations.publicBase?.() ?? "";
7416
7421
  const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
7417
7422
  const store = song && /^https:\/\//.test(here) ? {
@@ -7425,8 +7430,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7425
7430
  art,
7426
7431
  ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
7427
7432
  station, file,
7428
- tags: await tagReader.read(file),
7429
- known: trackFor(engine, file),
7433
+ tags: await tagReader.read(audio),
7434
+ known: trackFor(engine, audio),
7430
7435
  holders: holderStore.list(),
7431
7436
  share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
7432
7437
  aiTrack: composed ? { made: composed.made } : null,
@@ -66,6 +66,8 @@ export interface StationContext {
66
66
  throttle?: () => { retryAfter: number } | null;
67
67
  /** What a listener may know about the track on the air (track-facts.ts). */
68
68
  facts?: (station: Station, file: string) => Promise<TrackFacts>;
69
+ /** How long a file is, in seconds (the library's probe); 0 when unknown. For the page's progress bar. */
70
+ durationOf?: (file: string) => number;
69
71
  /** The cover picture inside a file (JPEG/PNG bytes), or null. */
70
72
  cover?: (file: string) => Promise<{ bytes: Buffer; type: string } | null>;
71
73
  /** The words of a file, from its tags or nichedb; null when none are known. */
@@ -116,6 +118,10 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
116
118
  // What the channel is carrying wins over the flag: a station made by hand
117
119
  // from films streams video whether or not anyone said so.
118
120
  const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
121
+ // How far into the song (or film) on the air, by the encoder's clock, and how long it is.
122
+ const position = file ? ctx.channels.info(channelOf(station.id))?.position : undefined;
123
+ const duration = file && ctx.durationOf ? ctx.durationOf(file) : 0;
124
+ const progress = typeof position === "number" && duration > 0 ? { position: Math.round(Math.min(position, duration) * 10) / 10, duration: Math.round(duration * 10) / 10 } : null;
119
125
  return {
120
126
  id: station.id,
121
127
  name: station.name,
@@ -124,6 +130,7 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
124
130
  state,
125
131
  // Tags, or nothing: a bare file name is the owner's, not the public's.
126
132
  nowPlaying: track && (track.title || track.artist) ? track : null,
133
+ progress,
127
134
  kind: video ? "video" : "audio",
128
135
  format: state === "stopped" || state === "offline" ? null : video
129
136
  ? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
package/src/stations.ts CHANGED
@@ -338,6 +338,9 @@ export class StationStore {
338
338
  }
339
339
  let listed = access.listed === undefined ? station.listed : access.listed === true;
340
340
  if (visibility !== "public") listed = false;
341
+ // Made private, a station forgets what was declared for it: going public
342
+ // again asks afresh, so a stale (or false) declaration cannot republish it.
343
+ if (visibility === "private" && !access.rights) station.rights = null;
341
344
  if (access.rights) {
342
345
  const basis = access.rights.basis;
343
346
  if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis as RightsBasis)) {
@@ -440,6 +443,8 @@ export interface PublicStation {
440
443
  /** Radio or TV. */
441
444
  kind: "audio" | "video";
442
445
  nowPlaying: { title: string; artist: string; album: string } | null;
446
+ /** Seconds into what is on and how long it is, when both are known. */
447
+ progress?: { position: number; duration: number } | null;
443
448
  format: { codec: string; bitrateKbps: number; sampleRate: number; channels: number } | null;
444
449
  listeners: number;
445
450
  }
@@ -1 +1 @@
1
- import{t as e}from"./main-DaqdNlZC.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
1
+ import{t as e}from"./main-CEkEtWRE.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
@@ -1 +1 @@
1
- import{t as e}from"./main-DaqdNlZC.js";var t={...e,"No parties live right now.":`Právě neprobíhají žádné párty.`,"Could not refresh parties.":`Párty se nepodařilo aktualizovat.`,"Pick a server in the directory, or paste its link here.":`Vyberte server v seznamu nebo sem vložte jeho odkaz.`,"Interface language":`Jazyk rozhraní`,Language:`Jazyk`,"Translation language":`Jazyk překladu`,"Original (auto-detect)":`Původní (automatické rozpoznání)`,Play:`Přehrát`,Pause:`Pozastavit`,Stop:`Zastavit`,Next:`Další`,Previous:`Předchozí`,Select:`Vybrat`,"Next/Prev":`Další/Předchozí`,Quit:`Ukončit`,"Now Playing":`Právě hraje`,"Spectrum Analyser":`Spektrální analyzátor`,Playlist:`Seznam skladeb`,Files:`Soubory`,"Open files":`Otevřít soubory`,"Open folder":`Otevřít složku`,"Add to library":`Přidat do knihovny`,"Replace playlist":`Nahradit seznam`,Filter:`Filtrovat`,Search:`Hledat`,"Nothing loaded.":`Nic není načteno.`,"Empty.":`Prázdné.`,"Loading…":`Načítání…`,"Connecting…":`Připojování…`,Connected:`Připojeno`,"Not connected":`Nepřipojeno`,"No source":`Žádný zdroj`,Playing:`Přehrávání`,Stopped:`Zastaveno`,Volume:`Hlasitost`,Seek:`Pozice přehrávání`,"Full screen":`Celá obrazovka`,Download:`Stáhnout`,Share:`Sdílet`,Copy:`Kopírovat`,"Copy link":`Kopírovat odkaz`,Copied:`Zkopírováno`,Save:`Uložit`,Cancel:`Zrušit`,Close:`Zavřít`,Remove:`Odebrat`,Delete:`Smazat`,Edit:`Upravit`,Add:`Přidat`,Send:`Odeslat`,Retry:`Zkusit znovu`,Back:`Zpět`,"Next page":`Další stránka`,"Previous page":`Předchozí stránka`,"Show more":`Zobrazit více`,Panels:`Panely`,"Reset layout":`Obnovit rozložení`,Show:`Zobrazit`,Hide:`Skrýt`,Collapse:`Sbalit`,Expand:`Rozbalit`,"Move up":`Nahoru`,"Move down":`Dolů`,Install:`Nainstalovat`,"TV mode":`Režim TV`,On:`Zapnuto`,Off:`Vypnuto`,Account:`Účet`,"Sign in":`Přihlásit se`,"Sign out":`Odhlásit se`,"Create account":`Vytvořit účet`,Email:`E-mail`,Password:`Heslo`,"Forgot password?":`Zapomenuté heslo?`,"Welcome back":`Vítejte zpět`,Profile:`Profil`,Handle:`Uživatelské jméno`,"Your servers":`Vaše servery`,Server:`Server`,Connect:`Připojit`,Disconnect:`Odpojit`,Directory:`Adresář`,Favourites:`Oblíbené`,Follow:`Sledovat`,Unfollow:`Přestat sledovat`,Notifications:`Oznámení`,"Phone number":`Telefonní číslo`,"Save number":`Uložit číslo`,Transcript:`Přepis`,Captions:`Titulky`,"Translate audio":`Překládat zvuk`,"Audio language":`Jazyk zvuku`,"Audio options":`Možnosti zvuku`,"Keep background sound":`Zachovat zvuk na pozadí`,"Background level":`Hlasitost pozadí`,"Speaker voices":`Hlasy mluvčích`,"Any voice":`Libovolný hlas`,"Male voice":`Mužský hlas`,"Female voice":`Ženský hlas`,"Original audio.":`Původní zvuk.`,"Buy translated audio":`Koupit překlad zvuku`,"Audio credit":`Zvukový kredit`,"Buy pass":`Koupit přístup`,Pass:`Přístup`,"Pay with":`Zaplatit pomocí`,"Check payment":`Ověřit platbu`,"Open checkout":`Přejít k platbě`,"Free sessions":`Bezplatné relace`,"Usage rates and expiry":`Ceny a platnost`,Catalogs:`Katalogy`,"Add catalog":`Přidat katalog`,"Catalog name":`Název katalogu`,Link:`Odkaz`,"Play link":`Přehrát odkaz`,"Go live":`Vysílat živě`,Publish:`Zveřejnit`,Listen:`Poslouchat`,"Live now":`Právě živě`,Upcoming:`Připravované`,Parties:`Společná sledování`,"Join party":`Připojit se ke sledování`,"Invite code":`Kód pozvánky`,Invite:`Pozvat`,Chat:`Chat`,"Chat message":`Zpráva chatu`,People:`Lidé`,Speaker:`Mluvčí`,Speakers:`Mluvčí`,Listener:`Posluchač`,Moderator:`Moderátor`,"Raise hand":`Zvednout ruku`,"Hand raises":`Zvednuté ruce`,Questions:`Otázky`,Resources:`Materiály`,Replay:`Záznam`,Schedule:`Rozvrh`,Event:`Událost`,Host:`Pořadatel`,About:`O události`,Stage:`Pódium`,Title:`Název`,Description:`Popis`,Topic:`Téma`,Public:`Veřejné`,"Anyone with the link":`Kdokoli s odkazem`,"Invited people":`Pozvané osoby`,"Who can join":`Kdo se může připojit`,"Date and time":`Datum a čas`,"Expected length":`Očekávaná délka`,Repeat:`Opakovat`,"Does not repeat":`Neopakuje se`,"Every day":`Každý den`,"Every week":`Každý týden`,Homepage:`Webové stránky`,"Host name":`Jméno pořadatele`,"Profile photo URL":`URL profilové fotky`,"Broadcast link":`Odkaz na vysílání`,"Edit event":`Upravit událost`,"Save changes":`Uložit změny`,"Create live":`Vytvořit vysílání`,"Schedule class":`Naplánovat lekci`,"Start class":`Zahájit lekci`,"End event":`Ukončit událost`,"End session":`Ukončit relaci`,"Session ended":`Relace ukončena`,"Event ended":`Událost ukončena`,Cancelled:`Zrušeno`,"Replay available":`Záznam je k dispozici`,Join:`Připojit se`,"Create invite":`Vytvořit pozvánku`,"Invite as":`Pozvat jako`,"Watch now":`Sledovat nyní`,"Start a room":`Vytvořit místnost`,"Recent replays":`Nedávné záznamy`,"Your account":`Váš účet`,"Write with AI":`Psát pomocí AI`,"Suggested title":`Navrhovaný název`,"Suggested description":`Navrhovaný popis`,"Suggested topic":`Navrhované téma`,"Use these details":`Použít tyto údaje`,"Learn something live.":`Učte se naživo.`,"Screen share":`Sdílet obrazovku`,"Upload and broadcast":`Nahrát a vysílat`,Help:`Nápověda`,"Reload Player":`Znovu načíst přehrávač`,"Copy Bundled CLI Path":`Kopírovat cestu k přibalenému CLI`,"Developer tools":`Vývojářské nástroje`,Undo:`Zpět`,Redo:`Znovu`,Cut:`Vyjmout`,Paste:`Vložit`,"Select all":`Vybrat vše`,Status:`Stav`,"Activity log":`Protokol aktivit`,Admin:`Správa`,"Playback controls":`Ovládání přehrávání`,"Tracks and folders":`Skladby a složky`,"Original-language captions.":`Titulky v původním jazyce.`,"Skip to playback controls":`Přejít k ovládání přehrávání`,"Skip to playlist":`Přejít k seznamu skladeb`,"Skip to transcript and translated audio":`Přejít k přepisu a přeloženému zvuku`};export{t as default};
1
+ import{t as e}from"./main-CEkEtWRE.js";var t={...e,"No parties live right now.":`Právě neprobíhají žádné párty.`,"Could not refresh parties.":`Párty se nepodařilo aktualizovat.`,"Pick a server in the directory, or paste its link here.":`Vyberte server v seznamu nebo sem vložte jeho odkaz.`,"Interface language":`Jazyk rozhraní`,Language:`Jazyk`,"Translation language":`Jazyk překladu`,"Original (auto-detect)":`Původní (automatické rozpoznání)`,Play:`Přehrát`,Pause:`Pozastavit`,Stop:`Zastavit`,Next:`Další`,Previous:`Předchozí`,Select:`Vybrat`,"Next/Prev":`Další/Předchozí`,Quit:`Ukončit`,"Now Playing":`Právě hraje`,"Spectrum Analyser":`Spektrální analyzátor`,Playlist:`Seznam skladeb`,Files:`Soubory`,"Open files":`Otevřít soubory`,"Open folder":`Otevřít složku`,"Add to library":`Přidat do knihovny`,"Replace playlist":`Nahradit seznam`,Filter:`Filtrovat`,Search:`Hledat`,"Nothing loaded.":`Nic není načteno.`,"Empty.":`Prázdné.`,"Loading…":`Načítání…`,"Connecting…":`Připojování…`,Connected:`Připojeno`,"Not connected":`Nepřipojeno`,"No source":`Žádný zdroj`,Playing:`Přehrávání`,Stopped:`Zastaveno`,Volume:`Hlasitost`,Seek:`Pozice přehrávání`,"Full screen":`Celá obrazovka`,Download:`Stáhnout`,Share:`Sdílet`,Copy:`Kopírovat`,"Copy link":`Kopírovat odkaz`,Copied:`Zkopírováno`,Save:`Uložit`,Cancel:`Zrušit`,Close:`Zavřít`,Remove:`Odebrat`,Delete:`Smazat`,Edit:`Upravit`,Add:`Přidat`,Send:`Odeslat`,Retry:`Zkusit znovu`,Back:`Zpět`,"Next page":`Další stránka`,"Previous page":`Předchozí stránka`,"Show more":`Zobrazit více`,Panels:`Panely`,"Reset layout":`Obnovit rozložení`,Show:`Zobrazit`,Hide:`Skrýt`,Collapse:`Sbalit`,Expand:`Rozbalit`,"Move up":`Nahoru`,"Move down":`Dolů`,Install:`Nainstalovat`,"TV mode":`Režim TV`,On:`Zapnuto`,Off:`Vypnuto`,Account:`Účet`,"Sign in":`Přihlásit se`,"Sign out":`Odhlásit se`,"Create account":`Vytvořit účet`,Email:`E-mail`,Password:`Heslo`,"Forgot password?":`Zapomenuté heslo?`,"Welcome back":`Vítejte zpět`,Profile:`Profil`,Handle:`Uživatelské jméno`,"Your servers":`Vaše servery`,Server:`Server`,Connect:`Připojit`,Disconnect:`Odpojit`,Directory:`Adresář`,Favourites:`Oblíbené`,Follow:`Sledovat`,Unfollow:`Přestat sledovat`,Notifications:`Oznámení`,"Phone number":`Telefonní číslo`,"Save number":`Uložit číslo`,Transcript:`Přepis`,Captions:`Titulky`,"Translate audio":`Překládat zvuk`,"Audio language":`Jazyk zvuku`,"Audio options":`Možnosti zvuku`,"Keep background sound":`Zachovat zvuk na pozadí`,"Background level":`Hlasitost pozadí`,"Speaker voices":`Hlasy mluvčích`,"Any voice":`Libovolný hlas`,"Male voice":`Mužský hlas`,"Female voice":`Ženský hlas`,"Original audio.":`Původní zvuk.`,"Buy translated audio":`Koupit překlad zvuku`,"Audio credit":`Zvukový kredit`,"Buy pass":`Koupit přístup`,Pass:`Přístup`,"Pay with":`Zaplatit pomocí`,"Check payment":`Ověřit platbu`,"Open checkout":`Přejít k platbě`,"Free sessions":`Bezplatné relace`,"Usage rates and expiry":`Ceny a platnost`,Catalogs:`Katalogy`,"Add catalog":`Přidat katalog`,"Catalog name":`Název katalogu`,Link:`Odkaz`,"Play link":`Přehrát odkaz`,"Go live":`Vysílat živě`,Publish:`Zveřejnit`,Listen:`Poslouchat`,"Live now":`Právě živě`,Upcoming:`Připravované`,Parties:`Společná sledování`,"Join party":`Připojit se ke sledování`,"Invite code":`Kód pozvánky`,Invite:`Pozvat`,Chat:`Chat`,"Chat message":`Zpráva chatu`,People:`Lidé`,Speaker:`Mluvčí`,Speakers:`Mluvčí`,Listener:`Posluchač`,Moderator:`Moderátor`,"Raise hand":`Zvednout ruku`,"Hand raises":`Zvednuté ruce`,Questions:`Otázky`,Resources:`Materiály`,Replay:`Záznam`,Schedule:`Rozvrh`,Event:`Událost`,Host:`Pořadatel`,About:`O události`,Stage:`Pódium`,Title:`Název`,Description:`Popis`,Topic:`Téma`,Public:`Veřejné`,"Anyone with the link":`Kdokoli s odkazem`,"Invited people":`Pozvané osoby`,"Who can join":`Kdo se může připojit`,"Date and time":`Datum a čas`,"Expected length":`Očekávaná délka`,Repeat:`Opakovat`,"Does not repeat":`Neopakuje se`,"Every day":`Každý den`,"Every week":`Každý týden`,Homepage:`Webové stránky`,"Host name":`Jméno pořadatele`,"Profile photo URL":`URL profilové fotky`,"Broadcast link":`Odkaz na vysílání`,"Edit event":`Upravit událost`,"Save changes":`Uložit změny`,"Create live":`Vytvořit vysílání`,"Schedule class":`Naplánovat lekci`,"Start class":`Zahájit lekci`,"End event":`Ukončit událost`,"End session":`Ukončit relaci`,"Session ended":`Relace ukončena`,"Event ended":`Událost ukončena`,Cancelled:`Zrušeno`,"Replay available":`Záznam je k dispozici`,Join:`Připojit se`,"Create invite":`Vytvořit pozvánku`,"Invite as":`Pozvat jako`,"Watch now":`Sledovat nyní`,"Start a room":`Vytvořit místnost`,"Recent replays":`Nedávné záznamy`,"Your account":`Váš účet`,"Write with AI":`Psát pomocí AI`,"Suggested title":`Navrhovaný název`,"Suggested description":`Navrhovaný popis`,"Suggested topic":`Navrhované téma`,"Use these details":`Použít tyto údaje`,"Learn something live.":`Učte se naživo.`,"Screen share":`Sdílet obrazovku`,"Upload and broadcast":`Nahrát a vysílat`,Help:`Nápověda`,"Reload Player":`Znovu načíst přehrávač`,"Copy Bundled CLI Path":`Kopírovat cestu k přibalenému CLI`,"Developer tools":`Vývojářské nástroje`,Undo:`Zpět`,Redo:`Znovu`,Cut:`Vyjmout`,Paste:`Vložit`,"Select all":`Vybrat vše`,Status:`Stav`,"Activity log":`Protokol aktivit`,Admin:`Správa`,"Playback controls":`Ovládání přehrávání`,"Tracks and folders":`Skladby a složky`,"Original-language captions.":`Titulky v původním jazyce.`,"Skip to playback controls":`Přejít k ovládání přehrávání`,"Skip to playlist":`Přejít k seznamu skladeb`,"Skip to transcript and translated audio":`Přejít k přepisu a přeloženému zvuku`};export{t as default};
@@ -1 +1 @@
1
- import{t as e}from"./main-DaqdNlZC.js";var t={...e,"No parties live right now.":`Ingen fester er live lige nu.`,"Could not refresh parties.":`Kunne ikke opdatere festerne.`,"Pick a server in the directory, or paste its link here.":`Vælg en server i oversigten, eller indsæt dens link her.`,"Interface language":`Grænsefladesprog`,Language:`Sprog`,"Translation language":`Oversættelsessprog`,"Original (auto-detect)":`Original (automatisk registrering)`,Play:`Afspil`,Pause:`Pause`,Stop:`Stop`,Next:`Næste`,Previous:`Forrige`,Select:`Vælg`,"Next/Prev":`Næste/Forrige`,Quit:`Afslut`,"Now Playing":`Afspilles nu`,"Spectrum Analyser":`Spektrumanalysator`,Playlist:`Afspilningsliste`,Files:`Filer`,"Open files":`Åbn filer`,"Open folder":`Åbn mappe`,"Add to library":`Føj til bibliotek`,"Replace playlist":`Erstat afspilningsliste`,Filter:`Filtrer`,Search:`Søg`,"Nothing loaded.":`Intet indlæst.`,"Empty.":`Tomt.`,"Loading…":`Indlæser…`,"Connecting…":`Forbinder…`,Connected:`Forbundet`,"Not connected":`Ikke forbundet`,"No source":`Ingen kilde`,Playing:`Afspiller`,Stopped:`Stoppet`,Volume:`Lydstyrke`,Seek:`Afspilningsposition`,"Full screen":`Fuld skærm`,Download:`Download`,Share:`Del`,Copy:`Kopiér`,"Copy link":`Kopiér link`,Copied:`Kopieret`,Save:`Gem`,Cancel:`Annuller`,Close:`Luk`,Remove:`Fjern`,Delete:`Slet`,Edit:`Rediger`,Add:`Tilføj`,Send:`Send`,Retry:`Prøv igen`,Back:`Tilbage`,"Next page":`Næste side`,"Previous page":`Forrige side`,"Show more":`Vis flere`,Panels:`Paneler`,"Reset layout":`Nulstil layout`,Show:`Vis`,Hide:`Skjul`,Collapse:`Fold sammen`,Expand:`Fold ud`,"Move up":`Flyt op`,"Move down":`Flyt ned`,Install:`Installer`,"TV mode":`TV-tilstand`,On:`Til`,Off:`Fra`,Account:`Konto`,"Sign in":`Log ind`,"Sign out":`Log ud`,"Create account":`Opret konto`,Email:`E-mail`,Password:`Adgangskode`,"Forgot password?":`Glemt adgangskode?`,"Welcome back":`Velkommen tilbage`,Profile:`Profil`,Handle:`Brugernavn`,"Your servers":`Dine servere`,Server:`Server`,Connect:`Forbind`,Disconnect:`Afbryd`,Directory:`Oversigt`,Favourites:`Favoritter`,Follow:`Følg`,Unfollow:`Følg ikke længere`,Notifications:`Notifikationer`,"Phone number":`Telefonnummer`,"Save number":`Gem nummer`,Transcript:`Transskription`,Captions:`Undertekster`,"Translate audio":`Oversæt lyd`,"Audio language":`Lydsprog`,"Audio options":`Lydindstillinger`,"Keep background sound":`Bevar baggrundslyd`,"Background level":`Baggrundslydstyrke`,"Speaker voices":`Talernes stemmer`,"Any voice":`Vilkårlig stemme`,"Male voice":`Mandlig stemme`,"Female voice":`Kvindelig stemme`,"Original audio.":`Original lyd.`,"Buy translated audio":`Køb oversat lyd`,"Audio credit":`Lydsaldo`,"Buy pass":`Køb adgang`,Pass:`Adgang`,"Pay with":`Betal med`,"Check payment":`Kontrollér betaling`,"Open checkout":`Gå til betaling`,"Free sessions":`Gratis sessioner`,"Usage rates and expiry":`Priser og udløb`,Catalogs:`Kataloger`,"Add catalog":`Tilføj katalog`,"Catalog name":`Katalognavn`,Link:`Link`,"Play link":`Afspil link`,"Go live":`Gå live`,Publish:`Udgiv`,Listen:`Lyt`,"Live now":`Live nu`,Upcoming:`Kommende`,Parties:`Seerfester`,"Join party":`Deltag i festen`,"Invite code":`Invitationskode`,Invite:`Inviter`,Chat:`Chat`,"Chat message":`Chatbesked`,People:`Personer`,Speaker:`Taler`,Speakers:`Talere`,Listener:`Lytter`,Moderator:`Moderator`,"Raise hand":`Ræk hånden op`,"Hand raises":`Oprakte hænder`,Questions:`Spørgsmål`,Resources:`Materialer`,Replay:`Genudsendelse`,Schedule:`Program`,Event:`Begivenhed`,Host:`Vært`,About:`Om`,Stage:`Scene`,Title:`Titel`,Description:`Beskrivelse`,Topic:`Emne`,Public:`Offentlig`,"Anyone with the link":`Alle med linket`,"Invited people":`Inviterede personer`,"Who can join":`Hvem kan deltage`,"Date and time":`Dato og klokkeslæt`,"Expected length":`Forventet varighed`,Repeat:`Gentag`,"Does not repeat":`Gentages ikke`,"Every day":`Hver dag`,"Every week":`Hver uge`,Homepage:`Hjemmeside`,"Host name":`Værtens navn`,"Profile photo URL":`URL til profilbillede`,"Broadcast link":`Udsendelseslink`,"Edit event":`Rediger begivenhed`,"Save changes":`Gem ændringer`,"Create live":`Opret livesending`,"Schedule class":`Planlæg undervisning`,"Start class":`Start undervisning`,"End event":`Afslut begivenhed`,"End session":`Afslut session`,"Session ended":`Session afsluttet`,"Event ended":`Begivenhed afsluttet`,Cancelled:`Annulleret`,"Replay available":`Genudsendelse tilgængelig`,Join:`Deltag`,"Create invite":`Opret invitation`,"Invite as":`Inviter som`,"Watch now":`Se nu`,"Start a room":`Opret et rum`,"Recent replays":`Seneste genudsendelser`,"Your account":`Din konto`,"Write with AI":`Skriv med AI`,"Suggested title":`Foreslået titel`,"Suggested description":`Foreslået beskrivelse`,"Suggested topic":`Foreslået emne`,"Use these details":`Brug disse oplysninger`,"Learn something live.":`Lær noget live.`,"Screen share":`Del skærm`,"Upload and broadcast":`Upload og udsend`,Help:`Hjælp`,"Reload Player":`Genindlæs afspiller`,"Copy Bundled CLI Path":`Kopiér stien til medfølgende CLI`,"Developer tools":`Udviklerværktøjer`,Undo:`Fortryd`,Redo:`Gentag`,Cut:`Klip`,Paste:`Indsæt`,"Select all":`Vælg alle`,Status:`Status`,"Activity log":`Aktivitetslog`,Admin:`Administration`,"Playback controls":`Afspilningskontroller`,"Tracks and folders":`Numre og mapper`,"Original-language captions.":`Undertekster på originalsproget.`,"Skip to playback controls":`Spring til afspilningskontroller`,"Skip to playlist":`Spring til afspilningslisten`,"Skip to transcript and translated audio":`Spring til transskription og oversat lyd`};export{t as default};
1
+ import{t as e}from"./main-CEkEtWRE.js";var t={...e,"No parties live right now.":`Ingen fester er live lige nu.`,"Could not refresh parties.":`Kunne ikke opdatere festerne.`,"Pick a server in the directory, or paste its link here.":`Vælg en server i oversigten, eller indsæt dens link her.`,"Interface language":`Grænsefladesprog`,Language:`Sprog`,"Translation language":`Oversættelsessprog`,"Original (auto-detect)":`Original (automatisk registrering)`,Play:`Afspil`,Pause:`Pause`,Stop:`Stop`,Next:`Næste`,Previous:`Forrige`,Select:`Vælg`,"Next/Prev":`Næste/Forrige`,Quit:`Afslut`,"Now Playing":`Afspilles nu`,"Spectrum Analyser":`Spektrumanalysator`,Playlist:`Afspilningsliste`,Files:`Filer`,"Open files":`Åbn filer`,"Open folder":`Åbn mappe`,"Add to library":`Føj til bibliotek`,"Replace playlist":`Erstat afspilningsliste`,Filter:`Filtrer`,Search:`Søg`,"Nothing loaded.":`Intet indlæst.`,"Empty.":`Tomt.`,"Loading…":`Indlæser…`,"Connecting…":`Forbinder…`,Connected:`Forbundet`,"Not connected":`Ikke forbundet`,"No source":`Ingen kilde`,Playing:`Afspiller`,Stopped:`Stoppet`,Volume:`Lydstyrke`,Seek:`Afspilningsposition`,"Full screen":`Fuld skærm`,Download:`Download`,Share:`Del`,Copy:`Kopiér`,"Copy link":`Kopiér link`,Copied:`Kopieret`,Save:`Gem`,Cancel:`Annuller`,Close:`Luk`,Remove:`Fjern`,Delete:`Slet`,Edit:`Rediger`,Add:`Tilføj`,Send:`Send`,Retry:`Prøv igen`,Back:`Tilbage`,"Next page":`Næste side`,"Previous page":`Forrige side`,"Show more":`Vis flere`,Panels:`Paneler`,"Reset layout":`Nulstil layout`,Show:`Vis`,Hide:`Skjul`,Collapse:`Fold sammen`,Expand:`Fold ud`,"Move up":`Flyt op`,"Move down":`Flyt ned`,Install:`Installer`,"TV mode":`TV-tilstand`,On:`Til`,Off:`Fra`,Account:`Konto`,"Sign in":`Log ind`,"Sign out":`Log ud`,"Create account":`Opret konto`,Email:`E-mail`,Password:`Adgangskode`,"Forgot password?":`Glemt adgangskode?`,"Welcome back":`Velkommen tilbage`,Profile:`Profil`,Handle:`Brugernavn`,"Your servers":`Dine servere`,Server:`Server`,Connect:`Forbind`,Disconnect:`Afbryd`,Directory:`Oversigt`,Favourites:`Favoritter`,Follow:`Følg`,Unfollow:`Følg ikke længere`,Notifications:`Notifikationer`,"Phone number":`Telefonnummer`,"Save number":`Gem nummer`,Transcript:`Transskription`,Captions:`Undertekster`,"Translate audio":`Oversæt lyd`,"Audio language":`Lydsprog`,"Audio options":`Lydindstillinger`,"Keep background sound":`Bevar baggrundslyd`,"Background level":`Baggrundslydstyrke`,"Speaker voices":`Talernes stemmer`,"Any voice":`Vilkårlig stemme`,"Male voice":`Mandlig stemme`,"Female voice":`Kvindelig stemme`,"Original audio.":`Original lyd.`,"Buy translated audio":`Køb oversat lyd`,"Audio credit":`Lydsaldo`,"Buy pass":`Køb adgang`,Pass:`Adgang`,"Pay with":`Betal med`,"Check payment":`Kontrollér betaling`,"Open checkout":`Gå til betaling`,"Free sessions":`Gratis sessioner`,"Usage rates and expiry":`Priser og udløb`,Catalogs:`Kataloger`,"Add catalog":`Tilføj katalog`,"Catalog name":`Katalognavn`,Link:`Link`,"Play link":`Afspil link`,"Go live":`Gå live`,Publish:`Udgiv`,Listen:`Lyt`,"Live now":`Live nu`,Upcoming:`Kommende`,Parties:`Seerfester`,"Join party":`Deltag i festen`,"Invite code":`Invitationskode`,Invite:`Inviter`,Chat:`Chat`,"Chat message":`Chatbesked`,People:`Personer`,Speaker:`Taler`,Speakers:`Talere`,Listener:`Lytter`,Moderator:`Moderator`,"Raise hand":`Ræk hånden op`,"Hand raises":`Oprakte hænder`,Questions:`Spørgsmål`,Resources:`Materialer`,Replay:`Genudsendelse`,Schedule:`Program`,Event:`Begivenhed`,Host:`Vært`,About:`Om`,Stage:`Scene`,Title:`Titel`,Description:`Beskrivelse`,Topic:`Emne`,Public:`Offentlig`,"Anyone with the link":`Alle med linket`,"Invited people":`Inviterede personer`,"Who can join":`Hvem kan deltage`,"Date and time":`Dato og klokkeslæt`,"Expected length":`Forventet varighed`,Repeat:`Gentag`,"Does not repeat":`Gentages ikke`,"Every day":`Hver dag`,"Every week":`Hver uge`,Homepage:`Hjemmeside`,"Host name":`Værtens navn`,"Profile photo URL":`URL til profilbillede`,"Broadcast link":`Udsendelseslink`,"Edit event":`Rediger begivenhed`,"Save changes":`Gem ændringer`,"Create live":`Opret livesending`,"Schedule class":`Planlæg undervisning`,"Start class":`Start undervisning`,"End event":`Afslut begivenhed`,"End session":`Afslut session`,"Session ended":`Session afsluttet`,"Event ended":`Begivenhed afsluttet`,Cancelled:`Annulleret`,"Replay available":`Genudsendelse tilgængelig`,Join:`Deltag`,"Create invite":`Opret invitation`,"Invite as":`Inviter som`,"Watch now":`Se nu`,"Start a room":`Opret et rum`,"Recent replays":`Seneste genudsendelser`,"Your account":`Din konto`,"Write with AI":`Skriv med AI`,"Suggested title":`Foreslået titel`,"Suggested description":`Foreslået beskrivelse`,"Suggested topic":`Foreslået emne`,"Use these details":`Brug disse oplysninger`,"Learn something live.":`Lær noget live.`,"Screen share":`Del skærm`,"Upload and broadcast":`Upload og udsend`,Help:`Hjælp`,"Reload Player":`Genindlæs afspiller`,"Copy Bundled CLI Path":`Kopiér stien til medfølgende CLI`,"Developer tools":`Udviklerværktøjer`,Undo:`Fortryd`,Redo:`Gentag`,Cut:`Klip`,Paste:`Indsæt`,"Select all":`Vælg alle`,Status:`Status`,"Activity log":`Aktivitetslog`,Admin:`Administration`,"Playback controls":`Afspilningskontroller`,"Tracks and folders":`Numre og mapper`,"Original-language captions.":`Undertekster på originalsproget.`,"Skip to playback controls":`Spring til afspilningskontroller`,"Skip to playlist":`Spring til afspilningslisten`,"Skip to transcript and translated audio":`Spring til transskription og oversat lyd`};export{t as default};