Merge a65947692b911a02db2cc621dd3a6fdfdc124ff5 into a00152397e0807ec906768d79f3e619adf43b3c3

This commit is contained in:
Sora 2026-05-03 09:47:08 +08:00 committed by GitHub
commit cece6a810a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 913 additions and 7 deletions

3
resources/APlayer.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
resources/APlayer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

136
resources/aplayer-share.js Normal file
View File

@ -0,0 +1,136 @@
/**
* APlayer integration for Navidrome Share Links
* Works with public share links without authentication
*/
(function() {
'use strict';
// Wait for DOM and APlayer to be ready
function initAPlayer() {
console.log('APlayer initialization started');
// Check if APlayer is loaded
if (typeof APlayer === 'undefined') {
console.error('APlayer library not loaded - checking if script loaded');
// Try to load APlayer if not available
const aplayerScript = document.querySelector('script[src*="APlayer.min.js"]');
if (!aplayerScript) {
console.error('APlayer script tag not found in DOM');
} else {
console.log('APlayer script tag found:', aplayerScript.src);
}
return;
}
console.log('APlayer library loaded');
// Get share info from the page (injected by server)
const shareInfoElement = document.getElementById('share-info');
if (!shareInfoElement) {
console.error('Share info not found');
return;
}
console.log('Share info element found:', shareInfoElement.textContent);
let shareInfo;
try {
shareInfo = JSON.parse(shareInfoElement.textContent);
} catch (e) {
console.error('Failed to parse share info:', e);
return;
}
if (!shareInfo || !shareInfo.tracks || shareInfo.tracks.length === 0) {
console.error('No tracks found in share');
return;
}
// Get base URL from the page
const baseURL = window.NavidromeConfig?.baseURL || '';
// Convert share tracks to APlayer format
const playlist = shareInfo.tracks.map(function(track) {
// Stream URL uses the encoded track ID (contains JWT token)
const streamUrl = baseURL + '/share/s/' + track.id;
// Cover art URL - we'll construct it from the share's image
const coverUrl = shareInfo.imageUrl || baseURL + '/android-chrome-192x192.png';
return {
name: track.title || 'Unknown Title',
artist: track.artist || 'Unknown Artist',
url: streamUrl,
cover: coverUrl,
theme: '#b7daff'
};
});
// Initialize APlayer
const container = document.getElementById('aplayer');
if (!container) {
console.error('APlayer container not found');
return;
}
console.log('APlayer container found:', container);
console.log('Container dimensions:', container.offsetWidth, 'x', container.offsetHeight);
console.log('Container styles:', window.getComputedStyle(container));
console.log('Creating APlayer with playlist:', playlist);
let ap;
try {
ap = new APlayer({
container: container,
lrcType: 0,
audio: playlist,
autoplay: false,
theme: '#b7daff',
loop: 'all',
order: 'list',
preload: 'auto',
volume: 0.7,
mutex: true,
listFolded: false,
listMaxHeight: 90,
fixed: false,
mini: false,
});
// Log initialization
console.log('APlayer initialized with', playlist.length, 'tracks');
console.log('APlayer instance:', ap);
// Check if APlayer created DOM elements
setTimeout(() => {
const aplayerElements = container.querySelectorAll('*');
console.log('APlayer created', aplayerElements.length, 'child elements');
if (aplayerElements.length === 0) {
console.error('APlayer did not create any child elements - initialization failed');
} else {
console.log('APlayer child elements:', aplayerElements);
}
}, 100);
} catch (error) {
console.error('APlayer initialization failed:', error);
return;
}
// Optional: Add event listeners
ap.on('play', function() {
console.log('Playing:', ap.list.audios[ap.list.index].name);
});
ap.on('error', function() {
console.error('Playback error');
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAPlayer);
} else {
initAPlayer();
}
})();

148
resources/aplayer.html Normal file
View File

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.ShareDescription}} - Navidrome</title>
<meta name="description" content="Shared music player - {{.ShareDescription}}">
<!-- APlayer CSS (vendored locally) -->
<link rel="stylesheet" href="{{.BaseURL}}/share/aplayer/APlayer.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 800px;
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header {
background: #ffffff;
color: #333;
padding: 40px 30px;
text-align: center;
border-bottom: 1px solid #e9ecef;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
font-weight: 600;
color: #2c3e50;
}
.header p {
font-size: 14px;
color: #7f8c8d;
}
.player-wrapper {
padding: 30px;
background: #ffffff;
}
#aplayer {
margin: 0 auto;
box-shadow: none !important;
border-radius: 0 !important;
}
.footer {
padding: 20px;
text-align: center;
font-size: 12px;
color: #999;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.footer a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.footer a:hover {
text-decoration: underline;
}
/* Make APlayer responsive */
@media (max-width: 600px) {
body {
padding: 0;
background: #ffffff;
}
.container {
border-radius: 0;
box-shadow: none;
}
.header {
padding: 30px 20px;
}
.header h1 {
font-size: 22px;
}
.player-wrapper {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{.ShareDescription}}</h1>
<p>Shared Music Player</p>
</div>
<div class="player-wrapper">
<div id="aplayer"></div>
</div>
<div class="footer">
Powered by <a href="https://github.com/navidrome/navidrome" target="_blank" rel="noopener noreferrer">Navidrome</a>
&amp; <a href="https://github.com/DIYgod/APlayer" target="_blank" rel="noopener noreferrer">APlayer</a>
</div>
</div>
<!-- Share info (injected by server) -->
<script id="share-info" type="application/json">{{.ShareInfo}}</script>
<!-- Navidrome config (for baseURL) -->
<script>
window.NavidromeConfig = {
baseURL: "{{.BaseURL}}"
};
</script>
<!-- APlayer library (vendored locally) -->
<script src="{{.BaseURL}}/share/aplayer/APlayer.min.js"></script>
<!-- APlayer Share integration script -->
<script>{{.APlayerScript}}</script>
</body>
</html>

View File

@ -0,0 +1,53 @@
package public
import (
"io"
"net/http"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
)
// handleAPlayerCSS serves the vendored APlayer CSS file
func (pub *Router) handleAPlayerCSS(w http.ResponseWriter, r *http.Request) {
cssFile, err := resources.FS().Open("APlayer.min.css")
if err != nil {
log.Error(r.Context(), "Could not find APlayer.min.css", err)
http.Error(w, "CSS file not found", http.StatusNotFound)
return
}
defer cssFile.Close()
cssContent, err := io.ReadAll(cssFile)
if err != nil {
log.Error(r.Context(), "Error reading APlayer.min.css", err)
http.Error(w, "Error reading CSS file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
_, _ = w.Write(cssContent)
}
// handleAPlayerJS serves the vendored APlayer JavaScript file
func (pub *Router) handleAPlayerJS(w http.ResponseWriter, r *http.Request) {
jsFile, err := resources.FS().Open("APlayer.min.js")
if err != nil {
log.Error(r.Context(), "Could not find APlayer.min.js", err)
http.Error(w, "JS file not found", http.StatusNotFound)
return
}
defer jsFile.Close()
jsContent, err := io.ReadAll(jsFile)
if err != nil {
log.Error(r.Context(), "Error reading APlayer.min.js", err)
http.Error(w, "Error reading JS file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
_, _ = w.Write(jsContent)
}

View File

@ -1,10 +1,15 @@
package public
import (
"bytes"
"context"
"encoding/json"
"errors"
"html/template"
"io"
"net/http"
"path"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -12,10 +17,13 @@ import (
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) {
@ -63,6 +71,139 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}
func (pub *Router) handleAPlayer(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Load share
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
// Map share info for APlayer
s = pub.mapShareInfo(r, *s)
// Read template
tmplData, err := resources.FS().Open("aplayer.html")
if err != nil {
log.Error(r.Context(), "Could not find aplayer.html template", err)
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
defer tmplData.Close()
tmplContent, err := io.ReadAll(tmplData)
if err != nil {
log.Error(r.Context(), "Error reading aplayer.html template", err)
http.Error(w, "Error reading template", http.StatusInternalServerError)
return
}
// Read APlayer script
scriptData, err := resources.FS().Open("aplayer-share.js")
if err != nil {
log.Error(r.Context(), "Could not find aplayer-share.js", err)
http.Error(w, "Script not found", http.StatusInternalServerError)
return
}
defer scriptData.Close()
scriptContent, err := io.ReadAll(scriptData)
if err != nil {
log.Error(r.Context(), "Error reading aplayer-share.js", err)
http.Error(w, "Error reading script", http.StatusInternalServerError)
return
}
// Parse template
tmpl, err := template.New("aplayer").Parse(string(tmplContent))
if err != nil {
log.Error(r.Context(), "Error parsing aplayer.html template", err)
http.Error(w, "Error parsing template", http.StatusInternalServerError)
return
}
// Prepare share data for JSON
type aplayerTrack struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Duration float32 `json:"duration"`
UpdatedAt time.Time `json:"updatedAt"`
}
type aplayerShareInfo struct {
ID string `json:"id"`
Description string `json:"description"`
Downloadable bool `json:"downloadable"`
Tracks []aplayerTrack `json:"tracks"`
ImageUrl string `json:"imageUrl"`
}
shareData := aplayerShareInfo{
ID: s.ID,
Description: s.Description,
Downloadable: s.Downloadable,
ImageUrl: s.ImageURL,
Tracks: slice.Map(s.Tracks, func(mf model.MediaFile) aplayerTrack {
return aplayerTrack{
ID: mf.ID,
Title: mf.Title,
Artist: mf.Artist,
Album: mf.Album,
Duration: mf.Duration,
UpdatedAt: mf.UpdatedAt,
}
}),
}
shareInfoJSON, err := json.Marshal(shareData)
if err != nil {
log.Error(r.Context(), "Error converting share data to JSON", err)
http.Error(w, "Error processing share data", http.StatusInternalServerError)
return
}
// Prepare template data
description := s.Description
if description == "" {
description = str.SanitizeText(s.Contents)
}
if description == "" {
description = "Shared Music"
}
baseURL := str.SanitizeText(conf.Server.BasePath)
data := map[string]interface{}{
"ShareDescription": description,
// #nosec G203 -- shareInfoJSON is generated by json.Marshal from server data, not user input
"ShareInfo": template.JS(shareInfoJSON),
// #nosec G203 -- scriptContent is from embedded resource file, not user input
"APlayerScript": template.JS(scriptContent),
"BaseURL": baseURL,
}
// Render template
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
log.Error(r.Context(), "Error executing aplayer template", err)
http.Error(w, "Error rendering page", http.StatusInternalServerError)
return
}
// Allow embedding in iframes for APlayer share pages
w.Header().Set("X-Frame-Options", "ALLOWALL")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
switch {
case errors.Is(err, model.ErrExpired):

View File

@ -58,6 +58,9 @@ func (pub *Router) routes() http.Handler {
r.HandleFunc("/d/{id}", pub.handleDownloads)
}
r.HandleFunc("/{id}/m3u", pub.handleM3U)
r.HandleFunc("/{id}/aplayer", pub.handleAPlayer)
r.HandleFunc("/aplayer/APlayer.min.css", pub.handleAPlayerCSS)
r.HandleFunc("/aplayer/APlayer.min.js", pub.handleAPlayerJS)
r.HandleFunc("/{id}", pub.handleShares)
r.HandleFunc("/", pub.handleShares)
r.Handle("/*", pub.assetsHandler)

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -489,6 +490,8 @@ var _ = Describe("helpers", func() {
var ctx context.Context
BeforeEach(func() {
ds := &tests.MockDataStore{}
auth.Init(ds)
ctx = context.Background()
conf.Server.Subsonic.EnableAverageRating = true
})

2
ui/package-lock.json generated
View File

@ -13144,4 +13144,4 @@
}
}
}
}
}

View File

@ -86,4 +86,4 @@
"rollup": "npm:@rollup/wasm-node"
}
}
}
}

