diff --git a/APLAYER_INTEGRATION.md b/APLAYER_INTEGRATION.md new file mode 100644 index 000000000..58e4cb739 --- /dev/null +++ b/APLAYER_INTEGRATION.md @@ -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 + +``` + +## 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). diff --git a/go.mod b/go.mod index 8b741caff..478f81263 100644 --- a/go.mod +++ b/go.mod @@ -125,6 +125,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // 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 go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/go.sum b/go.sum index f5916c912..d85baf346 100644 --- a/go.sum +++ b/go.sum @@ -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/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= 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/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/resources/aplayer-share.js b/resources/aplayer-share.js new file mode 100644 index 000000000..d94e08fa2 --- /dev/null +++ b/resources/aplayer-share.js @@ -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(); + } +})(); diff --git a/resources/aplayer.html b/resources/aplayer.html new file mode 100644 index 000000000..8f5272a59 --- /dev/null +++ b/resources/aplayer.html @@ -0,0 +1,128 @@ + + + + + + {{.ShareDescription}} - Navidrome + + + + + + + + +
+
+

{{.ShareDescription}}

+

Shared Music Player

+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 61f3fba71..b15c17f33 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -2,16 +2,23 @@ package public import ( "context" + "encoding/json" "errors" + "html/template" "net/http" "path" + "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "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/req" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) 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())) } +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) { switch { case errors.Is(err, model.ErrExpired): diff --git a/server/public/public.go b/server/public/public.go index 03ccaeebe..7af76f3a0 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -57,6 +57,7 @@ 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("/{id}", pub.handleShares) r.HandleFunc("/", pub.handleShares) r.Handle("/*", pub.assetsHandler) diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx index 2cf7f2df7..157aca9fe 100644 --- a/ui/src/share/ShareEdit.jsx +++ b/ui/src/share/ShareEdit.jsx @@ -6,20 +6,34 @@ import { SimpleForm, TextInput, } from 'react-admin' -import { sharePlayerUrl } from '../utils' -import { Link } from '@material-ui/core' +import { sharePlayerUrl, shareAPlayerUrl } from '../utils' +import { Link, Box, Typography } from '@material-ui/core' import { DateField } from '../common' import config from '../config' export const ShareEdit = (props) => { const { id, basePath, hasCreate, ...rest } = props const url = sharePlayerUrl(id) + const aplayerUrl = shareAPlayerUrl(id) return ( - - {url} - + + + Share URL + + + {url} + + + + + APlayer Embed URL + + + {aplayerUrl} + + {config.enableDownloads && } diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index 5788096df..903b2bcb3 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -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) }