From aa1095eef26b5ca9b555ee04a38e39793c04c979 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 6 Apr 2025 23:16:33 +1200 Subject: [PATCH] Add embedded player --- public/player-marker.js | 10 ++ pug/album_grid.pug | 2 +- pug/home.pug | 1 + pug/includes/layout.pug | 207 +++++++++++++++--------------- pug/includes/tag-status.pug | 8 +- pug/player.pug | 5 + routes/app.js | 2 +- routes/load-collection.js | 2 +- routes/play.js | 19 +++ scripts/populate-albums-tracks.js | 2 +- start.js | 12 +- 11 files changed, 160 insertions(+), 110 deletions(-) create mode 100644 public/player-marker.js create mode 100644 pug/player.pug create mode 100644 routes/play.js diff --git a/public/player-marker.js b/public/player-marker.js new file mode 100644 index 0000000..ade80ce --- /dev/null +++ b/public/player-marker.js @@ -0,0 +1,10 @@ +function movePlayer() { + const pc = document.getElementById("player-container") + const playerExists = pc.querySelector("iframe") + if (!playerExists) return + const pm = document.getElementById("player-marker") + pm.style.height = `${pc.clientHeight}px` + pc.style.top = `${Math.round(pm.getBoundingClientRect().top)}px` +} +movePlayer() +document.body.addEventListener("htmx:afterSettle", movePlayer) diff --git a/pug/album_grid.pug b/pug/album_grid.pug index 67a4be9..b1eb679 100644 --- a/pug/album_grid.pug +++ b/pug/album_grid.pug @@ -8,7 +8,7 @@ block view .d-grid.gx8.gy12.jc-center.break-word(style="grid-template-columns: repeat(auto-fit, 210px)") each item in items div - a.album-grid-link(href=item.item_url target="_blank") + a.s-link.album-grid-link(href=`/api/play/${item.item_type}/${item.item_id}` hx-target="#player" hx-select="#player" hx-indicator="null" hx-push-url="false") img(loading="lazy" src=item.item_art_url width=210 height=210) p.fs-body3.mb8= item.item_title .d-flex.fw-wrap.g4 diff --git a/pug/home.pug b/pug/home.pug index 96d8ca2..c3e7102 100644 --- a/pug/home.pug +++ b/pug/home.pug @@ -4,6 +4,7 @@ html meta(charset="utf-8") title BC Explorer + link(rel="icon" href="/favicon.png") link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") script(src="/static/htmx.js") meta(name="htmx-config" content='{"requestClass":"is-loading"}') diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug index 1c15842..392a69d 100644 --- a/pug/includes/layout.pug +++ b/pug/includes/layout.pug @@ -22,6 +22,7 @@ html link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") script(src="/static/htmx.js") script(src="/static/wordcloud.js") + script(defer src="/static/player-marker.js") meta(name="htmx-config" content='{"requestClass":"is-loading"}') style. .themed { @@ -69,108 +70,112 @@ html svg { flex-shrink: 0; } - body.themed.theme-system.overflow-y-scroll(hx-boost="true") - header.s-topbar.ps-sticky.t0 - .s-topbar--container.wmx9 - .s-topbar--logo - != icons.get("compass-solid", 24) - .ml4 BC Explorer - .fl-grow1 - nav - ul.s-navigation - li: +navi("arrange", "album", "album", "Album") - li: +navi("arrange", "artist", "people-tag", "Artist") - li: +navi("arrange", "label", "flower", "Label") - //- asana, flower, component, circle-spark, rhombus, sphere, union-alt, color-wheel, community, combine - li: +navi("arrange", "tag", "label", "Tag") - li: +navi("arrange", "track", "music-note", "Track") - .px16 - nav - ul.s-navigation.s-navigation__toggle.g0 - li: +navi("shape", "grid").brr0!= icons.get("view-grid") - li: +navi("shape", "list").blr0!= icons.get("align-justify") - .fl-grow1 - #player(hx-preserve) - button.s-btn.s-btn__outlined.s-btn__xs!= icons.get("play") + body.themed.theme-system.overflow-y-scroll(hx-boost="true" hx-swap="outerHTML" hx-target="#page" hx-select="#page") + #page + header.s-topbar.ps-sticky.t0 + .s-topbar--container.wmx9 + .s-topbar--logo + != icons.get("compass-solid", 24) + .ml4 BC Explorer + .fl-grow1 + nav + ul.s-navigation + li: +navi("arrange", "album", "album", "Album") + li: +navi("arrange", "artist", "people-tag", "Artist") + li: +navi("arrange", "label", "flower", "Label") + //- asana, flower, component, circle-spark, rhombus, sphere, union-alt, color-wheel, community, combine + li: +navi("arrange", "tag", "label", "Tag") + li: +navi("arrange", "track", "music-note", "Track") + .px16 + nav + ul.s-navigation.s-navigation__toggle.g0 + li: +navi("shape", "grid").brr0!= icons.get("view-grid") + li: +navi("shape", "list").blr0!= icons.get("align-justify") + .fl-grow1 - .d-flex.py24.px16.g24.fs-body1 - .fl-grow1 - block view + .d-flex.py24.px16.g24.fs-body1 + main.fl-grow1 + block view - div - .ps-sticky.d-flex.fd-column.g12.wmx4(style="top: 80px") - if arrange === "tag" - include tag-status.pug + aside.ws3 + .ps-sticky.d-flex.fd-column.g12(style="top: 80px") + if arrange === "tag" + include tag-status.pug - if filter && filter_field - .s-sidebarwidget.s-sidebarwidget__blue.d-flex.ai-center.gx16.jc-space-between.p8.pl16 - != icons.get("search") - .fl-grow1 Searching for #[strong= searchText] - a.s-btn.s-notice--btn(href=and({filter: null, filter_field: null, filter_fuzzy: null})) Clear - else - form.d-flex.ai-stretch.gx8.jc-space-between.baw0(hx-indicator="#search-submit") - input.s-input(name="filter" placeholder="Search" autocomplete="off").fl-grow1 - input(type="hidden" name="filter_field" value= - ( arrange === "artist" ? "band_name" - : arrange === "label" ? "band_url" - : arrange === "tag" ? "tag" - : "item_title")) - input(type="hidden" name="filter_fuzzy" value="true") - input(type="hidden" name="arrange" value=arrange) - input(type="hidden" name="shape" value=shape) - button.s-btn.s-btn__xs.s-btn__icon.s-btn__outlined.s-btn__muted#search-submit(style="height: 38px")!= icons.get("search") + if filter && filter_field + .s-sidebarwidget.s-sidebarwidget__blue.d-flex.ai-center.gx16.jc-space-between.p8.pl16 + != icons.get("search") + .fl-grow1 Searching for #[strong= searchText] + a.s-btn.s-notice--btn(href=and({filter: null, filter_field: null, filter_fuzzy: null})) Clear + else + form.d-flex.ai-stretch.gx8.jc-space-between.baw0(hx-indicator="#search-submit") + input.s-input(name="filter" placeholder="Search" autocomplete="off").fl-grow1 + input(type="hidden" name="filter_field" value= + ( arrange === "artist" ? "band_name" + : arrange === "label" ? "band_url" + : arrange === "tag" ? "tag" + : "item_title")) + input(type="hidden" name="filter_fuzzy" value="true") + input(type="hidden" name="arrange" value=arrange) + input(type="hidden" name="shape" value=shape) + button.s-btn.s-btn__xs.s-btn__icon.s-btn__outlined.s-btn__muted#search-submit(style="height: 38px")!= icons.get("search") - .s-sidebarwidget.wmn3 - .s-sidebarwidget--header Collection - table.s-sidebarwidget--content.s-sidebarwidget__items - tr.s-sidebarwidget--item - th items - td= count.total - tr.s-sidebarwidget--item - th runtime - td= count.runtime - tr.s-sidebarwidget--item - th format - td - = count.albums - span.fc-black-400= ` albums` - span.fc-black-250= ` / ` - = count.singles - span.fc-black-400= ` singles` - tr.s-sidebarwidget--item - th price - td - = count.free - span.fc-black-400= ` free` - span.fc-black-250= ` / ` - = count.paid - span.fc-black-400= ` paid` - tr.s-sidebarwidget--item - th tracks - td - = count.tracks - span.pl8.fc-black-250= ` / ` - span.fc-black-400 avg #{count.avgTracks} - tr.s-sidebarwidget--item - th tags - td - = count.tags - span.pl8.fc-black-250= ` / ` - span.fc-black-400 avg #{count.avgTags} - span.fc-black-250= ` / ` - span.fc-black-400 lonely #{count.lonelyTags} - tr.s-sidebarwidget--item - th value - td - = `${count.displayCurrencySymbol}${count.value} ` - span.fc-black-400 #{count.displayCurrency} - tr.s-sidebarwidget--item - th diversity - //- supernova red-500, warm yellow-500, hot orange-500 - //- 0-9 black, 10-99 yellow, 100-999 orange, 1000+ red - td.w100 - .s-progress.d-grid.g2.h4.mtn6(style=`grid-template-columns: ${count.taste.map(t => t + "fr").join(" ")}`).bg-white.fc-black-400.fs-fine.lh-xxl - .s-progress--bar.bg-black-400(title=`${count.taste[0]} labels with <20 fans`)= count.taste[0] - .s-progress--bar.bg-yellow-400(title=`${count.taste[1]} labels with 20-199 fans`)= count.taste[1] - .s-progress--bar.bg-orange-400(title=`${count.taste[2]} labels with 200-1999 fans`)= count.taste[2] - .s-progress--bar.bg-red-400(title=`${count.taste[3]} labels with >2000 fans`)= count.taste[3] + #player-marker.pe-none.myn6 + + .s-sidebarwidget + .s-sidebarwidget--header Collection + table.s-sidebarwidget--content.s-sidebarwidget__items + tr.s-sidebarwidget--item + th items + td= count.total + tr.s-sidebarwidget--item + th runtime + td= count.runtime + tr.s-sidebarwidget--item + th format + td + = count.albums + span.fc-black-400= ` albums` + span.fc-black-250= ` / ` + = count.singles + span.fc-black-400= ` singles` + tr.s-sidebarwidget--item + th price + td + = count.free + span.fc-black-400= ` free` + span.fc-black-250= ` / ` + = count.paid + span.fc-black-400= ` paid` + tr.s-sidebarwidget--item + th tracks + td + = count.tracks + span.pl8.fc-black-250= ` / ` + span.fc-black-400 avg #{count.avgTracks} + tr.s-sidebarwidget--item + th tags + td + = count.tags + span.pl8.fc-black-250= ` / ` + span.fc-black-400 avg #{count.avgTags} + span.fc-black-250= ` / ` + span.fc-black-400 lonely #{count.lonelyTags} + tr.s-sidebarwidget--item + th value + td + = `${count.displayCurrencySymbol}${count.value} ` + span.fc-black-400 #{count.displayCurrency} + tr.s-sidebarwidget--item + th diversity + //- supernova red-500, warm yellow-500, hot orange-500 + //- 0-9 black, 10-99 yellow, 100-999 orange, 1000+ red + td.w100 + .s-progress.d-grid.g2.h4.mtn6(style=`grid-template-columns: ${count.taste.map(t => t + "fr").join(" ")}`).bg-white.fc-black-400.fs-fine.lh-xxl + .s-progress--bar.bg-black-400(title=`${count.taste[0]} labels with <20 fans`)= count.taste[0] + .s-progress--bar.bg-yellow-400(title=`${count.taste[1]} labels with 20-199 fans`)= count.taste[1] + .s-progress--bar.bg-orange-400(title=`${count.taste[2]} labels with 200-1999 fans`)= count.taste[2] + .s-progress--bar.bg-red-400(title=`${count.taste[3]} labels with >2000 fans`)= count.taste[3] + + #player-container.ps-fixed.r16.ws3(hx-preserve="true") + #player diff --git a/pug/includes/tag-status.pug b/pug/includes/tag-status.pug index 047f594..ac10009 100644 --- a/pug/includes/tag-status.pug +++ b/pug/includes/tag-status.pug @@ -1,14 +1,14 @@ if downloader.total > 0 || downloader.outcome #tag-download if !downloader.running && !downloader.outcome - form.s-notice.s-notice__info.d-flex.ai-center.p8.gx16.pl16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-post="/api/tag-download") + form.s-notice.s-notice__info.d-flex.ai-center.p8.gx16.pl16(role="status" hx-target="#tag-download" hx-post="/api/tag-download") != icons.get("info-circle") div Tag data needs to be downloaded. This will take a while. input(type="hidden" name="account" value=account) button.s-btn.s-btn__outlined Download now else if !downloader.outcome - .s-notice.p16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-get=`/api/tag-download?account=${account}` hx-trigger="every 5s" hx-indicator="null") + .s-notice.p16(role="status" hx-target="#tag-download" hx-get=`/api/tag-download?account=${account}` hx-trigger="every 5s" hx-indicator="null") .d-flex.gx16.ai-center != icons.get("cloud-download") .fl-grow1 Downloading tags... @@ -23,7 +23,7 @@ if downloader.total > 0 || downloader.outcome span= downloader.total else if downloader.outcome === "Success" - .s-notice.s-notice__success.p8.gx16.pl16.d-flex.ai-center.wmn3 + .s-notice.s-notice__success.p8.gx16.pl16.d-flex.ai-center title#title(hx-swap-oob="true") * Tags downloaded! | BC Explorer != icons.get("cloud-check") .fl-grow1 Tags downloaded. @@ -31,7 +31,7 @@ if downloader.total > 0 || downloader.outcome a.s-btn.s-btn__outlined(href="") Refresh else - .s-notice.s-notice__danger.p8.gx16.pl16.d-flex.ai-center.wmn3 + .s-notice.s-notice__danger.p8.gx16.pl16.d-flex.ai-center title#title(hx-swap-oob="true") * Tag download failed! | BC Explorer != icons.get("cloud-xmark") .fl-grow1= downloader.outcome diff --git a/pug/player.pug b/pug/player.pug new file mode 100644 index 0000000..7750297 --- /dev/null +++ b/pug/player.pug @@ -0,0 +1,5 @@ +#player + .s-sidebarwidget(style="overflow: hidden") + div(style="margin: -1px; margin-bottom: -11px").ps-relative + a.ps-absolute.bg-white.bar0.t0.r0.s-btn.s-btn__icon.s-btn__muted.s-btn__sm.px16(href=`/api/play/${item_type}/${item_id}` hx-target="#player" hx-select="#player" hx-push-url="false").fc-theme-primary!= icons.get("refresh-double") + iframe(style="border: 0; width: 100%; height: 424px;" src=`https://bandcamp.com/EmbeddedPlayer/${item_type}=${item_id}/size=large/bgcol=ffffff/linkcol=63b2cc/artwork=none/transparent=true/`) diff --git a/routes/app.js b/routes/app.js index 235caad..4900872 100644 --- a/routes/app.js +++ b/routes/app.js @@ -67,7 +67,7 @@ const schema = { query: z.object({ arrange: z.enum(["album", "artist", "label", "tag", "track"]), shape: z.enum(["grid", "list"]), - filter_field: z.enum(["band_name", "band_url", "item_id", "tag", "why"]).optional(), + filter_field: z.enum(["band_name", "band_url", "item_title", "item_id", "tag", "why"]).optional(), filter: z.string().optional(), filter_fuzzy: z.enum(["true"]).optional() }), diff --git a/routes/load-collection.js b/routes/load-collection.js index 39d1428..2b64777 100644 --- a/routes/load-collection.js +++ b/routes/load-collection.js @@ -61,7 +61,7 @@ async function loadCollection(inputUsername) { const preparedTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration) VALUES (@account, @item_id, @track_id, @title, @artist, @track_number, @duration)") db.transaction(() => { for (const [key, tracklist] of Object.entries(items.tracklists)) { - assert.match(key[0], /[at]/) + if (!key[0].match(/[at]/)) continue for (const track of tracklist) { preparedTrack.run({ account, diff --git a/routes/play.js b/routes/play.js new file mode 100644 index 0000000..e70d6a4 --- /dev/null +++ b/routes/play.js @@ -0,0 +1,19 @@ +// @ts-check + +const {z} = require("zod") +const {sync, router} = require("../passthrough") +const {defineEventHandler} = require("h3") +const {getValidatedRouterParams} = require("h3") + +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") + +const schema = z.object({ + item_type: z.enum(["album", "track"]), + item_id: z.number({coerce: true}) +}) + +router.get("/api/play/:item_type/:item_id", defineEventHandler(async event => { + const locals = await getValidatedRouterParams(event, schema.parse) + return pugSync.render(event, "player.pug", locals) +})) diff --git a/scripts/populate-albums-tracks.js b/scripts/populate-albums-tracks.js index 8fe67e4..edde249 100644 --- a/scripts/populate-albums-tracks.js +++ b/scripts/populate-albums-tracks.js @@ -58,7 +58,7 @@ const har = JSON.parse(fs.readFileSync("scripts/account.har", "utf8")) const preparedTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration, mp3) VALUES (@account, @item_id, @track_id, @title, @artist, @track_number, @duration, @mp3)") db.transaction(() => { for (const [key, tracklist] of Object.entries(items.tracklists)) { - assert.match(key[0], /[at]/) + if (!key[0].match(/[at]/)) continue for (const track of tracklist) { preparedTrack.run({ account, diff --git a/start.js b/start.js index d850987..3091fa6 100644 --- a/start.js +++ b/start.js @@ -36,6 +36,7 @@ pugSync.createRoute(router, "/", "home.pug") sync.require("./routes/app") sync.require("./routes/load-collection") +sync.require("./routes/play") // Files @@ -53,7 +54,9 @@ router.get("/static/htmx.js", defineEventHandler({ handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8") + return Promise.all(["htmx.org/dist/htmx.js"].map(r => + fs.promises.readFile(require.resolve(r), "utf-8") + )).then(files => files.join("\n\n\n")) } })) @@ -66,6 +69,13 @@ router.get("/static/wordcloud.js", defineEventHandler({ } })) +router.get("/static/player-marker.js", defineEventHandler({ + handler: async event => { + defaultContentType(event, "text/javascript") + return fs.promises.readFile("public/player-marker.js") + } +})) + router.get("/favicon.png", defineEventHandler({ handler: async event => { handleCacheHeaders(event, {maxAge: 86400})