View File

@ -576,6 +576,23 @@
"delete_user_title": "Delete user '%{name}'",
"delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?",
"remove_missing_title": "Remove missing files",
"shareUrl": "Share URL",
"aplayerEmbedUrl": "APlayer Embed URL",
"navidromeMusicPlayer": "Navidrome Music Player",
"embedCode": "Embed Code",
"copyCode": "Copy Code",
"codeCopied": "Code copied to clipboard",
"embedTip": "Tip: Copy this code and paste it into your webpage HTML to use",
"floatingPlayerLeft": "Bottom Left Floating Player",
"floatingPlayerLeftDesc": "Collapsible floating player in bottom left corner, users can click to expand/collapse",
"fixedBottomPlayer": "Fixed Bottom Player",
"fixedBottomPlayerDesc": "Fixed at bottom of page, always visible (similar to MetingJS fixed mode)",
"basicIframe": "Basic iframe",
"basicIframeDesc": "Simple iframe embed, suitable for fixed position display",
"responsiveIframe": "Responsive iframe",
"responsiveIframeDesc": "16:9 responsive layout, adapts to different screen widths",
"floatingPlayerRight": "Bottom Right Floating Player",
"floatingPlayerRightDesc": "Collapsible floating player in bottom right corner (alternative position)",
"remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.",
"remove_all_missing_title": "Remove all missing files",
"remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.",

