mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge a65947692b911a02db2cc621dd3a6fdfdc124ff5 into a00152397e0807ec906768d79f3e619adf43b3c3
This commit is contained in:
commit
cece6a810a
3
resources/APlayer.min.css
vendored
Normal file
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
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
136
resources/aplayer-share.js
Normal 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
148
resources/aplayer.html
Normal 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>
|
||||
& <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>
|
||||
53
server/public/handle_aplayer_assets.go
Normal file
53
server/public/handle_aplayer_assets.go
Normal 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)
|
||||
}
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
2
ui/package-lock.json
generated
@ -13144,4 +13144,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,4 +86,4 @@
|
||||
"rollup": "npm:@rollup/wasm-node"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
344
ui/src/share/EmbedCodeField.jsx
Normal file
344
ui/src/share/EmbedCodeField.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user