Compare commits

...

32 Commits

Author SHA1 Message Date
Sora
245d3b7903
Merge a65947692b911a02db2cc621dd3a6fdfdc124ff5 into 23f3556371321faf199866989b906f2ef06a8034 2026-04-03 11:32:03 +08:00
Sora
a65947692b
Merge branch 'navidrome:master' into master 2026-02-02 17:01:09 +08:00
Sora
db4e338941
Merge branch 'navidrome:master' into master 2026-01-21 08:04:50 +08:00
Sora
0f0a33655b Add fixed bottom player embed option
Introduced a new 'Fixed Bottom Player' embed code and corresponding i18n strings, providing an always-visible player fixed at the bottom of the page similar to MetingJS fixed mode. Also improved CSS transitions and refactored toggle logic for the floating player embed.
2026-01-20 13:26:18 +08:00
Sora
1c1299d9dd Refine APlayer page styling and responsiveness
Updated background colors, border radii, and box shadows for a cleaner look. Improved header and footer styles, adjusted padding, and enhanced mobile responsiveness for better user experience.
2026-01-20 12:49:40 +08:00
Sora
f3d88eb977 Merge branch 'master' of https://github.com/SoraKasvgano/navidrome 2026-01-20 12:04:51 +08:00
Sora
4a5d5dcaf0 Update EmbedCodeField.jsx 2026-01-20 12:04:46 +08:00
Sora
d278258eb7
Merge branch 'master' into master 2026-01-20 11:26:05 +08:00
Sora
d57ed1de85 Add JSON Forms dependencies and update Babel packages
Added @jsonforms/core, @jsonforms/material-renderers, and @jsonforms/react to dependencies. Updated multiple @babel packages and related dependencies to their latest versions in package.json and package-lock.json.
2026-01-20 11:24:26 +08:00
Sora
4f1175a60b Update package-lock.json 2026-01-20 11:21:37 +08:00
Sora
426d28d7bc Allow iframe embedding for APlayer share pages
Sets the X-Frame-Options header to ALLOWALL in handleAPlayer to permit embedding APlayer share pages in iframes.
2026-01-20 11:18:29 +08:00
Sora
301a3e2e03 Update handle_shares.go 2026-01-20 10:25:46 +08:00
Sora
1ddc8ccbf4 Use template.JS for ShareInfo and APlayerScript
Wrap ShareInfo and APlayerScript with template.JS to ensure they are safely injected as JavaScript in templates, preventing potential escaping issues.
2026-01-20 10:20:10 +08:00
Sora
08b5e3bc85 Fix APlayer instance scope in initialization
Refactored APlayer initialization to use a properly scoped variable for the instance, ensuring it is accessible outside the try block. This improves logging and debugging of the APlayer instance after creation.
2026-01-20 10:02:50 +08:00
Sora
e251421fb8 add more debug info for aplayer
add more debug info for aplayer
2026-01-19 15:20:33 +08:00
Sora
20e7500fb8 fix frame
fix frame
2026-01-19 14:52:36 +08:00
Sora
fabd2b9a7a Refactor ShareEdit.jsx for improved readability
Reformatted imports and JSX in ShareEdit.jsx to improve code readability and maintain consistent style. No functional changes were made.
2026-01-19 14:11:42 +08:00
Sora
bb54195955 Refactor share URLs section into accordion in ShareEdit
Replaces the static display of share URLs and embed code with an expandable Accordion component for improved UI organization and user experience in the ShareEdit form.
2026-01-19 13:48:01 +08:00
Sora
1baadd8293 Localize embed code field UI text
Replaced hardcoded Chinese strings in EmbedCodeField.jsx with translation keys and added corresponding English translations to en.json. This improves internationalization and ensures UI text is properly localized.
2026-01-19 13:36:32 +08:00
Sora
2313e4d9ea Localize share URLs and embed code labels
Replaced hardcoded labels for share URLs and embed code in ShareEdit.jsx with localized strings. Added corresponding entries to en.json for improved internationalization support.
2026-01-19 13:29:19 +08:00
Sora
84b6c69593
Merge branch 'navidrome:master' into master 2026-01-19 13:06:06 +08:00
Deluan
2b564074b5 fix(tests): initialize auth in AverageRating tests
The toArtist and toArtistID3 functions call publicurl.ImageURL which
requires auth.TokenAuth to be initialized. Without this, the tests
panic with nil pointer dereference when calling CreatePublicToken.
2026-01-18 21:38:54 -05:00
Deluan
b49d18b18d Revert unrelated formatting changes to package-lock.json 2026-01-18 21:18:15 -05:00
Deluan
e6220d8d0d Merge branch 'master' into fork/SoraKasvgano/master 2026-01-18 21:17:50 -05:00
Sora
c773c279ca add embed code.
# Navidrome 分享播放器嵌入代码使用示例