View File

@ -0,0 +1,344 @@
import React, { useState } from 'react'
import {
Box,
Typography,
TextField,
IconButton,
Tabs,
Tab,
makeStyles,
Snackbar,
} from '@material-ui/core'
import { useTranslate } from 'react-admin'
import FileCopyIcon from '@material-ui/icons/FileCopy'
const useStyles = makeStyles((theme) => ({
root: {
marginBottom: theme.spacing(3),
},
tabPanel: {
marginTop: theme.spacing(2),
},
codeField: {
fontFamily: 'monospace',
fontSize: '12px',
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '12px',
},
},
copyButton: {
marginLeft: theme.spacing(1),
},
header: {
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(1),
},
}))
const TabPanel = ({ children, value, index, ...other }) => {
return (
<div
role="tabpanel"
hidden={value !== index}
id={`embed-tabpanel-${index}`}
aria-labelledby={`embed-tab-${index}`}
{...other}
>
{value === index && <Box py={2}>{children}</Box>}
</div>
)
}
export const EmbedCodeField = ({ url, title = 'Music Player' }) => {
const classes = useStyles()
const translate = useTranslate()
const [tabValue, setTabValue] = useState(0)
const [snackbarOpen, setSnackbarOpen] = useState(false)
const handleTabChange = (event, newValue) => {
setTabValue(newValue)
}
const handleCopy = (text) => {
navigator.clipboard.writeText(text).then(() => {
setSnackbarOpen(true)
})
}
const handleSnackbarClose = () => {
setSnackbarOpen(false)
}
// iframe
const iframeEmbed = `<iframe src="${url}" width="100%" height="450" frameborder="0" allowfullscreen></iframe>`
// iframe
const responsiveEmbed = `<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe src="${url}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" frameborder="0" allowfullscreen></iframe>
</div>`
// - MetingJS
const floatingPlayerEmbed = `<!-- Navidrome 悬浮播放器 -->
<div id="navidrome-floating-player">
<div id="nav-player-container" class="nav-collapsed">
<div id="nav-player-toggle" onclick="toggleNavPlayer()">
<span id="nav-toggle-icon"></span>
</div>
<div id="nav-player-content">
<iframe src="${url}" frameborder="0" allowfullscreen></iframe>
</div>
</div>
</div>
<style>
#navidrome-floating-player {
position: fixed;
left: 20px;
bottom: 20px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#nav-player-container {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
}
#nav-player-container.nav-collapsed {
width: 60px;
height: 60px;
}
#nav-player-container.nav-expanded {
width: 380px;
height: 520px;
}
#nav-player-toggle {
width: 60px;
height: 60px;
min-height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
}
#nav-player-container.nav-expanded #nav-player-toggle {
border-radius: 12px 12px 0 0;
}
#nav-player-container.nav-collapsed #nav-player-toggle {
border-radius: 12px;
}
#nav-player-toggle:hover {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
#nav-toggle-icon {
font-size: 28px;
color: white;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
#nav-player-container.nav-expanded #nav-toggle-icon {
transform: rotate(180deg);
}
#nav-player-content {
display: none;
width: 100%;
flex: 1;
overflow: hidden;
}
#nav-player-container.nav-expanded #nav-player-content {
display: block;
}
#nav-player-content iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* 移动端适配 */
@media (max-width: 768px) {
#navidrome-floating-player {
left: 10px;
bottom: 10px;
}
#nav-player-container.nav-expanded {
width: calc(100vw - 20px);
height: 480px;
max-width: 380px;
}
}
</style>
<script>
function toggleNavPlayer() {
const container = document.getElementById('nav-player-container');
container.classList.toggle('nav-collapsed');
container.classList.toggle('nav-expanded');
}
//
document.addEventListener('click', function(event) {
const player = document.getElementById('navidrome-floating-player');
const container = document.getElementById('nav-player-container');
if (player && !player.contains(event.target) &&
container.classList.contains('nav-expanded')) {
toggleNavPlayer();
}
});
</script>`
// - MetingJS fixed
const fixedBottomEmbed = `<!-- Navidrome 固定底部播放器 -->
<div id="navidrome-fixed-player">
<iframe src="${url}" frameborder="0" allowfullscreen></iframe>
</div>
<style>
#navidrome-fixed-player {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 80px;
z-index: 9999;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
background: white;
}
#navidrome-fixed-player iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* 为页面内容添加底部边距,避免被播放器遮挡 */
body {
padding-bottom: 80px;
}
</style>`
//
const floatingPlayerRightEmbed = floatingPlayerEmbed
.replace('left: 20px;', 'right: 20px;')
.replace('left: 10px;', 'right: 10px;')
const embedOptions = [
{
label: translate('message.floatingPlayerLeft'),
code: floatingPlayerEmbed,
description: translate('message.floatingPlayerLeftDesc'),
},
{
label: translate('message.fixedBottomPlayer'),
code: fixedBottomEmbed,
description: translate('message.fixedBottomPlayerDesc'),
},
{
label: translate('message.basicIframe'),
code: iframeEmbed,
description: translate('message.basicIframeDesc'),
},
{
label: translate('message.responsiveIframe'),
code: responsiveEmbed,
description: translate('message.responsiveIframeDesc'),
},
{
label: translate('message.floatingPlayerRight'),
code: floatingPlayerRightEmbed,
description: translate('message.floatingPlayerRightDesc'),
},
]
return (
<Box className={classes.root}>
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('message.embedCode')}
</Typography>
<Tabs
value={tabValue}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
>
{embedOptions.map((option, index) => (
<Tab key={index} label={option.label} />
))}
</Tabs>
{embedOptions.map((option, index) => (
<TabPanel key={index} value={tabValue} index={index}>
<Typography
variant="caption"
color="textSecondary"
display="block"
gutterBottom
>
{option.description}
</Typography>
<Box display="flex" alignItems="flex-start">
<TextField
fullWidth
multiline
rows={option.code.split('\n').length > 20 ? 20 : 12}
variant="outlined"
value={option.code}
className={classes.codeField}
InputProps={{
readOnly: true,
}}
/>
<IconButton
className={classes.copyButton}
onClick={() => handleCopy(option.code)}
color="primary"
size="small"
title={translate('message.copyCode')}
>
<FileCopyIcon />
</IconButton>
</Box>
<Typography variant="caption" color="textSecondary" display="block">
{translate('message.embedTip')}
</Typography>
</TabPanel>
))}
<Snackbar
open={snackbarOpen}
autoHideDuration={2000}
onClose={handleSnackbarClose}
message={translate('message.codeCopied')}
/>
</Box>
)
}

