[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

docs: Add preview UI callout to landing

FoxxMD (Jul 17, 2026, 2:45 PM UTC) 12f284db fab2e493

+447
+14
docsite/docs/index.mdx
··· 3 3 title: Overview 4 4 sidebar_position: 1 5 5 --- 6 + import Carousel from '@site/src/components/Carousel/NextCarousel'; 7 + import BrowserOnly from '@docusaurus/BrowserOnly'; 6 8 7 9 ![Latest Release](https://img.shields.io/github/v/release/foxxmd/multi-scrobbler) 8 10 ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) ··· 63 65 * Install using [Docker images for x86/ARM](/installation#docker) or [locally with NodeJS](/installation#nodejs) 64 66 65 67 [**Quick Start Guide**](/quickstart) 68 + 69 + <DetailsAdmo type="tip" summary="New UI Preview"> 70 + 71 + A **full rewrite** of the UI is now available for preview. The new UI modernizes Multi-Scrobbler and gives you in depth insight into how your scrobbles are handled end-to-end. 72 + 73 + <Carousel/> 74 + 75 + To try out the stable-release preview navigate to `http://yourMSDomain:9078/next` 76 + 77 + Or to try out the **latest preview snapshot** see the docker image pinned [in this comment.](https://github.com/FoxxMD/multi-scrobbler/issues/500#issuecomment-4917525794) 78 + 79 + </DetailsAdmo> 66 80 67 81 <img src={require('/img/status-ui.png').default} width="800"/> 68 82
+30
docsite/package-lock.json
··· 22 22 "clsx": "^2.0.0", 23 23 "docusaurus-plugin-sass": "^0.2.6", 24 24 "docusaurus-theme-github-codeblock": "^2.0.2", 25 + "embla-carousel": "^9.0.0-rc02", 26 + "embla-carousel-react": "^9.0.0-rc02", 25 27 "json5": "^2.2.3", 26 28 "micromark-extension-directive": "^3.0.1", 27 29 "modern-react-json-editor": "^1.0.2", ··· 10862 10864 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", 10863 10865 "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", 10864 10866 "license": "ISC" 10867 + }, 10868 + "node_modules/embla-carousel": { 10869 + "version": "9.0.0-rc02", 10870 + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-9.0.0-rc02.tgz", 10871 + "integrity": "sha512-GOQBX6v5cNk/ptfuHUoHhW9rvl5KeSGsBoppaUqtihu2kDmQAyzvnH/95r3TaSScFSJTj68V9AM6E5nq3jUVcQ==", 10872 + "license": "MIT" 10873 + }, 10874 + "node_modules/embla-carousel-react": { 10875 + "version": "9.0.0-rc02", 10876 + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-9.0.0-rc02.tgz", 10877 + "integrity": "sha512-DpcD0kTv6L31KvBcrx5d57E6T2rP/YceTGAdq7llkpKFdfM1of8YotHVEQTNIU3I9o5mjE/QxLi1ndJmm1DikA==", 10878 + "license": "MIT", 10879 + "dependencies": { 10880 + "embla-carousel": "9.0.0-rc02", 10881 + "embla-carousel-reactive-utils": "9.0.0-rc02" 10882 + }, 10883 + "peerDependencies": { 10884 + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 10885 + } 10886 + }, 10887 + "node_modules/embla-carousel-reactive-utils": { 10888 + "version": "9.0.0-rc02", 10889 + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-9.0.0-rc02.tgz", 10890 + "integrity": "sha512-H1c+yUbcVST9eJoGU+05L1vSDyBs0hf/Gf1OadHYIJ3asBfyvn6oaGCs2wXq1D8/f09y0wwCxeRxT9UZU/TxJA==", 10891 + "license": "MIT", 10892 + "peerDependencies": { 10893 + "embla-carousel": "9.0.0-rc02" 10894 + } 10865 10895 }, 10866 10896 "node_modules/emoji-regex": { 10867 10897 "version": "9.2.2",
+2
docsite/package.json
··· 30 30 "clsx": "^2.0.0", 31 31 "docusaurus-plugin-sass": "^0.2.6", 32 32 "docusaurus-theme-github-codeblock": "^2.0.2", 33 + "embla-carousel": "^9.0.0-rc02", 34 + "embla-carousel-react": "^9.0.0-rc02", 33 35 "json5": "^2.2.3", 34 36 "micromark-extension-directive": "^3.0.1", 35 37 "modern-react-json-editor": "^1.0.2",
+96
docsite/src/components/Carousel/Arrows.tsx
··· 1 + import React, { 2 + ComponentPropsWithRef, 3 + useCallback, 4 + useEffect, 5 + useState 6 + } from 'react' 7 + import { EmblaCarouselType } from 'embla-carousel' 8 + 9 + type UsePrevNextButtonsType = { 10 + prevBtnDisabled: boolean 11 + nextBtnDisabled: boolean 12 + onPrevButtonClick: () => void 13 + onNextButtonClick: () => void 14 + } 15 + 16 + export const usePrevNextButtons = ( 17 + emblaApi: EmblaCarouselType | undefined 18 + ): UsePrevNextButtonsType => { 19 + const [prevBtnDisabled, setPrevBtnDisabled] = useState(true) 20 + const [nextBtnDisabled, setNextBtnDisabled] = useState(true) 21 + 22 + const onPrevButtonClick = useCallback(() => { 23 + if (!emblaApi) return 24 + emblaApi.goToPrev() 25 + }, [emblaApi]) 26 + 27 + const onNextButtonClick = useCallback(() => { 28 + if (!emblaApi) return 29 + emblaApi.goToNext() 30 + }, [emblaApi]) 31 + 32 + const onSelect = useCallback((emblaApi: EmblaCarouselType) => { 33 + setPrevBtnDisabled(!emblaApi.canGoToPrev()) 34 + setNextBtnDisabled(!emblaApi.canGoToNext()) 35 + }, []) 36 + 37 + useEffect(() => { 38 + if (!emblaApi) return 39 + 40 + onSelect(emblaApi) 41 + emblaApi.on('reinit', onSelect).on('select', onSelect) 42 + }, [emblaApi, onSelect]) 43 + 44 + return { 45 + prevBtnDisabled, 46 + nextBtnDisabled, 47 + onPrevButtonClick, 48 + onNextButtonClick 49 + } 50 + } 51 + 52 + type PropType = ComponentPropsWithRef<'button'> 53 + 54 + export const PrevButton = (props: PropType) => { 55 + const { children, disabled, ...restProps } = props 56 + 57 + return ( 58 + <button 59 + className={'embla__button embla__button--prev'.concat( 60 + disabled ? ' embla__button--disabled' : '' 61 + )} 62 + type="button" 63 + {...restProps} 64 + > 65 + <svg className="embla__button__svg" viewBox="0 0 532 532"> 66 + <path 67 + fill="currentColor" 68 + d="M355.66 11.354c13.793-13.805 36.208-13.805 50.001 0 13.785 13.804 13.785 36.238 0 50.034L201.22 266l204.442 204.61c13.785 13.805 13.785 36.239 0 50.044-13.793 13.796-36.208 13.796-50.002 0a5994246.277 5994246.277 0 0 0-229.332-229.454 35.065 35.065 0 0 1-10.326-25.126c0-9.2 3.393-18.26 10.326-25.2C172.192 194.973 332.731 34.31 355.66 11.354Z" 69 + /> 70 + </svg> 71 + {children} 72 + </button> 73 + ) 74 + } 75 + 76 + export const NextButton = (props: PropType) => { 77 + const { children, disabled, ...restProps } = props 78 + 79 + return ( 80 + <button 81 + className={'embla__button embla__button--next'.concat( 82 + disabled ? ' embla__button--disabled' : '' 83 + )} 84 + type="button" 85 + {...restProps} 86 + > 87 + <svg className="embla__button__svg" viewBox="0 0 532 532"> 88 + <path 89 + fill="currentColor" 90 + d="M176.34 520.646c-13.793 13.805-36.208 13.805-50.001 0-13.785-13.804-13.785-36.238 0-50.034L330.78 266 126.34 61.391c-13.785-13.805-13.785-36.239 0-50.044 13.793-13.796 36.208-13.796 50.002 0 22.928 22.947 206.395 206.507 229.332 229.454a35.065 35.065 0 0 1 10.326 25.126c0 9.2-3.393 18.26-10.326 25.2-45.865 45.901-206.404 206.564-229.332 229.52Z" 91 + /> 92 + </svg> 93 + {children} 94 + </button> 95 + ) 96 + }
+158
docsite/src/components/Carousel/NextCarousel.scss
··· 1 + :root[data-theme="light"] { 2 + --text-body: rgb(54, 49, 61); 3 + --text-comment: rgb(99, 94, 105); 4 + --text-high-contrast: rgb(49, 49, 49); 5 + --text-medium-contrast: rgb(99, 94, 105); 6 + --text-low-contrast: rgb(116, 109, 118); 7 + --detail-high-contrast: rgb(192, 192, 192); 8 + --detail-medium-contrast: rgb(234, 234, 234); 9 + --detail-low-contrast: rgb(240, 240, 242); 10 + --text-body-rgb-value: 54, 49, 61; 11 + --text-comment-rgb-value: 99, 94, 105; 12 + --text-high-contrast-rgb-value: 49, 49, 49; 13 + --text-medium-contrast-rgb-value: 99, 94, 105; 14 + --text-low-contrast-rgb-value: 116, 109, 118; 15 + --detail-high-contrast-rgb-value: 192, 192, 192; 16 + --detail-medium-contrast-rgb-value: 234, 234, 234; 17 + --detail-low-contrast-rgb-value: 240, 240, 242; 18 + } 19 + :root[data-theme="dark"] { 20 + --text-body: rgb(222, 222, 222); 21 + --text-comment: rgb(170, 170, 170); 22 + --text-high-contrast: rgb(230, 230, 230); 23 + --text-medium-contrast: rgb(202, 202, 202); 24 + --text-low-contrast: rgb(170, 170, 170); 25 + --detail-high-contrast: rgb(101, 101, 101); 26 + --detail-medium-contrast: rgb(25, 25, 25); 27 + --detail-low-contrast: rgb(21, 21, 21); 28 + --text-body-rgb-value: 222, 222, 222; 29 + --text-comment-rgb-value: 170, 170, 170; 30 + --text-high-contrast-rgb-value: 230, 230, 230; 31 + --text-medium-contrast-rgb-value: 202, 202, 202; 32 + --text-low-contrast-rgb-value: 170, 170, 170; 33 + --detail-high-contrast-rgb-value: 101, 101, 101; 34 + --detail-medium-contrast-rgb-value: 25, 25, 25; 35 + --detail-low-contrast-rgb-value: 21, 21, 21; 36 + } 37 + 38 + .embla { 39 + max-width: 48rem; 40 + margin: auto; 41 + 42 + --slide-height: 33rem; 43 + --slide-spacing: 1rem; 44 + --slide-size: 100%; 45 + margin-bottom: 1rem; 46 + } 47 + 48 + .embla__viewport { 49 + overflow: hidden; 50 + } 51 + 52 + .embla__container { 53 + display: flex; 54 + touch-action: pan-y pinch-zoom; 55 + margin-left: calc(var(--slide-spacing) * -1); 56 + } 57 + 58 + .embla__slide { 59 + transform: translate3d(0, 0, 0); 60 + flex: 0 0 var(--slide-size); 61 + min-width: 0; 62 + padding-left: var(--slide-spacing); 63 + 64 + } 65 + 66 + .embla__slide__container { 67 + border: 0.15rem solid transparent; 68 + border-radius: 1.2rem; 69 + display: flex; 70 + align-items: normal; 71 + justify-content: center; 72 + height: var(--slide-height); 73 + user-select: none; 74 + overflow:hidden; 75 + flex-wrap: wrap; 76 + } 77 + 78 + .imageCaption { 79 + font-style: italic; 80 + font-size: 0.8em; 81 + text-wrap-style: balance; 82 + text-align: center; 83 + } 84 + 85 + .embla__controls { 86 + display: grid; 87 + grid-template-columns: auto 1fr; 88 + justify-content: space-between; 89 + gap: 1.2rem; 90 + // margin-top: 1.8rem; 91 + } 92 + 93 + .embla__buttons { 94 + display: grid; 95 + grid-template-columns: repeat(2, 1fr); 96 + gap: 0.6rem; 97 + align-items: center; 98 + } 99 + 100 + .embla__button { 101 + -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5); 102 + -webkit-appearance: none; 103 + appearance: none; 104 + background-color: transparent; 105 + touch-action: manipulation; 106 + display: inline-flex; 107 + text-decoration: none; 108 + cursor: pointer; 109 + border: 0; 110 + padding: 0; 111 + margin: 0; 112 + border: 0.2rem solid var(--detail-medium-contrast); 113 + width: 2.5rem; 114 + height: 2.5rem; 115 + 116 + z-index: 1; 117 + border-radius: 50%; 118 + color: var(--text-body); 119 + display: flex; 120 + align-items: center; 121 + justify-content: center; 122 + transform: rotate(0deg); 123 + } 124 + 125 + .embla__button--disabled { 126 + color: var(--detail-high-contrast); 127 + } 128 + 129 + .embla__button__svg { 130 + width: 35%; 131 + height: 35%; 132 + } 133 + 134 + .embla__selected-snap-display { 135 + justify-self: flex-end; 136 + align-self: center; 137 + color: var(--text-low-contrast); 138 + font-weight: 600; 139 + } 140 + 141 + .embla__slide:nth-child(1) { 142 + flex: 0 0 90%; 143 + } 144 + .embla__slide:nth-child(2) { 145 + flex: 0 0 60%; 146 + } 147 + .embla__slide:nth-child(3) { 148 + flex: 0 0 75%; 149 + } 150 + .embla__slide:nth-child(4) { 151 + flex: 0 0 80%; 152 + } 153 + .embla__slide:nth-child(5) { 154 + flex: 0 0 100%; 155 + } 156 + .embla__slide:nth-child(6) { 157 + flex: 0 0 100%; 158 + }
+100
docsite/src/components/Carousel/NextCarousel.tsx
··· 1 + import React, {useEffect} from 'react' 2 + import BrowserOnly from "@docusaurus/BrowserOnly" 3 + import './NextCarousel.scss'; 4 + import { EmblaOptionsType } from 'embla-carousel' 5 + import useEmblaCarousel from 'embla-carousel-react' 6 + import { 7 + NextButton, 8 + PrevButton, 9 + usePrevNextButtons 10 + } from './Arrows' 11 + import { 12 + SelectedSnapDisplay, 13 + useSelectedSnapDisplay 14 + } from './SnapDisplay' 15 + import ComponentList from '@site/static/img/next/ms-componentlist.png'; 16 + import ComponentListDesktop from '@site/static/img/next/ms-componentlistdesktop.png'; 17 + import ComponentDetailed from '@site/static/img/next/ms-componentdetailed.png'; 18 + import PlaysList from '@site/static/img/next/ms-playlist.jpg'; 19 + import Timeline from '@site/static/img/next/ms-mbemptyquery.png'; 20 + import Logs from '@site/static/img/next/ms-logs.jpg'; 21 + 22 + const EmblaCarousel = (props: {options?: EmblaOptionsType}) => { 23 + const { options } = props 24 + const [emblaRef, emblaApi] = useEmblaCarousel(options) 25 + 26 + const { 27 + prevBtnDisabled, 28 + nextBtnDisabled, 29 + onPrevButtonClick, 30 + onNextButtonClick 31 + } = usePrevNextButtons(emblaApi) 32 + 33 + const { selectedSnap, snapCount } = useSelectedSnapDisplay(emblaApi) 34 + 35 + return ( 36 + <div className="embla"> 37 + <div className="embla__viewport" ref={emblaRef}> 38 + <div className="embla__container"> 39 + <div className="embla__slide" key="1"> 40 + <div className="embla__slide__container"> 41 + <img src={ComponentListDesktop} /> 42 + <div className="imageCaption">Source/Client List on desktop</div> 43 + </div> 44 + </div> 45 + <div className="embla__slide" key="2"> 46 + <div className="embla__slide__container"> 47 + <img src={ComponentList} /> 48 + <div className="imageCaption">Source/Client List on mobile</div> 49 + </div> 50 + </div> 51 + <div className="embla__slide" key="3"> 52 + <div className="embla__slide__container"> 53 + <img src={ComponentDetailed} /> 54 + <div className="imageCaption">Source/Client Details</div> 55 + </div> 56 + </div> 57 + <div className="embla__slide" key="4"> 58 + <div className="embla__slide__container"> 59 + <img src={PlaysList} /> 60 + <div className="imageCaption">Search for Plays/Scrobbles with live updates</div> 61 + </div> 62 + </div> 63 + <div className="embla__slide" key="5"> 64 + <div className="embla__slide__container"> 65 + <img src={Timeline} /> 66 + <div className="imageCaption">Scrobble timeline audit trail with detailed errors</div> 67 + </div> 68 + </div> 69 + <div className="embla__slide" key="6"> 70 + <div className="embla__slide__container"> 71 + <img src={Logs} /> 72 + <div className="imageCaption">Floating logs for dev-level troubleshooting</div> 73 + </div> 74 + </div> 75 + </div> 76 + </div> 77 + 78 + <div className="embla__controls"> 79 + <div className="embla__buttons"> 80 + <PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} /> 81 + <NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} /> 82 + </div> 83 + <SelectedSnapDisplay 84 + selectedSnap={selectedSnap} 85 + snapCount={snapCount} 86 + /> 87 + </div> 88 + </div> 89 + ) 90 + } 91 + 92 + const ClientCarousel = () => { 93 + return ( 94 + <BrowserOnly>{() => { 95 + return <EmblaCarousel/> 96 + }}</BrowserOnly> 97 + ) 98 + } 99 + 100 + export default ClientCarousel;
+47
docsite/src/components/Carousel/SnapDisplay.tsx
··· 1 + import React, { useCallback, useEffect, useState } from 'react' 2 + import { EmblaCarouselType } from 'embla-carousel' 3 + 4 + type UseSelectedSnapDisplayType = { 5 + selectedSnap: number 6 + snapCount: number 7 + } 8 + 9 + export const useSelectedSnapDisplay = ( 10 + emblaApi: EmblaCarouselType | undefined 11 + ): UseSelectedSnapDisplayType => { 12 + const [selectedSnap, setSelectedSnap] = useState(0) 13 + const [snapCount, setSnapCount] = useState(0) 14 + 15 + const updateScrollSnapState = useCallback((emblaApi: EmblaCarouselType) => { 16 + setSnapCount(emblaApi.snapList().length) 17 + setSelectedSnap(emblaApi.selectedSnap()) 18 + }, []) 19 + 20 + useEffect(() => { 21 + if (!emblaApi) return 22 + 23 + updateScrollSnapState(emblaApi) 24 + emblaApi.on('select', updateScrollSnapState) 25 + emblaApi.on('reinit', updateScrollSnapState) 26 + }, [emblaApi, updateScrollSnapState]) 27 + 28 + return { 29 + selectedSnap, 30 + snapCount 31 + } 32 + } 33 + 34 + type PropType = { 35 + selectedSnap: number 36 + snapCount: number 37 + } 38 + 39 + export const SelectedSnapDisplay = (props: PropType) => { 40 + const { selectedSnap, snapCount } = props 41 + 42 + return ( 43 + <div className="embla__selected-snap-display"> 44 + {selectedSnap + 1} / {snapCount} 45 + </div> 46 + ) 47 + }
docsite/static/img/next/ms-componentdetailed.png

This is a binary file and will not be displayed.

docsite/static/img/next/ms-componentlist.png

This is a binary file and will not be displayed.

docsite/static/img/next/ms-componentlistdesktop.png

This is a binary file and will not be displayed.

docsite/static/img/next/ms-logs.jpg

This is a binary file and will not be displayed.

docsite/static/img/next/ms-mbemptyquery.png

This is a binary file and will not be displayed.

docsite/static/img/next/ms-playlist.jpg

This is a binary file and will not be displayed.