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
|
package public
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
@ -12,10 +17,13 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/publicurl"
|
"github.com/navidrome/navidrome/core/publicurl"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/resources"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/ui"
|
"github.com/navidrome/navidrome/ui"
|
||||||
. "github.com/navidrome/navidrome/utils/gg"
|
. "github.com/navidrome/navidrome/utils/gg"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"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) {
|
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
|
_, _ = 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) {
|
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, model.ErrExpired):
|
case errors.Is(err, model.ErrExpired):
|
||||||
|
|||||||
@ -58,6 +58,9 @@ func (pub *Router) routes() http.Handler {
|
|||||||
r.HandleFunc("/d/{id}", pub.handleDownloads)
|
r.HandleFunc("/d/{id}", pub.handleDownloads)
|
||||||
}
|
}
|
||||||
r.HandleFunc("/{id}/m3u", pub.handleM3U)
|
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("/{id}", pub.handleShares)
|
||||||
r.HandleFunc("/", pub.handleShares)
|
r.HandleFunc("/", pub.handleShares)
|
||||||
r.Handle("/*", pub.assetsHandler)
|
r.Handle("/*", pub.assetsHandler)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -489,6 +490,8 @@ var _ = Describe("helpers", func() {
|
|||||||
var ctx context.Context
|
var ctx context.Context
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ds := &tests.MockDataStore{}
|
||||||
|
auth.Init(ds)
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
conf.Server.Subsonic.EnableAverageRating = true
|
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"
|
"rollup": "npm:@rollup/wasm-node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -576,6 +576,23 @@
|
|||||||
"delete_user_title": "Delete user '%{name}'",
|
"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)?",
|
"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",
|
"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_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_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.",
|
"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,
|
NumberField,
|
||||||
SimpleForm,
|
SimpleForm,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { sharePlayerUrl } from '../utils'
|
import { sharePlayerUrl, shareAPlayerUrl } from '../utils'
|
||||||
import { Link } from '@material-ui/core'
|
import {
|
||||||
|
Link,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
} from '@material-ui/core'
|
||||||
import { DateField } from '../common'
|
import { DateField } from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
import { EmbedCodeField } from './EmbedCodeField'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
|
||||||
export const ShareEdit = (props) => {
|
export const ShareEdit = (props) => {
|
||||||
const { id, basePath, hasCreate, ...rest } = props
|
const { id, basePath, hasCreate, ...rest } = props
|
||||||
|
const translate = useTranslate()
|
||||||
const url = sharePlayerUrl(id)
|
const url = sharePlayerUrl(id)
|
||||||
|
const aplayerUrl = shareAPlayerUrl(id)
|
||||||
return (
|
return (
|
||||||
<Edit {...props}>
|
<Edit {...props}>
|
||||||
<SimpleForm {...rest}>
|
<SimpleForm {...rest}>
|
||||||
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{url}
|
|
||||||
</Link>
|
|
||||||
<TextInput source="description" />
|
<TextInput source="description" />
|
||||||
{config.enableDownloads && <BooleanInput source="downloadable" />}
|
{config.enableDownloads && <BooleanInput source="downloadable" />}
|
||||||
<DateTimeInput source="expiresAt" />
|
<DateTimeInput source="expiresAt" />
|
||||||
@ -30,6 +40,44 @@ export const ShareEdit = (props) => {
|
|||||||
<NumberField source="visitCount" disabled />
|
<NumberField source="visitCount" disabled />
|
||||||
<DateField source="lastVisitedAt" disabled showTime />
|
<DateField source="lastVisitedAt" disabled showTime />
|
||||||
<DateField source="createdAt" 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>
|
</SimpleForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -25,6 +25,14 @@ export const sharePlayerUrl = (id) => {
|
|||||||
return url.href
|
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) => {
|
export const shareStreamUrl = (id) => {
|
||||||
return shareUrl(config.publicBaseUrl + '/s/' + id)
|
return shareUrl(config.publicBaseUrl + '/s/' + id)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user