mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
ecba19a08e
commit
68cc46242c
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {} };
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
31
ui/src/utils/withTheme.jsx
Normal file
31
ui/src/utils/withTheme.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user