## 功能说明

在分享详情页(如 `http://127.0.0.1:4533/app/#/share/895AGkthN4`),现在会显示四种嵌入代码选项:

### 1. 左下角悬浮播放器 (推荐)

这是最适合博客和网页的嵌入方式,提供:
- 🎵 可折叠的悬浮按钮
- 📱 响应式设计,支持移动端
- 🎨 美观的渐变样式
- 👆 点击展开/收起播放器
- 🔒 点击外部区域自动收起

**效果预览:**
- 收起状态:左下角显示一个圆形音乐图标按钮
- 展开状态:显示完整的播放器界面(380x520px)

### 2. 基础 iframe

最简单的嵌入方式,适合:
- 固定位置显示
- 快速集成
- 无需额外样式

### 3. 响应式 iframe

自适应布局嵌入,适合:
- 需要响应式设计的页面
- 博客文章内容区域
- 16:9 宽高比显示

### 4. 右下角悬浮播放器

与左下角版本功能相同,但显示在右下角,可根据网页布局选择。

---

## 使用方法

### 步骤 1:创建分享

1. 在 Navidrome 中选择要分享的歌曲、专辑或播放列表
2. 点击"分享"按钮创建分享链接
3. 进入分享详情页

### 步骤 2:获取嵌入代码

1. 在分享详情页向下滚动到"嵌入代码 (Embed Code)"区域
2. 选择需要的嵌入类型(推荐"左下角悬浮播放器")
3. 点击复制按钮复制代码

### 步骤 3:嵌入到网页

将复制的代码粘贴到您的网页 HTML 中,通常在 `</body>` 标签之前。

---

## 代码示例

### 示例 1:博客文章中添加悬浮播放器

```html
<!DOCTYPE html>
<html>
<head>
    <title>我的博客文章</title>
</head>
<body>
    <article>
        <h1>我的音乐分享</h1>
        <p>这是我最喜欢的音乐收藏...</p>
    </article>

    <!-- Navidrome 悬浮播放器 -->
    <!-- 将从 Navidrome 复制的完整代码粘贴在这里 -->
    <div id="navidrome-floating-player">
        ...
    </div>
</body>
</html>
```

### 示例 2:WordPress 博客

在 WordPress 中使用自定义 HTML 块:

1. 添加"自定义 HTML"块
2. 粘贴嵌入代码
3. 发布文章

### 示例 3:个人网站多个页面共享

将嵌入代码添加到网站模板的 footer 中,所有页面都会显示悬浮播放器。

---

## 自定义样式

如果需要自定义悬浮播放器的位置或样式,可以修改嵌入代码中的 CSS:

### 修改位置

```css
/* 修改为右下角 */
#navidrome-floating-player {
  right: 20px;  /* 改为 right */
  bottom: 20px;
}

/* 修改为右上角 */
#navidrome-floating-player {
  right: 20px;
  top: 20px;  /* 改为 top */
}
```

### 修改大小

```css
/* 展开时的大小 */
#nav-player-container.nav-expanded {
  width: 450px;    /* 修改宽度 */
  height: 600px;   /* 修改高度 */
}

/* 按钮大小 */
#nav-player-toggle {
  width: 70px;     /* 修改按钮宽度 */
  height: 70px;    /* 修改按钮高度 */
}
```

### 修改颜色主题

