add aplayer for shared url support

# APlayer Integration for Navidrome Shares

This integration allows you to share music from Navidrome using APlayer, a beautiful HTML5 music player, without requiring authentication.

## Features

- 🎵 Beautiful, responsive music player interface
- 🔐 No authentication required - works with public share links
-  Respects share expiration dates
- 🎨 Clean, modern design
- 📱 Mobile-friendly
- 🔗 Easy to embed on external websites

## How to Use

### 1. Create a Share in Navidrome

1. In Navidrome, select songs, albums, or playlists you want to share
2. Click the share button and create a share link
3. Configure the share settings (expiration, description, etc.)

### 2. Get the APlayer URL

1. Go to the Navidrome admin panel
2. Navigate to "Shares" in the menu
3. Click on your share to edit it
4. You'll see two URLs:
   - **Share URL**: The regular Navidrome share page
   - **APlayer Embed URL**: The APlayer player page

### 3. Share or Embed

You can either:

- **Direct link**: Share the APlayer URL directly for people to listen in their browser
- **Embed in website**: Use an iframe to embed the player on your own website

#### Embed Example

```html
<iframe
  src="http://your-navidrome-server/share/SHARE_ID/aplayer"
  width="100%"
  height="500"
  frameborder="0"
  allow="autoplay">
</iframe>
```

## Technical Details

### How It Works

1. The APlayer page loads the share data from the server (no authentication needed)
2. Track streaming uses JWT tokens embedded in the share link
3. Tokens automatically expire when the share expires
4. All streaming is done through Navidrome's public API endpoints

### Security

- No username/password required
- Uses the same security model as regular Navidrome shares
- JWT tokens are scoped to specific shares
- Respects share expiration dates
- Cannot access data outside the shared content

### Files Added/Modified

**New Files:**
- `resources/aplayer.html` - HTML template for the APlayer page
- `resources/aplayer-share.js` - JavaScript that initializes APlayer with share data

**Modified Files:**
- `server/public/public.go` - Added route for `/share/:id/aplayer`
- `server/public/handle_shares.go` - Added handler for APlayer page
- `ui/src/utils/urls.js` - Added `shareAPlayerUrl()` function
- `ui/src/share/ShareEdit.jsx` - Added APlayer URL display

## Customization

### Styling

You can customize the appearance by modifying `resources/aplayer.html`. The default theme uses a purple gradient background, but you can change:

- Colors and gradients
- Player theme color
- Layout and spacing
- Font styles

### Player Options

Edit `resources/aplayer-share.js` to modify APlayer settings:

```javascript
const ap = new APlayer({
  autoplay: false,    // Auto-start playback
  theme: '#b7daff',   // Player color theme
  loop: 'all',        // Loop mode (all/one/none)
  volume: 0.7,        // Default volume (0-1)
  // ... more options
});
```

