diff --git a/src/css/components.css b/src/css/components.css
index 5bb8af5..64a1ad6 100644
--- a/src/css/components.css
+++ b/src/css/components.css
@@ -230,7 +230,7 @@ button {
/* queue - cover art column */
#queue #queue-table th:nth-child(1),
#queue #queue-table td:nth-child(1) {
- width: calc(var(--art-song) * 2);
+ width: calc(var(--art-song) * 1.5);
}
/* queue - duration column */
@@ -248,11 +248,7 @@ button {
}
#queue #queue-table td:nth-child(6) button {
- margin-inline-end: 0.5rem;
-}
-
-#queue #queue-table td:nth-child(6) button:last-child {
- margin-inline-end: 0;
+ margin-inline-start: 0.5rem;
}
/* queue - rows */
@@ -307,6 +303,18 @@ button {
opacity: 1;
}
+#queue #queue-table .queue-rating-star {
+ opacity: 0.25;
+}
+
+#queue #queue-table .queue-rating-star:hover {
+ opacity: 0.5;
+}
+
+#queue #queue-table .queue-rating-star.rated {
+ opacity: 1;
+}
+
#queue #queue-table .queue-play,
#queue #queue-table .queue-play-next,
#queue #queue-table .queue-move-up,
@@ -501,4 +509,10 @@ img[src*="famfamfam-silk"] {
#queue #queue-table .queue-move-down {
display: inline-block;
}
+
+ /* queue - hide artist column */
+ #queue-table th:nth-child(3),
+ #queue-table td:nth-child(3) {
+ display: none;
+ }
}
diff --git a/src/index.html b/src/index.html
index 03101ec..a1a7496 100644
--- a/src/index.html
+++ b/src/index.html
@@ -92,13 +92,25 @@
+
+
+
+
+
+
diff --git a/src/js/api.js b/src/js/api.js
index f6274bb..30424ba 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -158,6 +158,18 @@ class SubsonicAPI {
return this._validateAndRequest(id, "unstar.view", {}, "Song ID");
}
+ setRating(id, rating) {
+ if (rating < 0 || rating > 5 || !Number.isInteger(rating)) {
+ throw new Error("Rating must be 0-5");
+ }
+ return this._validateAndRequest(
+ id,
+ "setRating.view",
+ { rating },
+ "Song ID",
+ );
+ }
+
getLyricsBySongId(id) {
return this._validateAndRequest(
id,
diff --git a/src/js/auth.js b/src/js/auth.js
index cc915e1..fcb47db 100644
--- a/src/js/auth.js
+++ b/src/js/auth.js
@@ -6,6 +6,7 @@ async function initializeApp() {
await loadQueue().catch(() => {});
updateQueueDisplay();
await loadLibrary();
+ loadRatings();
await loadPlaylists();
await loadFavorites();
// restore song display on startup
diff --git a/src/js/constants.js b/src/js/constants.js
index 563c1ab..d43c7a1 100644
--- a/src/js/constants.js
+++ b/src/js/constants.js
@@ -21,6 +21,8 @@ const CLASSES = {
QUEUE_MOVE_UP: "queue-move-up",
QUEUE_PLAY_NEXT: "queue-play-next",
QUEUE_PLAY: "queue-play",
+ QUEUE_RATING_STAR: "queue-rating-star",
+ RATED: "rated",
SELECTED: "selected",
STRIPE: "stripe",
TREE_ITEM: "tree-item",
diff --git a/src/js/contextmenu.js b/src/js/contextmenu.js
index 3eb32fe..8d780e9 100644
--- a/src/js/contextmenu.js
+++ b/src/js/contextmenu.js
@@ -201,6 +201,14 @@ function getSortMenuItems() {
sortQueue("favorited", true);
updateQueue();
},
+ [STRINGS.CONTEXT_SORT_RATING_LOW_HIGH]: () => {
+ sortQueue("rating", true);
+ updateQueue();
+ },
+ [STRINGS.CONTEXT_SORT_RATING_HIGH_LOW]: () => {
+ sortQueue("rating", false);
+ updateQueue();
+ },
};
}
diff --git a/src/js/library.js b/src/js/library.js
index b3875bb..0631cd8 100644
--- a/src/js/library.js
+++ b/src/js/library.js
@@ -184,6 +184,26 @@ async function loadFavorites() {
updateQueueDisplay();
}
+function loadRatings() {
+ state.ratings.clear();
+ const recordIfRated = (item) => {
+ if (item.userRating && item.userRating > 0) {
+ state.ratings.set(item.id, item.userRating);
+ }
+ };
+ const walk = (items) => {
+ if (!Array.isArray(items)) return;
+ items.forEach((item) => {
+ recordIfRated(item);
+ if (item.artist) walk(item.artist);
+ if (item.album) walk(item.album);
+ if (item.song) walk(item.song);
+ });
+ };
+ walk(state.library);
+ state.queue.forEach(recordIfRated);
+}
+
function clearNestedList(parentLi, className) {
const existingUl = parentLi.querySelector(`ul.${className}`);
if (existingUl) existingUl.remove();
diff --git a/src/js/player.js b/src/js/player.js
index 9313489..1048e72 100644
--- a/src/js/player.js
+++ b/src/js/player.js
@@ -109,6 +109,18 @@ const setFavoriteSong = async (song, force) => {
}
};
+// set song rating (1-5)
+const setSongRating = async (song, rating) => {
+ if (!song || !state.api || !state.settings.enableRatings) return;
+ if (rating < 0 || rating > 5) return;
+ if (rating === 0) {
+ state.ratings.delete(song.id);
+ } else {
+ await state.api.setRating(song.id, rating);
+ state.ratings.set(song.id, rating);
+ }
+};
+
// highlight currently playing song in queue
function highlightCurrentTrack() {
clearRowClasses(ui.queueList, ["currently-playing"]);
diff --git a/src/js/queue.js b/src/js/queue.js
index 8aae24e..b9a76cc 100644
--- a/src/js/queue.js
+++ b/src/js/queue.js
@@ -98,6 +98,7 @@ function sortQueue(field, ascending) {
itemsToSort.sort((a, b) => {
const getValue = (song) => {
if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0;
+ if (field === "rating") return state.ratings.get(song.id) || 0;
if (field === "duration") return song.duration || 0;
return song[field] || "";
};
@@ -221,6 +222,11 @@ const SONG_EXTRACTORS = {
// add songs to queue
async function addToQueue(fetcher, songExtractor, insertNext = false) {
const songs = await songExtractor(await fetcher());
+ songs.forEach((song) => {
+ if (song.userRating && song.userRating > 0) {
+ state.ratings.set(song.id, song.userRating);
+ }
+ });
if (insertNext) {
const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
state.queue.splice(insertPos, 0, ...songs);
@@ -455,10 +461,39 @@ function createQueueRow(song, idx) {
durationCell.textContent = formatDuration(song.duration);
cells.appendChild(durationCell);
- // action buttons cell
+ // action buttons cell (includes ratings if enabled)
const actionsCell = document.createElement("td");
const isFavorited = state.favorites.has(song.id);
+
+ if (state.settings.enableRatings) {
+ const currentRating = state.ratings.get(song.id) || 0;
+ for (let i = 1; i <= 5; i++) {
+ const star = createIconButton(
+ CLASSES.QUEUE_RATING_STAR,
+ `Rate ${i}`,
+ ICONS.STAR,
+ );
+ star.dataset.rating = i;
+ if (i <= currentRating) {
+ star.classList.add(CLASSES.RATED);
+ }
+ star.addEventListener("click", async (e) => {
+ e.stopPropagation();
+ const newRating = i === currentRating ? 0 : i;
+ await setSongRating(song, newRating);
+ updateQueueDisplay();
+ });
+ actionsCell.appendChild(star);
+ }
+ }
+
ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => {
+ if (
+ className === CLASSES.QUEUE_FAVORITE &&
+ !state.settings.enableFavorites
+ ) {
+ return;
+ }
const btn = createIconButton(className, label, icon);
if (className === CLASSES.QUEUE_FAVORITE && isFavorited) {
btn.classList.add(CLASSES.FAVORITED);
diff --git a/src/js/settings.js b/src/js/settings.js
index f5e6d38..0c71e51 100644
--- a/src/js/settings.js
+++ b/src/js/settings.js
@@ -29,6 +29,8 @@ function setupSettings() {
const dynamicFaviconToggle = document.getElementById(
"dynamic-favicon-toggle",
);
+ const favoritesToggle = document.getElementById("favorites-toggle");
+ const ratingsToggle = document.getElementById("ratings-toggle");
// load and apply settings from localStorage
const saved = localStorage.getItem("tinysub_settings");
@@ -42,6 +44,8 @@ function setupSettings() {
scrobbleToggle.checked = state.settings.scrobbling;
dynamicFaviconToggle.checked = state.settings.dynamicFavicon;
+ favoritesToggle.checked = state.settings.enableFavorites;
+ ratingsToggle.checked = state.settings.enableRatings;
// initialize sliders and setup listeners
Object.entries(ART_SETTINGS).forEach(([name, key]) => {
@@ -95,6 +99,18 @@ function setupSettings() {
}
});
+ ratingsToggle.addEventListener("change", () => {
+ state.settings.enableRatings = ratingsToggle.checked;
+ localStorage.setItem("tinysub_settings", JSON.stringify(state.settings));
+ updateQueueDisplay();
+ });
+
+ favoritesToggle.addEventListener("change", () => {
+ state.settings.enableFavorites = favoritesToggle.checked;
+ localStorage.setItem("tinysub_settings", JSON.stringify(state.settings));
+ updateQueueDisplay();
+ });
+
// logout button
const logoutBtn = document.getElementById("logout-settings-btn");
if (logoutBtn) {
diff --git a/src/js/state.js b/src/js/state.js
index 741d44f..8cbd124 100644
--- a/src/js/state.js
+++ b/src/js/state.js
@@ -6,6 +6,7 @@ const state = {
queue: [],
queueIndex: -1,
favorites: new Set(),
+ ratings: new Map(),
loop: false,
expanded: {
artists: false,
@@ -18,6 +19,8 @@ const state = {
settings: {
scrobbling: true,
dynamicFavicon: true,
+ enableFavorites: true,
+ enableRatings: false,
artArtist: 24,
artAlbum: 32,
artSong: 16,
diff --git a/src/js/strings/en.js b/src/js/strings/en.js
index 6f8cf4b..c9df18d 100644
--- a/src/js/strings/en.js
+++ b/src/js/strings/en.js
@@ -26,6 +26,8 @@ const STRINGS = {
CONTEXT_SORT_DURATION_SHORT_LONG: "duration (short to long)",
CONTEXT_SORT_FAVORITED_FIRST: "favorited first",
CONTEXT_SORT_FAVORITED_LAST: "favorited last",
+ CONTEXT_SORT_RATING_LOW_HIGH: "rating (low to high)",
+ CONTEXT_SORT_RATING_HIGH_LOW: "rating (high to low)",
CONTEXT_SORT_SHUFFLE: "shuffle",
CONTEXT_SORT_SONG_AZ: "song (a-z)",
CONTEXT_SORT_SONG_ZA: "song (z-a)",