```css
/* 按钮渐变色 */
#nav-player-toggle {
  background: linear-gradient(135deg, #FF6B6B 0%, #4ECDC4 100%);
}
```

---

## 注意事项

1. **跨域问题**:确保 Navidrome 服务器配置允许 iframe 嵌入
2. **HTTPS**:如果您的网站使用 HTTPS,Navidrome 也需要配置 HTTPS
3. **分享过期**:嵌入的播放器依赖分享链接,注意设置合适的过期时间
4. **移动端优化**:悬浮播放器已包含移动端适配,但建议在移动设备上测试效果

---

## 浏览器兼容性

悬浮播放器支持所有现代浏览器:
-  Chrome/Edge (88+)
-  Firefox (85+)
-  Safari (14+)
-  iOS Safari (14+)
-  Android Chrome (88+)

---

## 功能特性

### 悬浮播放器特性

-  平滑展开/收起动画
- 🎯 自动定位到角落
- 🖱️ 悬停放大效果
- 📱 移动端自适应
- 🎨 渐变色设计
- 🔊 完整的 APlayer 功能支持

### APlayer 功能

- 🎵 播放/暂停控制
- ⏭️ 上一首/下一首
- 🔀 随机播放
- 🔁 循环模式
- 🎚️ 音量控制
- 📋 播放列表
- 📥 下载功能(如果启用)

---

## 技术支持

如果遇到问题,可以:
1. 检查浏览器控制台是否有错误
2. 确认 Navidrome 服务器正常运行
3. 验证分享链接是否有效
4. 提交 Issue 到 Navidrome GitHub 仓库
2025-12-18 14:11:32 +08:00
Sora
98983995a3 fix static url
fix static url
2025-12-18 13:42:42 +08:00
Sora
d55b0ed1b5 Format code with goimports and prettier 2025-12-18 11:31:39 +08:00
Sora
8f6fa2c597
Merge branch 'master' into master 2025-12-17 07:24:14 +08:00
Sora
ed43b16628 fix it
# Code Review Fixes - All Applied 

All suggestions from code review have been successfully implemented.

## Fix #1: Simplified File Reading 
**Location**: `server/public/handle_shares.go`

**Before**:
```go
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
    }
}
```

**After**:
```go
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
}
```

**Benefits**: More concise, robust error handling, removed unnecessary buffer variable

---

## Fix #2: Simplified Script Reading 
**Location**: `server/public/handle_shares.go`

**Before**:
```go
scriptContent := make([]byte, 0)
for {
    n, err := scriptData.Read(buf)
    if n > 0 {
        scriptContent = append(scriptContent, buf[:n]...)
    }
    if err != nil {
        break
    }
}
```

**After**:
```go
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
}
```

**Benefits**: Consistent with file reading pattern, better error handling

---

## Fix #3: Removed Redundant Code 
**Location**: `server/public/handle_shares.go`

**Before**:
```go
baseURL := str.SanitizeText(conf.Server.BasePath)
if baseURL == "" {
    baseURL = ""
}
```

**After**:
```go
baseURL := str.SanitizeText(conf.Server.BasePath)
```

**Benefits**: Eliminated no-op code

---

## Fix #4: Fixed Material-UI Link Props 
**Location**: `ui/src/share/ShareEdit.jsx`

**Before**:
```jsx
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
    {url}
</Link>
<Link source="APlayerURL" href={aplayerUrl} target="_blank" rel="noopener noreferrer">
    {aplayerUrl}
</Link>
```

**After**:
```jsx
<Link href={url} target="_blank" rel="noopener noreferrer">
    {url}
</Link>
<Link href={aplayerUrl} target="_blank" rel="noopener noreferrer">
    {aplayerUrl}
</Link>
```

**Benefits**: Removed invalid `source` prop, proper React component usage

---

## Fix #5: Vendored APlayer Assets 
**Location**: Multiple files

**Before**: CDN-hosted assets from `cdn.jsdelivr.net`

**After**: Local vendored assets

