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)",