For all available options, see [APlayer documentation](https://aplayer.js.org/).

## Credits

- [Navidrome](https://github.com/navidrome/navidrome) - Modern Music Server
- [APlayer](https://github.com/DIYgod/APlayer) - Beautiful HTML5 Music Player
- [AplayerForNavidrome](https://github.com/maytom2016/AplayerForNavidrome) - Original inspiration

## License

This integration follows the same license as Navidrome (GPL-3.0).
This commit is contained in:
Sora 2025-12-16 09:05:07 +08:00
parent c7ac0e4414
commit 605902c6c0
9 changed files with 514 additions and 5 deletions

114
APLAYER_INTEGRATION.md Normal file
View File

@ -0,0 +1,114 @@
# APlayer Integration for Navidrome Shares
This integration allows you to share music from Navidrome using APlayer, a beautiful HTML5 music player, without requiring authentication.
## Features
- 🎵 Beautiful, responsive music player interface
- 🔐 No authentication required - works with public share links
- ⏰ Respects share expiration dates
- 🎨 Clean, modern design
- 📱 Mobile-friendly
- 🔗 Easy to embed on external websites
## How to Use
### 1. Create a Share in Navidrome
1. In Navidrome, select songs, albums, or playlists you want to share
2. Click the share button and create a share link
3. Configure the share settings (expiration, description, etc.)
### 2. Get the APlayer URL
1. Go to the Navidrome admin panel
2. Navigate to "Shares" in the menu
3. Click on your share to edit it
4. You'll see two URLs:
- **Share URL**: The regular Navidrome share page
- **APlayer Embed URL**: The APlayer player page
### 3. Share or Embed
You can either:
- **Direct link**: Share the APlayer URL directly for people to listen in their browser
- **Embed in website**: Use an iframe to embed the player on your own website
#### Embed Example
```html
<iframe
src="http://your-navidrome-server/share/SHARE_ID/aplayer"
width="100%"
height="500"
frameborder="0"
allow="autoplay">
</iframe>
```
## Technical Details
### How It Works
1. The APlayer page loads the share data from the server (no authentication needed)
2. Track streaming uses JWT tokens embedded in the share link
3. Tokens automatically expire when the share expires
4. All streaming is done through Navidrome's public API endpoints
### Security
- No username/password required
- Uses the same security model as regular Navidrome shares
- JWT tokens are scoped to specific shares
- Respects share expiration dates
- Cannot access data outside the shared content
### Files Added/Modified
**New Files:**
- `resources/aplayer.html` - HTML template for the APlayer page
- `resources/aplayer-share.js` - JavaScript that initializes APlayer with share data
**Modified Files:**
- `server/public/public.go` - Added route for `/share/:id/aplayer`
- `server/public/handle_shares.go` - Added handler for APlayer page
- `ui/src/utils/urls.js` - Added `shareAPlayerUrl()` function
- `ui/src/share/ShareEdit.jsx` - Added APlayer URL display
## Customization
### Styling
You can customize the appearance by modifying `resources/aplayer.html`. The default theme uses a purple gradient background, but you can change:
- Colors and gradients
- Player theme color
- Layout and spacing
- Font styles
### Player Options
Edit `resources/aplayer-share.js` to modify APlayer settings:
```javascript
const ap = new APlayer({
autoplay: false, // Auto-start playback
theme: '#b7daff', // Player color theme
loop: 'all', // Loop mode (all/one/none)
volume: 0.7, // Default volume (0-1)
// ... more options
});
```
For all available options, see [APlayer documentation](https://aplayer.js.org/).
## Credits
- [Navidrome](https://github.com/navidrome/navidrome) - Modern Music Server
- [APlayer](https://github.com/DIYgod/APlayer) - Beautiful HTML5 Music Player
- [AplayerForNavidrome](https://github.com/maytom2016/AplayerForNavidrome) - Original inspiration
## License
This integration follows the same license as Navidrome (GPL-3.0).

1
go.mod
View File

@ -125,6 +125,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/wtolson/go-taglib v0.0.0-20210406152913-79209c280058 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect

2
go.sum
View File

@ -277,6 +277,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wtolson/go-taglib v0.0.0-20210406152913-79209c280058 h1:/kj9W8wSHTlwt/i4n6902i/YOPYNIXiDR/PAmgbrDyc=
github.com/wtolson/go-taglib v0.0.0-20210406152913-79209c280058/go.mod h1:p+WHGfN/a+Ol37Pm7EIOO/6Cylieb2qn1jmKfxtSsUg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

View File

@ -0,0 +1,96 @@
/**
* 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() {
if (typeof APlayer === 'undefined') {
console.error('APlayer library not loaded');
return;
}
// Get share info from the page (injected by server)
const shareInfoElement = document.getElementById('share-info');
if (!shareInfoElement) {
console.error('Share info not found');
return;
}
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 + '/public/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;
}
const 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,
});
// Log initialization
console.log('APlayer initialized with', playlist.length, 'tracks');
// 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();
}
})();

128
resources/aplayer.html Normal file
View File

@ -0,0 +1,128 @@
<!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 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
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: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 24px;
margin-bottom: 10px;
font-weight: 600;
}
.header p {
font-size: 14px;
opacity: 0.9;
}
.player-wrapper {
padding: 20px;
}
#aplayer {
margin: 0;
}
.footer {
padding: 20px;
text-align: center;
font-size: 12px;
color: #666;
border-top: 1px solid #eee;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Make APlayer responsive */
@media (max-width: 600px) {
.header h1 {
font-size: 20px;
}
.container {
margin: 0;
border-radius: 0;
}
}
</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 -->
<script src="https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.js"></script>
<!-- APlayer Share integration script -->
<script>{{.APlayerScript}}</script>
</body>
</html>

View File

@ -2,16 +2,23 @@ package public
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"html/template"
"net/http" "net/http"
"path" "path"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"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/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) {
@ -59,6 +66,144 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(s.ToM3U8())) _, _ = w.Write([]byte(s.ToM3U8()))
} }
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 := make([]byte, 0)
buf := make([]byte, 1024)
for {
n, err := tmplData.Read(buf)
if n > 0 {
tmplContent = append(tmplContent, buf[:n]...)
}
if err != nil {
break
}
}
// 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 := make([]byte, 0)
for {
n, err := scriptData.Read(buf)
if n > 0 {
scriptContent = append(scriptContent, buf[:n]...)
}
if err != nil {
break
}
}
// 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)
if baseURL == "" {
baseURL = ""
}
data := map[string]interface{}{
"ShareDescription": description,
"ShareInfo": string(shareInfoJSON),
"APlayerScript": string(scriptContent),
"BaseURL": baseURL,
}
// Render template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = tmpl.Execute(w, data)
if err != nil {
log.Error(r.Context(), "Error executing aplayer template", err)
}
}
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):

View File

@ -57,6 +57,7 @@ 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("/{id}", pub.handleShares) r.HandleFunc("/{id}", pub.handleShares)
r.HandleFunc("/", pub.handleShares) r.HandleFunc("/", pub.handleShares)
r.Handle("/*", pub.assetsHandler) r.Handle("/*", pub.assetsHandler)

View File

@ -6,20 +6,34 @@ import {
SimpleForm, SimpleForm,
TextInput, TextInput,
} 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 } from '@material-ui/core'
import { DateField } from '../common' import { DateField } from '../common'
import config from '../config' import config from '../config'
export const ShareEdit = (props) => { export const ShareEdit = (props) => {
const { id, basePath, hasCreate, ...rest } = props const { id, basePath, hasCreate, ...rest } = props
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"> <Box mb={2}>
{url} <Typography variant="body2" color="textSecondary" gutterBottom>
</Link> Share URL
</Typography>
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
{url}
</Link>
</Box>
<Box mb={2}>
<Typography variant="body2" color="textSecondary" gutterBottom>
APlayer Embed URL
</Typography>
<Link source="APlayerURL" href={aplayerUrl} target="_blank" rel="noopener noreferrer">
{aplayerUrl}
</Link>
</Box>
<TextInput source="description" /> <TextInput source="description" />
{config.enableDownloads && <BooleanInput source="downloadable" />} {config.enableDownloads && <BooleanInput source="downloadable" />}
<DateTimeInput source="expiresAt" /> <DateTimeInput source="expiresAt" />

View File

@ -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)
} }