audio_view.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. const {
  2. form,
  3. button,
  4. div,
  5. h2,
  6. p,
  7. section,
  8. input,
  9. br,
  10. a,
  11. audio: audioHyperaxe,
  12. span,
  13. textarea,
  14. select,
  15. label,
  16. option
  17. } = require("../server/node_modules/hyperaxe");
  18. const { template, i18n, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views");
  19. const moment = require("../server/node_modules/moment");
  20. const { config } = require("../server/SSB_server.js");
  21. const { renderUrl } = require("../backend/renderUrl")
  22. const { renderMapLocationVisitLabel } = require("./maps_view");
  23. const opinionCategories = require("../backend/opinion_categories");
  24. const userId = config.keys.id;
  25. const safeArr = (v) => (Array.isArray(v) ? v : []);
  26. const safeText = (v) => String(v || "").trim();
  27. const buildReturnTo = (filter, params = {}) => {
  28. const f = safeText(filter || "all");
  29. const q = safeText(params.q || "");
  30. const sort = safeText(params.sort || "recent");
  31. const parts = [`filter=${encodeURIComponent(f)}`];
  32. if (q) parts.push(`q=${encodeURIComponent(q)}`);
  33. if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
  34. return `/audios?${parts.join("&")}`;
  35. };
  36. const renderTags = (tags) => {
  37. const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
  38. return list.length
  39. ? div(
  40. { class: "card-tags" },
  41. list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  42. )
  43. : null;
  44. };
  45. const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
  46. form(
  47. {
  48. method: "POST",
  49. action: audioObj.isFavorite
  50. ? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}`
  51. : `/audios/favorites/add/${encodeURIComponent(audioObj.key)}`
  52. },
  53. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  54. button(
  55. { type: "submit", class: "filter-btn" },
  56. audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton
  57. )
  58. );
  59. const renderTranscodeButton = (audioObj) =>
  60. audioObj.isBcs
  61. ? form(
  62. { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}`, class: "audio-transcode-form" },
  63. button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
  64. )
  65. : null;
  66. const renderAudioPlayer = (audioObj, opts = {}) =>
  67. audioObj?.url
  68. ? div(
  69. { class: "audio-container" },
  70. audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }),
  71. opts.skipTranscode ? null : renderTranscodeButton(audioObj)
  72. )
  73. : p(i18n.audioNoFile);
  74. const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
  75. const returnTo = buildReturnTo(filter, params);
  76. const isAuthor = String(audioObj.author) === String(userId);
  77. const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0;
  78. if (!isAuthor) return [];
  79. const items = [];
  80. if (!hasOpinions) {
  81. items.push(
  82. form(
  83. { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` },
  84. input({ type: "hidden", name: "returnTo", value: returnTo }),
  85. button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
  86. )
  87. );
  88. }
  89. items.push(
  90. form(
  91. { method: "POST", action: `/audios/delete/${encodeURIComponent(audioObj.key)}` },
  92. input({ type: "hidden", name: "returnTo", value: returnTo }),
  93. button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
  94. )
  95. );
  96. return items;
  97. };
  98. const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
  99. const list = safeArr(comments).filter(c => {
  100. const t = c && c.value && c.value.content && c.value.content.text;
  101. return t && String(t).trim();
  102. });
  103. const commentsCount = list.length;
  104. return div(
  105. { class: "vote-comments-section" },
  106. div(
  107. { class: "comments-count" },
  108. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  109. span({ class: "card-value" }, String(commentsCount))
  110. ),
  111. div(
  112. { class: "comment-form-wrapper" },
  113. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  114. form(
  115. { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  116. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  117. textarea({
  118. id: "comment-text",
  119. name: "text",
  120. rows: 4,
  121. class: "comment-textarea",
  122. placeholder: i18n.voteNewCommentPlaceholder
  123. }),
  124. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  125. br(),
  126. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  127. )
  128. ),
  129. list.length
  130. ? div(
  131. { class: "comments-list" },
  132. list.map((c) => {
  133. const author = c?.value?.author || "";
  134. const ts = c?.value?.timestamp || c?.timestamp;
  135. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  136. const relDate = ts ? moment(ts).fromNow() : "";
  137. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  138. const content = c?.value?.content || {};
  139. const rootId = content.fork || content.root || null;
  140. const text = content.text || "";
  141. return div(
  142. { class: "votations-comment-card" },
  143. span(
  144. { class: "created-at" },
  145. span(i18n.createdBy),
  146. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  147. absDate ? span(" | ") : "",
  148. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  149. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  150. relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  151. ),
  152. p({ class: "votations-comment-text" }, ...renderUrl(text))
  153. );
  154. })
  155. )
  156. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  157. );
  158. };
  159. const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => {
  160. const returnTo = buildReturnTo(filter, params);
  161. return audios.length
  162. ? audios.map((audioObj) => {
  163. const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0;
  164. const title = safeText(audioObj.title);
  165. const ownerActions = renderAudioOwnerActions(filter, audioObj, params);
  166. return div(
  167. { class: "tags-header audio-card" },
  168. div(
  169. { class: "bookmark-topbar" },
  170. div(
  171. { class: "bookmark-topbar-left" },
  172. form(
  173. { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
  174. input({ type: "hidden", name: "returnTo", value: returnTo }),
  175. input({ type: "hidden", name: "filter", value: filter || "all" }),
  176. params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
  177. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  178. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  179. ),
  180. renderAudioFavoriteToggle(audioObj, returnTo),
  181. audioObj.author && String(audioObj.author) !== String(userId)
  182. ? form(
  183. { method: "GET", action: "/pm" },
  184. input({ type: "hidden", name: "recipients", value: audioObj.author }),
  185. button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
  186. )
  187. : null
  188. ),
  189. ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
  190. ),
  191. title ? h2(title) : null,
  192. audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null,
  193. renderAudioPlayer(audioObj),
  194. div(
  195. { class: "card-comments-summary" },
  196. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  197. span({ class: "card-value" }, String(commentCount)),
  198. br(),
  199. br(),
  200. form(
  201. { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
  202. input({ type: "hidden", name: "returnTo", value: returnTo }),
  203. input({ type: "hidden", name: "filter", value: filter || "all" }),
  204. params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
  205. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  206. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  207. )
  208. ),
  209. div({ class: "card-spread-left" }, renderSpreadButton(audioObj.key, (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads)),
  210. renderMapLocationVisitLabel(audioObj.mapUrl),
  211. br(),
  212. (() => {
  213. const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
  214. const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
  215. const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
  216. return p(
  217. { class: "card-footer" },
  218. span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  219. userLink(audioObj.author),
  220. showUpdated
  221. ? span(
  222. { class: "votations-comment-date" },
  223. ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
  224. )
  225. : null
  226. );
  227. })()
  228. );
  229. })
  230. : p(params.q ? i18n.audioNoMatch : i18n.noAudios);
  231. };
  232. const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
  233. const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
  234. return div(
  235. { class: "div-center audio-form" },
  236. form(
  237. {
  238. action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
  239. method: "POST",
  240. enctype: "multipart/form-data"
  241. },
  242. input({ type: "hidden", name: "returnTo", value: returnTo }),
  243. span(i18n.audioFileLabel),
  244. br(),
  245. input({ type: "file", name: "audio", required: filter !== "edit" }),
  246. br(),
  247. br(),
  248. span(i18n.audioTitleLabel),
  249. br(),
  250. input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
  251. br(),
  252. span(i18n.audioDescriptionLabel),
  253. br(),
  254. textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
  255. br(),
  256. span(i18n.mapLocationTitle || "Map Location"),
  257. br(),
  258. input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: audioToEdit?.mapUrl || "" }),
  259. br(),
  260. span(i18n.audioTagsLabel),
  261. br(),
  262. input({
  263. type: "text",
  264. name: "tags",
  265. placeholder: i18n.audioTagsPlaceholder,
  266. value: safeArr(audioToEdit?.tags).join(", ")
  267. }),
  268. br(),
  269. br(),
  270. button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton)
  271. )
  272. );
  273. };
  274. exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => {
  275. const title =
  276. filter === "mine"
  277. ? i18n.audioMineSectionTitle
  278. : filter === "create"
  279. ? i18n.audioCreateSectionTitle
  280. : filter === "edit"
  281. ? i18n.audioUpdateSectionTitle
  282. : filter === "recent"
  283. ? i18n.audioRecentSectionTitle
  284. : filter === "top"
  285. ? i18n.audioTopSectionTitle
  286. : filter === "favorites"
  287. ? i18n.audioFavoritesSectionTitle
  288. : i18n.audioAllSectionTitle;
  289. const q = safeText(params.q || "");
  290. const sort = safeText(params.sort || "recent");
  291. const list = safeArr(audios);
  292. const audioToEdit = audioId ? list.find((a) => a.key === audioId) : null;
  293. return template(
  294. title,
  295. section(
  296. div({ class: "tags-header" },
  297. h2(title),
  298. p(i18n.audioDescription)
  299. ),
  300. (() => {
  301. const { renderReachChip } = require('./clearnet_view');
  302. const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
  303. return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
  304. })(),
  305. br(),
  306. div(
  307. { class: "filters" },
  308. form(
  309. { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
  310. input({ type: "hidden", name: "q", value: q }),
  311. input({ type: "hidden", name: "sort", value: sort }),
  312. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
  313. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
  314. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
  315. button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
  316. button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
  317. button(
  318. { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
  319. i18n.audioFilterFavorites
  320. ),
  321. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
  322. )
  323. )
  324. ),
  325. section(
  326. filter === "create" || filter === "edit"
  327. ? renderAudioForm(filter, audioId, audioToEdit, { ...params, filter })
  328. : section(
  329. div(
  330. { class: "audios-search" },
  331. form(
  332. { method: "GET", action: "/audios", class: "filter-box" },
  333. input({ type: "hidden", name: "filter", value: filter }),
  334. input({
  335. type: "text",
  336. name: "q",
  337. value: q,
  338. placeholder: i18n.audioSearchPlaceholder,
  339. class: "filter-box__input"
  340. }),
  341. div(
  342. { class: "filter-box__controls" },
  343. select(
  344. { name: "sort", class: "filter-box__select" },
  345. option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent),
  346. option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest),
  347. option({ value: "top", selected: sort === "top" }, i18n.audioSortTop)
  348. ),
  349. button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton)
  350. )
  351. )
  352. ),
  353. div({ class: "audios-list" }, renderAudioList(list, filter, { q, sort }))
  354. )
  355. )
  356. );
  357. };
  358. exports.singleAudioView = async (audioObj, filter = "all", comments = [], params = {}) => {
  359. const q = safeText(params.q || "");
  360. const sort = safeText(params.sort || "recent");
  361. const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
  362. const title = safeText(audioObj.title);
  363. const isAuthor = String(audioObj.author) === String(userId);
  364. const { renderReachChip } = require('./clearnet_view');
  365. const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
  366. const chips = [
  367. renderLifespanChip(audioObj.lifetime, i18n),
  368. audioObj.sizeBytes ? renderEcoTax(audioObj.sizeBytes, audioObj.key) : null
  369. ].filter(Boolean);
  370. const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
  371. const sideActions = [];
  372. sideActions.push(renderAudioFavoriteToggle(audioObj, returnTo));
  373. if (audioObj.author && String(audioObj.author) !== String(userId)) {
  374. sideActions.push(form(
  375. { method: "GET", action: "/pm" },
  376. input({ type: "hidden", name: "recipients", value: audioObj.author }),
  377. button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
  378. ));
  379. }
  380. if (audioObj.isBcs) {
  381. sideActions.push(form(
  382. { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}` },
  383. button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
  384. ));
  385. }
  386. for (const a of ownerActions) sideActions.push(a);
  387. const tagsNode = renderTags(audioObj.tags);
  388. const audioSide = div({ class: "tribe-side" },
  389. div({ class: "shop-title-row" },
  390. title ? h2({ class: "tribe-card-title" }, title) : null,
  391. renderReachChip(isClearnet, i18n)
  392. ),
  393. chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
  394. safeText(audioObj.description)
  395. ? p({ class: "tribe-side-description" }, ...renderUrl(audioObj.description))
  396. : null,
  397. tagsNode,
  398. div({ class: "card-spread-centered" }, renderSpreadButton(audioObj.key, params.spreads)),
  399. renderMapLocationVisitLabel(audioObj.mapUrl)
  400. );
  401. const audioMain = div({ class: "tribe-main" },
  402. sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
  403. renderAudioPlayer(audioObj),
  404. div({ class: "voting-buttons" },
  405. opinionCategories.map((category) =>
  406. form(
  407. { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
  408. input({ type: "hidden", name: "returnTo", value: returnTo }),
  409. button(
  410. { class: "vote-btn" },
  411. `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
  412. audioObj.opinions?.[category] || 0
  413. }]`
  414. )
  415. )
  416. )
  417. ),
  418. (() => {
  419. const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
  420. const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
  421. const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
  422. return p(
  423. { class: "card-footer" },
  424. span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  425. userLink(audioObj.author),
  426. showUpdated
  427. ? span(
  428. { class: "votations-comment-date" },
  429. ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
  430. )
  431. : null
  432. );
  433. })(),
  434. renderAudioCommentsSection(audioObj.key, comments, returnTo)
  435. );
  436. return template(
  437. i18n.audioTitle,
  438. section(
  439. div({ class: "tags-header" },
  440. h2(i18n.audioAllSectionTitle || i18n.audioTitle),
  441. p(i18n.audioDescription)
  442. ),
  443. div(
  444. { class: "filters" },
  445. form(
  446. { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
  447. input({ type: "hidden", name: "q", value: q }),
  448. input({ type: "hidden", name: "sort", value: sort }),
  449. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
  450. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
  451. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
  452. button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
  453. button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
  454. button(
  455. { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
  456. i18n.audioFilterFavorites
  457. ),
  458. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
  459. )
  460. ),
  461. div({ class: "tribe-details" }, audioSide, audioMain)
  462. )
  463. );
  464. };
  465. const { renderCompositionSequence } = require("./melody_view");
  466. exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload = null, availableIds = null, itemSize = null }) => {
  467. const title = i18n.audioTranscodeDetailTitle || "Transcode";
  468. const composition = Array.isArray(audio.bcsComposition) ? audio.bcsComposition : [];
  469. const hasStego = decoded && stegoPayload && (stegoPayload.id || stegoPayload.ts || stegoPayload.msg);
  470. const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm:ss") : null;
  471. return template(
  472. title,
  473. section(
  474. div({ class: "tags-header" },
  475. h2(title),
  476. p(i18n.audioTranscodeDetailDescription || "Decode the embedded payload and the original blockchain composition map.")
  477. ),
  478. div({ class: "filters" },
  479. form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" },
  480. input({ type: "hidden", name: "filter", value: "all" }),
  481. button({ type: "submit", class: "filter-btn" }, i18n.audioBackToBcs || "Back to BCS")
  482. )
  483. ),
  484. div({ class: "bookmark-item card" },
  485. audio.title ? h2(audio.title) : null,
  486. renderAudioPlayer(audio, { skipTranscode: true }),
  487. p({ class: "transcode-meta card-footer" },
  488. userLink(audio.author),
  489. span({ class: "melody-meta-sep" }, " · "),
  490. span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm:ss")),
  491. itemSize ? span({ class: "melody-meta-sep" }, " · ") : null,
  492. itemSize ? renderEcoTax(itemSize, audio.key) : null
  493. ),
  494. safeText(audio.description) ? p({ class: "melody-bcs-desc" }, audio.description) : null,
  495. renderTags(audio.tags),
  496. br(),
  497. form({ method: "POST", action: `/melody/transcode/${encodeURIComponent(audio.key)}`, class: "audio-transcode-run-form" },
  498. button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
  499. ),
  500. br(),
  501. decoded
  502. ? div({ class: "transcode-result" },
  503. hasStego
  504. ? [
  505. div({ class: "transcode-stego-field" },
  506. span({ class: "card-label" }, (i18n.audioTranscodeStegoTimestamp || "Generated at") + ": "),
  507. span({ class: "card-value" }, stegoDate || (i18n.audioTranscodeStegoUnknown || "—"))
  508. ),
  509. div({ class: "transcode-stego-field" },
  510. span({ class: "card-label" }, (i18n.audioTranscodeStegoOasisId || "By") + ": "),
  511. stegoPayload.id ? userLink(stegoPayload.id) : span({ class: "card-value" }, i18n.audioTranscodeStegoUnknown || "—")
  512. ),
  513. div({ class: "transcode-stego-field transcode-stego-msg" },
  514. span({ class: "card-label" }, (i18n.audioTranscodeStegoMessage || "TEXT") + ":"),
  515. br(),
  516. stegoPayload.msg
  517. ? p({ class: "transcode-stego-text" }, stegoPayload.msg)
  518. : span({ class: "card-value" }, i18n.audioTranscodeStegoEmpty || "(none)")
  519. )
  520. ]
  521. : p({ class: "empty" }, i18n.audioTranscodeStegoNotFound || "No steganographic payload could be decoded from this audio."),
  522. composition.length
  523. ? renderCompositionSequence(composition, availableIds)
  524. : p({ class: "empty" }, i18n.audioTranscodeCompositionEmpty || "This audio does not include a stored blockchain composition.")
  525. )
  526. : null
  527. )
  528. )
  529. );
  530. };
  531. exports.audiosTranscodeView = exports.audioTranscodeDetailView;