View File

@ -5,21 +5,31 @@ import {
NumberField,
SimpleForm,
TextInput,
useTranslate,
} from 'react-admin'
import { sharePlayerUrl } from '../utils'
import { Link } from '@material-ui/core'
import { sharePlayerUrl, shareAPlayerUrl } from '../utils'
import {
Link,
Box,
Typography,
Divider,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@material-ui/core'
import { DateField } from '../common'
import config from '../config'
import { EmbedCodeField } from './EmbedCodeField'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
export const ShareEdit = (props) => {
const { id, basePath, hasCreate, ...rest } = props
const translate = useTranslate()
const url = sharePlayerUrl(id)
const aplayerUrl = shareAPlayerUrl(id)
return (
<Edit {...props}>
<SimpleForm {...rest}>
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
{url}
</Link>
<TextInput source="description" />
{config.enableDownloads && <BooleanInput source="downloadable" />}
<DateTimeInput source="expiresAt" />
@ -30,6 +40,44 @@ export const ShareEdit = (props) => {
<NumberField source="visitCount" disabled />
<DateField source="lastVisitedAt" disabled showTime />
<DateField source="createdAt" disabled showTime />
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="share-urls-content"
id="share-urls-header"
>
<Typography variant="body2" color="textSecondary">
{translate('message.shareUrl')} &{' '}
{translate('message.aplayerEmbedUrl')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box mb={2}>
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('message.shareUrl')}
</Typography>
<Link href={url} target="_blank" rel="noopener noreferrer">
{url}
</Link>
</Box>
<Box mb={2}>
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('message.aplayerEmbedUrl')}
</Typography>
<Link href={aplayerUrl} target="_blank" rel="noopener noreferrer">
{aplayerUrl}
</Link>
</Box>
<Box mb={3}>
<Divider />
</Box>
<EmbedCodeField
url={aplayerUrl}
title={translate('message.navidromeMusicPlayer')}
/>
</AccordionDetails>
</Accordion>
</SimpleForm>
</Edit>
)

View File

@ -25,6 +25,14 @@ export const sharePlayerUrl = (id) => {
return url.href
}
export const shareAPlayerUrl = (id) => {
const url = new URL(
shareUrl(config.publicBaseUrl + '/' + id + '/aplayer'),
window.location.href,
)
return url.href
}
export const shareStreamUrl = (id) => {
return shareUrl(config.publicBaseUrl + '/s/' + id)
}