feat(share): add a download dialog to share page + ux fixes

Adds a dialog that allows a choice to download either the current track,
or all tracks in the list. If there is only one track, it will simply
download the track itself rather than a zip with a single file.

Fixes an issue where long downloads would show no indication of progress.
(The player's default is to use a background XHR with no visual feedback)

Uses an invisible link to initiate a browser-handled download instead,
which should avoid rage clicks that would otherwise lead to multiple
simultaneous downloads (and possibly transcodes).

Also did a little bit of housekeeping:
- Use the share description as the page title
- Set the json share description to the content field, if the
  description is empty (to match the existing logic elsewhere)
- Also some mild refactoring to allow the share page to access the
  (default) theme, and i18n. (This does slightly change the appearance)
  - Side note: the withTheme HOC is definitely the wrong place to do
    locale loading things. But I am not too familiar with react-admin,
    so i just kept the related code together.
This commit is contained in:
Katelyn Dickey 2026-03-12 01:22:26 -04:00
parent ecba19a08e
commit 68cc46242c
9 changed files with 172 additions and 39 deletions

View File

@ -2,10 +2,13 @@ package public
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/auth"
streampkg "github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
@ -72,6 +75,10 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if conf.Server.EnableDownloads && p.BoolOr("download", false) {
w.Header().Set("Content-Disposition", "attachment; filename=\""+downloadFilename(mf, info.format)+"\"")
}
n, err := stream.Serve(ctx, w, r)
if err != nil || n == 0 {
http.Error(w, "internal error", http.StatusInternalServerError)
@ -100,3 +107,15 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
shareID: c.ShareID,
}, nil
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
func downloadFilename(mf *model.MediaFile, format string) string {
ext := mf.Suffix
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%s - %s.%s", sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}

View File

@ -160,6 +160,9 @@ func addShareData(r *http.Request, data map[string]any, shareInfo *model.Share)
Description: shareInfo.Description,
Downloadable: shareInfo.Downloadable,
}
if sd.Description == "" {
sd.Description = shareInfo.Contents
}
sd.Tracks = slice.Map(shareInfo.Tracks, func(mf model.MediaFile) shareTrack {
return shareTrack{
ID: mf.ID,

View File

@ -26,7 +26,7 @@
<meta property="og:image" content="{{ .ShareImageURL }}">
<meta property="og:image:width" content="300">
<meta property="og:image:height" content="300">
<title>Navidrome</title>
<title>{{ if .ShareDescription }}{{ .ShareDescription }} | Navidrome{{ else }}Navidrome{{ end }}</title>
<script>
// Shim for libraries that check for Node.js process object
window.process = { env: {} };

View File

@ -6,6 +6,7 @@ import {
Resource,
useSetLocale,
useRefresh,
TranslationProvider,
} from 'react-admin'
import { HotKeys } from 'react-hotkeys'
import dataProvider from './dataProvider'
@ -192,7 +193,13 @@ const AppWithHotkeys = () => {
let language = localStorage.getItem('locale') || 'en'
document.documentElement.lang = language
if (config.enableSharing && shareInfo) {
return <SharePlayer />
return (
<Provider store={adminStore}>
<TranslationProvider i18nProvider={i18nProvider}>
<SharePlayer />
</TranslationProvider>
</Provider>
)
}
return (
<HotKeys keyMap={keyMap}>

View File

@ -260,7 +260,13 @@
"createdAt": "Created at"
},
"notifications": {},
"actions": {}
"actions": {
"download": {
"title": "Download",
"currentTrack": "Current Track",
"allTracks": "All Tracks"
}
}
},
"missing": {
"name": "Missing File |||| Missing Files",

View File

@ -8,21 +8,15 @@ import CardActions from '@material-ui/core/CardActions'
import CircularProgress from '@material-ui/core/CircularProgress'
import Link from '@material-ui/core/Link'
import TextField from '@material-ui/core/TextField'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import {
createMuiTheme,
useLogin,
useNotify,
useTranslate,
useVersion,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { useLogin, useNotify, useTranslate } from 'react-admin'
import Logo from '../icons/android-icon-192x192.png'
import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { INSIGHTS_DOC_URL } from '../consts.js'
import withTheme from '../utils/withTheme'
const useStyles = makeStyles(
(theme) => ({
@ -399,18 +393,6 @@ Login.propTypes = {
previousRoute: PropTypes.string,
}
// We need to put the ThemeProvider decoration in another component
// Because otherwise the useStyles() hook used in Login won't get
// the right theme
const LoginWithTheme = (props) => {
const theme = useCurrentTheme()
const version = useVersion()
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<Login key={version} {...props} />
</ThemeProvider>
)
}
const LoginWithTheme = withTheme(Login)
export default LoginWithTheme

View File

@ -1,10 +1,25 @@
import { useState } from 'react'
import { useTranslate } from 'react-admin'
import ReactJkMusicPlayer from 'navidrome-music-player'
import config, { shareInfo } from '../config'
import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils'
import {
shareCoverUrl,
shareDownloadUrl,
shareStreamUrl,
toDownloadUrl,
} from '../utils'
import withTheme from '../utils/withTheme'
import { DialogTitle } from '../dialogs/DialogTitle'
import { makeStyles } from '@material-ui/core/styles'
import { Button, Dialog, DialogContent } from '@material-ui/core'
import {
QueueMusic as QueueMusicIcon,
MusicNote as MusicNoteIcon,
} from '@material-ui/icons'
const useStyle = makeStyles({
const useStyle = makeStyles((theme) => ({
player: {
'& .group .next-audio': {
pointerEvents: (props) => props.single && 'none',
@ -20,10 +35,38 @@ const useStyle = makeStyles({
},
},
},
})
columnLayout: {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
},
}))
const downloadFile = (src, filename) => {
const link = document.createElement('a')
link.href = src
link.download = filename ?? '' // when blank, will use the Content-Disposition header
document.body.appendChild(link)
link.click()
link.remove()
}
const SharePlayer = () => {
const translate = useTranslate()
const classes = useStyle({ single: shareInfo?.tracks.length === 1 })
const [downloadInfo, setDownloadInfo] = useState(null)
const handleCustomDownload = (downloadInfo) => {
if (shareInfo?.tracks) {
if (shareInfo.tracks.length === 1) {
downloadFile(toDownloadUrl(downloadInfo.src))
} else {
setDownloadInfo(downloadInfo)
}
}
}
const handleClose = () => setDownloadInfo(null)
const list = shareInfo?.tracks.map((s) => {
return {
@ -34,11 +77,7 @@ const SharePlayer = () => {
duration: s.duration,
}
})
const onBeforeAudioDownload = () => {
return Promise.resolve({
src: shareDownloadUrl(shareInfo?.id),
})
}
const options = {
audioLists: list,
mode: 'full',
@ -56,12 +95,52 @@ const SharePlayer = () => {
sortableOptions: { delay: 200, delayOnTouchOnly: true },
}
return (
<ReactJkMusicPlayer
{...options}
className={classes.player}
onBeforeAudioDownload={onBeforeAudioDownload}
/>
<>
<ReactJkMusicPlayer
{...options}
className={classes.player}
customDownloader={handleCustomDownload}
/>
<Dialog
id="share-download-menu"
open={downloadInfo}
onClose={handleClose}
aria-labelledby="share-download-title"
>
<DialogTitle id="share-download-title" onClose={handleClose}>
{translate('resources.share.actions.download.title')}
</DialogTitle>
<DialogContent className={classes.columnLayout} dividers>
<Button
variant="contained"
startIcon={<MusicNoteIcon />}
onClick={() => {
downloadFile(toDownloadUrl(downloadInfo.src))
setDownloadInfo(null)
}}
>
{translate('resources.share.actions.download.currentTrack')}
</Button>
<Button
variant="contained"
startIcon={<QueueMusicIcon />}
disabled={!shareInfo}
onClick={() => {
downloadFile(
shareDownloadUrl(shareInfo?.id),
shareInfo?.description + '.zip',
)
setDownloadInfo(null)
}}
>
{translate('resources.share.actions.download.allTracks')}
</Button>
</DialogContent>
</Dialog>
</>
)
}
export default SharePlayer
const SharePlayerWithTheme = withTheme(SharePlayer)
export default SharePlayerWithTheme

View File

@ -29,6 +29,12 @@ export const shareStreamUrl = (id) => {
return shareUrl(config.publicBaseUrl + '/s/' + id)
}
export const toDownloadUrl = (src) => {
const url = new URL(src, window.origin)
url.searchParams.set('download', 'true')
return url.toString()
}
export const shareDownloadUrl = (id) => {
return shareUrl(config.publicBaseUrl + '/d/' + id)
}

View File

@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import {
createMuiTheme,
useRefresh,
useSetLocale,
useVersion,
} from 'react-admin'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { retrieveTranslation } from '../i18n'
const withTheme = (Component) => {
const WithTheme = (props) => {
const theme = useCurrentTheme()
const version = useVersion()
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<Component key={version} {...props} />
</ThemeProvider>
)
}
WithTheme.displayName = `WithTheme(${Component.displayName ?? Component.name ?? 'Component'})`
return WithTheme
}
export default withTheme