This commit is contained in:
2026-08-23 14:13:18 -05:00
parent 789bf91eed
commit 866f243f1b
37 changed files with 1761 additions and 58 deletions
+21
View File
@@ -178,6 +178,21 @@ body {
text-align: right;
}
.vs-song-art-cell {
width: 48px;
padding-left: 0 !important;
padding-right: 0 !important;
}
.vs-song-art {
display: block;
width: 40px;
height: 40px;
border-radius: 0.25rem;
object-fit: cover;
background: var(--bs-secondary-bg);
}
/* --- Player bar --- */
.vs-player {
@@ -352,3 +367,9 @@ body {
display: none;
}
}
/* Inline page transitions keep the persistent player mounted. */
.vs-main[aria-busy="true"] {
opacity: .65;
transition: opacity 120ms ease;
pointer-events: none;
}
+129 -1
View File
@@ -474,7 +474,134 @@
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
form.requestSubmit();
});
}
// --- Inline navigation ---
// Fetch server-rendered pages and replace only the main content area. The
// audio element and player live outside that area, so playback continues
// uninterrupted while the URL, title, and active sidebar item still track
// the current page normally.
function initInlineNavigation() {
var pending = null;
var currentURL = location.href;
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
function isLocalHTTP(url) {
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
}
function shouldHandleLink(link, ev) {
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
var url = new URL(link.href, location.href);
if (!isLocalHTTP(url)) return false;
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
/\/export$/.test(url.pathname)) return false;
// Let the browser handle an in-page fragment without a network request.
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
return true;
}
function setBusy(busy) {
var main = document.querySelector(".vs-main");
if (!main) return;
if (busy) main.setAttribute("aria-busy", "true");
else main.removeAttribute("aria-busy");
}
function applyPage(html, responseURL, push, restoreScroll) {
var parsed = new DOMParser().parseFromString(html, "text/html");
var nextMain = parsed.querySelector(".vs-main");
var oldMain = document.querySelector(".vs-main");
// Login, setup, shared, and error documents are intentionally full-page.
if (!nextMain || !oldMain) return false;
oldMain.replaceWith(nextMain);
document.title = parsed.title || document.title;
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
});
currentURL = responseURL;
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
window.scrollTo(0, top);
var autofocus = nextMain.querySelector("[autofocus]");
if (autofocus) autofocus.focus({ preventScroll: true });
return true;
}
function navigate(url, options) {
options = options || {};
if (pending) pending.abort();
var controller = new AbortController();
pending = controller;
setBusy(true);
var request = {
method: options.method || "GET",
body: options.body || null,
signal: controller.signal,
headers: { "X-VibeSonic-Inline": "1" },
};
return fetch(url, request).then(function (response) {
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
throw new Error("not an inline page");
}
return response.text().then(function (html) {
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
location.assign(response.url);
}
});
}).catch(function (err) {
if (err.name !== "AbortError") {
if ((options.method || "GET") === "GET") location.assign(url);
else location.reload();
}
}).finally(function () {
if (pending === controller) {
pending = null;
setBusy(false);
}
});
}
document.addEventListener("click", function (ev) {
var link = ev.target.closest("a[href]");
if (!shouldHandleLink(link, ev)) return;
ev.preventDefault();
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
navigate(link.href);
});
document.addEventListener("submit", function (ev) {
var form = ev.target;
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
var url = new URL(form.action || location.href, location.href);
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
ev.preventDefault();
var data = new FormData(form);
var method = (form.method || "GET").toUpperCase();
if (method === "GET") {
url.search = new URLSearchParams(data).toString();
navigate(url.href);
} else {
navigate(url.href, { method: method, body: data });
}
});
window.addEventListener("popstate", function (ev) {
navigate(location.href, {
push: false,
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
});
});
}
@@ -483,5 +610,6 @@
var player = initPlayer();
initKeyboardShortcuts(player);
initStarButtons();
initInlineNavigation();
});
})();
+51
View File
@@ -0,0 +1,51 @@
{{define "admin_album_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit album</h2>
<form method="post" action="/admin/albums/{{.Data.ID}}/edit" enctype="multipart/form-data">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Title</label>
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort title</label>
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Year</label>
<input type="number" name="year" class="form-control form-control-sm" value="{{if .Data.Year.Valid}}{{.Data.Year.Int64}}{{end}}">
</div>
<div class="col-6">
<label class="form-label small">Original year</label>
<input type="number" name="original_year" class="form-control form-control-sm" value="{{if .Data.OriginalYear.Valid}}{{.Data.OriginalYear.Int64}}{{end}}">
</div>
</div>
<div class="mb-3">
<label class="form-label small">Record label</label>
<input type="text" name="record_label" class="form-control form-control-sm" value="{{.Data.RecordLabel}}">
</div>
<hr>
<h3 class="h6">Cover art</h3>
{{if .Data.CoverArtID.Valid}}
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:120px;" class="mb-2" alt="">
{{end}}
<div class="mb-2">
<label class="form-label small">Upload a cover</label>
<input type="file" name="cover_file" class="form-control form-control-sm" accept="image/*">
</div>
<div class="mb-3">
<label class="form-label small">...or set a cover URL</label>
<input type="url" name="cover_url" class="form-control form-control-sm" placeholder="https://example.com/cover.jpg">
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/albums/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+50
View File
@@ -0,0 +1,50 @@
{{define "admin_artist_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit artist</h2>
<form method="post" action="/admin/artists/{{.Data.ID}}/edit" enctype="multipart/form-data">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{.Data.Name}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort name</label>
<input type="text" name="sort_name" class="form-control form-control-sm" value="{{.Data.SortName}}">
</div>
<div class="mb-3">
<label class="form-label small">Biography</label>
<textarea name="bio" class="form-control form-control-sm" rows="4">{{.Data.Bio}}</textarea>
</div>
<hr>
<h3 class="h6">Photo</h3>
{{if .Data.CoverArtID.Valid}}
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:100px;height:100px;border-radius:50%;object-fit:cover;" class="mb-2" alt="">
{{end}}
{{if .Data.PhotoManual}}
<p class="small text-body-secondary">This photo was set manually and will not be replaced by auto-fetch.</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="remove_photo" value="1" id="remove_photo">
<label class="form-check-label" for="remove_photo">Remove custom photo (revert to auto-fetch)</label>
</div>
{{else}}
<p class="small text-body-secondary">No custom photo set{{if not .Data.MusicBrainzID}} (this artist has no MusicBrainz ID, so auto-fetch cannot run){{end}}.</p>
{{end}}
<div class="mb-2">
<label class="form-label small">Upload a photo</label>
<input type="file" name="photo_file" class="form-control form-control-sm" accept="image/*">
</div>
<div class="mb-3">
<label class="form-label small">...or set a photo URL</label>
<input type="url" name="photo_url" class="form-control form-control-sm" placeholder="https://example.com/photo.jpg">
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/artists/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+7
View File
@@ -22,6 +22,13 @@
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
</div>
<div>
{{if .Episodes}}
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<input type="hidden" name="next" value="/admin/podcasts">
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all</button>
</form>
{{end}}
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
+37
View File
@@ -21,6 +21,43 @@
</div>
</div>
<div class="card mt-3" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Artist photos</h2>
{{if .Data.ArtistPhotosEnabled}}
<p class="text-secondary small mb-2">
Fetch a photo (fanart.tv when an artist has a tagged MusicBrainz ID and a key is configured, otherwise a direct name search against Wikidata/Wikimedia Commons) for every artist with no manually-set photo. This never calls MusicBrainz itself, so it isn't affected by its rate limits — matching by name alone is less precise, though. This bypasses any previously-cached "not found" result, so it's a good way to retry after fixing a config or network issue.
</p>
{{else}}
<p class="text-secondary small mb-2">
Automatic background fetching is disabled (<code>VIBESONIC_ENABLE_ARTIST_PHOTOS=false</code>), but this button still runs a one-off fetch — useful for checking why photos aren't showing up.
</p>
{{end}}
<form method="post" action="/admin/settings/artist-photos/refresh">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Fetch all artist photos now</button>
</form>
{{if .Data.ArtistPhotoResults}}
<table class="table table-sm vs-table mt-3 mb-0">
<thead><tr><th>Artist</th><th>Status</th><th>Error</th></tr></thead>
<tbody>
{{range .Data.ArtistPhotoResults}}
<tr>
<td>{{.ArtistName}}</td>
<td>
{{if .Error}}<span class="text-danger">{{.Status}}</span>
{{else if eq .Status "fetched"}}<span class="text-success">{{.Status}}</span>
{{else}}<span class="text-secondary">{{.Status}}</span>{{end}}
</td>
<td class="text-danger small">{{.Error}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
<div class="card mt-3" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">External authentication</h2>
+46
View File
@@ -0,0 +1,46 @@
{{define "admin_song_edit"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Edit song</h2>
<form method="post" action="/admin/songs/{{.Data.ID}}/edit">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<label class="form-label small">Title</label>
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
</div>
<div class="mb-2">
<label class="form-label small">Sort title</label>
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Track #</label>
<input type="number" name="track_number" class="form-control form-control-sm" value="{{if .Data.TrackNumber.Valid}}{{.Data.TrackNumber.Int64}}{{end}}">
</div>
<div class="col-6">
<label class="form-label small">Disc #</label>
<input type="number" name="disc_number" class="form-control form-control-sm" value="{{if .Data.DiscNumber.Valid}}{{.Data.DiscNumber.Int64}}{{end}}">
</div>
</div>
<div class="mb-2">
<label class="form-label small">Composer</label>
<input type="text" name="composer" class="form-control form-control-sm" value="{{.Data.Composer}}">
</div>
<div class="mb-3">
<label class="form-label small">Conductor</label>
<input type="text" name="conductor" class="form-control form-control-sm" value="{{.Data.Conductor}}">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="metadata_locked" value="1" id="metadata_locked" {{if .Data.MetadataLocked}}checked{{end}}>
<label class="form-check-label" for="metadata_locked">Lock metadata (prevent library rescans from overwriting these fields)</label>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/songs" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+3
View File
@@ -11,6 +11,9 @@
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
</form>
{{if and .User .User.IsAdmin}}
<a class="btn btn-sm btn-outline-secondary" href="/admin/albums/{{.Data.Album.ID}}/edit"><i class="bi bi-pencil"></i> Edit album</a>
{{end}}
</div>
</div>
{{template "song_rows" .Data.Songs}}
+10
View File
@@ -1,4 +1,14 @@
{{define "artist"}}{{template "header" .}}
<div class="d-flex gap-3 mb-4">
<img class="vs-card-art" style="width:180px;border-radius:50%;" src="{{if .Data.Artist.CoverArtID}}/cover-art/{{.Data.Artist.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
<div>
<h2 class="h4 mb-1">{{.Data.Artist.Name}}</h2>
{{if .Data.Artist.Bio}}<p class="text-secondary mb-2">{{.Data.Artist.Bio}}</p>{{end}}
{{if and .User .User.IsAdmin}}
<a class="btn btn-sm btn-outline-secondary" href="/admin/artists/{{.Data.Artist.ID}}/edit"><i class="bi bi-pencil"></i> Edit artist</a>
{{end}}
</div>
</div>
<div class="vs-grid mb-3">
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
</div>
+8 -2
View File
@@ -8,7 +8,7 @@
{{define "artist_card"}}
<a href="/artists/{{.ID}}" class="vs-card">
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
<img class="vs-card-art" style="border-radius:50%;" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
<div class="vs-card-title">{{.Name}}</div>
</a>
{{end}}
@@ -26,11 +26,17 @@
data-cover="{{$s.CoverArtID}}"
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
<td class="vs-song-art-cell">
<img class="vs-song-art" src="{{if $s.CoverArtID}}/cover-art/{{$s.CoverArtID}}?size=80{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
</td>
<td>
<div class="fw-semibold">{{$s.Title}}</div>
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
</td>
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
<td class="text-end text-secondary small">
{{fmtDuration $s.DurationSeconds}}
{{if $s.IsAdmin}}<a href="/admin/songs/{{$s.ID}}/edit" title="Edit song" class="text-secondary ms-2"><i class="bi bi-pencil"></i></a>{{end}}
</td>
</tr>
{{end}}
</tbody>
+1 -1
View File
@@ -19,7 +19,7 @@
</script>
</head>
<body>
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
<script data-vs-csrf="{{.CSRFToken}}">window.vsCSRFToken = {{.CSRFToken}};</script>
<header class="vs-topbar d-flex align-items-center justify-content-between">
<a href="/" class="vs-brand d-flex align-items-center gap-2">
<i class="bi bi-vinyl-fill"></i>
+9 -1
View File
@@ -3,7 +3,15 @@
{{range .Data}}
<div class="card mb-3">
<div class="card-body">
<h2 class="h6">{{.Channel.Title}}</h2>
<div class="d-flex align-items-center justify-content-between gap-2">
<h2 class="h6 mb-0">{{.Channel.Title}}</h2>
{{if .Episodes}}
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all episodes</button>
</form>
{{end}}
</div>
<p class="text-secondary small">{{.Channel.Description}}</p>
<table class="table table-sm vs-table mb-0">
<tbody>