**Implementation**:
- Downloaded `APlayer.min.css` and `APlayer.min.js` to `resources/`
- Created `server/public/handle_aplayer_assets.go` with asset handlers
- Added routes `/public/aplayer/APlayer.min.css` and `/public/aplayer/APlayer.min.js`
- Updated `resources/aplayer.html` to reference local URLs
- Assets cached for 1 year

**Benefits**:
- Works in offline/intranet environments
- No external dependencies
- Better privacy (no CDN tracking)
- Faster load times
- Consistent versioning

---

## Fix #6: Buffer Template Rendering 
**Location**: `server/public/handle_shares.go`

**Before**:
```go
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)
}
```

**After**:
```go
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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
```

**Benefits**:
- Prevents partial HTML responses on template errors
- Proper HTTP error response if rendering fails
- More robust error handling
- Clients only receive complete, valid HTML

---

## Build Status

 All fixes applied
 Code compiles successfully
 No errors or warnings
 Ready for production

## Files Modified Summary

1. `server/public/handle_shares.go` - 4 improvements
2. `server/public/handle_aplayer_assets.go` - New file (asset handlers)
3. `server/public/public.go` - Added routes
4. `ui/src/share/ShareEdit.jsx` - Fixed component props
5. `resources/aplayer.html` - Updated to use local assets
6. `resources/APlayer.min.css` - Vendored asset
7. `resources/APlayer.min.js` - Vendored asset

## Code Quality Metrics

- **Readability**: Improved with `io.ReadAll()` usage
- **Robustness**: Better error handling throughout
- **Performance**: Assets cached for 1 year
- **Reliability**: Buffer rendering prevents partial responses
- **Maintainability**: Removed redundant code
- **Standards Compliance**: Fixed React component usage

---

**Status**:  All Code Review Suggestions Implemented
**Last Updated**: 2025-12-16
2025-12-16 09:50:52 +08:00
Sora
c51a0fd81a tidy 2025-12-16 09:40:26 +08:00
Sora
066fc5eac2 Shared url with aplayer 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

### CDN vs. Vendored Assets

** Current Implementation**: APlayer assets are vendored locally and served from the application

- Files are embedded in the Navidrome binary
- No external CDN dependencies
- Works in offline/intranet environments
- Better privacy and performance

For details on the vendoring implementation, see [VENDOR_APLAYER.md](VENDOR_APLAYER.md).

### 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).

# Vendoring APlayer Assets ( COMPLETED)

The APlayer integration now uses locally vendored assets instead of CDN-hosted files. This provides better reliability, offline support, and privacy.

## Implementation Status:  Complete

The following has been implemented:

1.  Asset handlers created (`server/public/handle_aplayer_assets.go`)
2.  Routes added for `/public/aplayer/APlayer.min.css` and `/public/aplayer/APlayer.min.js`
3.  Template updated to use local assets
4.  Files downloaded to `resources/` folder

## Benefits

-  Works in offline/intranet environments
-  No external dependencies
-  Better privacy (no CDN tracking)
-  Consistent versioning
-  Faster load times (no external requests)
-  Assets cached for 1 year for performance

## How It Works

1. APlayer CSS and JS files are stored in `resources/` directory
2. Go's embed.FS automatically embeds them into the binary
3. Public routes serve the files at `/public/aplayer/APlayer.min.css` and `/public/aplayer/APlayer.min.js`
4. The HTML template references these local URLs
5. Browser caches assets for optimal performance

## Files Involved

- `resources/APlayer.min.css` - APlayer stylesheet (12.5 KB)
- `resources/APlayer.min.js` - APlayer library (59.3 KB)
- `server/public/handle_aplayer_assets.go` - Asset serving handlers
- `server/public/public.go` - Route registration
- `resources/aplayer.html` - Template with local asset references
2025-12-16 09:39:32 +08:00
Sora
605902c6c0 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).
2025-12-16 09:05:07 +08:00
14 changed files with 914 additions and 7 deletions

3
resources/APlayer.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
resources/APlayer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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

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

148
resources/aplayer.html Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

2
ui/package-lock.json generated
View File

@ -11285,4 +11285,4 @@
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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