mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
17 Commits
2007f24d29
...
77d149c076
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d149c076 | ||
|
|
131c0c565c | ||
|
|
53ff33866d | ||
|
|
508670ecfb | ||
|
|
c369224597 | ||
|
|
59427e29ab | ||
|
|
3d2f7148e7 | ||
|
|
f0c0e804fa | ||
|
|
4bbcbc17f1 | ||
|
|
6a9ccb309c | ||
|
|
efeaa80de9 | ||
|
|
f54229ea50 | ||
|
|
a82a03feda | ||
|
|
d3c2beabd8 | ||
|
|
e3534fa56b | ||
|
|
b342035884 | ||
|
|
b0e2457e61 |
@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
|
||||
@ -16,6 +16,7 @@ type Data struct {
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
|
||||
@ -83,6 +83,15 @@ nfpms:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- src: release/linux/.package.rpm # contents: "rpm"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: rpm
|
||||
- src: release/linux/.package.deb # contents: "deb"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: deb
|
||||
|
||||
scripts:
|
||||
preinstall: "release/linux/preinstall.sh"
|
||||
postinstall: "release/linux/postinstall.sh"
|
||||
|
||||
1
release/linux/.package.deb
Normal file
1
release/linux/.package.deb
Normal file
@ -0,0 +1 @@
|
||||
deb
|
||||
1
release/linux/.package.rpm
Normal file
1
release/linux/.package.rpm
Normal file
@ -0,0 +1 @@
|
||||
rpm
|
||||
@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
# package type indicator file
|
||||
echo "msi" > "$MSI_OUTPUT_DIR/.package"
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
@ -69,6 +69,12 @@
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
|
||||
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
|
||||
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<InstallUISequence>
|
||||
@ -81,6 +87,7 @@
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
<ComponentRef Id='PackageFile' />
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
||||
|
||||
@ -1,628 +0,0 @@
|
||||
{
|
||||
"languageName": "Tiếng Việt",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Tên bài hát",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"duration": "Thời lượng",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Số lượt phát",
|
||||
"title": "Tên",
|
||||
"artist": "Nghệ sĩ",
|
||||
"album": "Album",
|
||||
"path": "Đường dẫn file",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"size": "Kích thước tệp",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"bitRate": "Số bit",
|
||||
"discSubtitle": "Tiêu đề phụ của đĩa",
|
||||
"starred": "Yêu thích",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"quality": "Chất lượng",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Phát lần cuối",
|
||||
"channels": "Kênh",
|
||||
"createdAt": "Ngày thêm bài hát",
|
||||
"grouping": "Nhóm",
|
||||
"mood": "Tâm trạng",
|
||||
"participants": "Người tham gia bổ sung",
|
||||
"tags": "Tag bổ sung",
|
||||
"mappedTags": "Thẻ đã liên kết",
|
||||
"rawTags": "Thẻ gốc",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Thêm bài hát vào hàng chờ",
|
||||
"playNow": "Phát ",
|
||||
"addToPlaylist": "Thêm vào danh sách",
|
||||
"shuffleAll": "Ngẫu nhiên Tất cả",
|
||||
"download": "Tải bài hát xuống",
|
||||
"playNext": "Phát tiếp theo",
|
||||
"info": "Lấy thông tin bài hát",
|
||||
"showInPlaylist": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Tên album",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"artist": "Nghệ sĩ",
|
||||
"duration": "Thời lượng",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"name": "Tên",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"createdAt": "Ngày thêm album",
|
||||
"size": "Kích cỡ",
|
||||
"originalDate": "Bản gốc",
|
||||
"releaseDate": "Ngày phát hành",
|
||||
"releases": "Bản phát hành |||| Các bản phát hành",
|
||||
"released": "Đã phát hành",
|
||||
"recordLabel": "Hãng đĩa",
|
||||
"catalogNum": "Số Catalog",
|
||||
"releaseType": "Loai",
|
||||
"grouping": "Nhóm",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Phát",
|
||||
"playNext": "Tiếp theo",
|
||||
"addToQueue": "Thêm album vào hàng chờ",
|
||||
"shuffle": "phát ngẫu nhiên",
|
||||
"addToPlaylist": "Thêm vào danh sách phát",
|
||||
"download": "Tải Album xuống",
|
||||
"info": "Lấy thông tin album",
|
||||
"share": "Chia sẻ"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tất cả",
|
||||
"random": "Ngẫu nhiên",
|
||||
"recentlyAdded": "Thêm vào gần đây",
|
||||
"recentlyPlayed": "Đã phát gần đây",
|
||||
"mostPlayed": "Phát nhiều nhất",
|
||||
"starred": "Album Yêu thích",
|
||||
"topRated": "Được đánh giá cao nhất"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Nghệ sĩ",
|
||||
"fields": {
|
||||
"name": "Tên nghệ sĩ",
|
||||
"albumCount": "Số Album",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"rating": "Đánh giá",
|
||||
"genre": "Thể loại",
|
||||
"size": "Kích cỡ",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": "",
|
||||
"maincredit": ""
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "",
|
||||
"radio": "",
|
||||
"topSongs": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Người dùng",
|
||||
"fields": {
|
||||
"userName": "Tên người dùng",
|
||||
"isAdmin": "Quản trị viên",
|
||||
"lastLoginAt": "Lần đăng nhập cuối",
|
||||
"updatedAt": "Cập nhật lúc",
|
||||
"name": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"createdAt": "Tạo vào",
|
||||
"changePassword": "Đổi mật khẩu ?",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Lần truy cập cuối",
|
||||
"libraries": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
|
||||
"libraries": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Tạo bởi user",
|
||||
"updated": "Cập nhật bởi user",
|
||||
"deleted": "Xóa người dùng"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Nhập token của MusicBrainz",
|
||||
"clickHereForToken": "",
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Trình phát |||| Các trình phát",
|
||||
"fields": {
|
||||
"name": "Tên trình phát",
|
||||
"transcodingId": "Mã chuyển mã",
|
||||
"maxBitRate": "Bit Rate cao nhất",
|
||||
"client": "",
|
||||
"userName": "Tên người dùng",
|
||||
"lastSeen": "Lần cuối nhìn thấy",
|
||||
"reportRealPath": "Hiện đường dẫn thực",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Chuyển đổi định dạng",
|
||||
"fields": {
|
||||
"name": "Tên cấu hình chuyển mã",
|
||||
"targetFormat": "Định dạng cuối",
|
||||
"defaultBitRate": "Số Bit mặc định",
|
||||
"command": "Câu lệnh"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Danh sách phát |||| Các danh sách phát",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"duration": "Thời lượng",
|
||||
"ownerName": "Chủ sở hữu",
|
||||
"public": "Công khai",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc",
|
||||
"songCount": "Số bài hát",
|
||||
"comment": "Bình luận",
|
||||
"sync": "Tự động thêm vào",
|
||||
"path": "Nhập từ"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Chọn 1 danh sách phát",
|
||||
"addNewPlaylist": "Tạo \"%{name}\"",
|
||||
"export": "Xuất danh sách phát",
|
||||
"makePublic": "",
|
||||
"makePrivate": "",
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"removeFromSelection": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Thêm các bài hát trùng lặp",
|
||||
"song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
|
||||
"noPlaylistsFound": "",
|
||||
"noPlaylists": ""
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radios",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "URL trang chủ",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Phát ngay"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Chia sẻ |||| Chia sẻ",
|
||||
"fields": {
|
||||
"username": "Chia sẻ bởi",
|
||||
"url": "URL",
|
||||
"description": "Phần mô tả",
|
||||
"contents": "Nội dung",
|
||||
"expiresAt": "Hết hạn",
|
||||
"lastVisitedAt": "Lần mở cuối ",
|
||||
"visitCount": "Lượt ",
|
||||
"format": "Định dạng",
|
||||
"maxBitRate": "Số Bit cao nhất",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào",
|
||||
"downloadable": "Cho phép tải xuống?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
},
|
||||
"empty": ""
|
||||
},
|
||||
"library": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"sections": {
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
},
|
||||
"actions": {
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "Xóa thư viện thành công",
|
||||
"scanStarted": "Bắt đầu quét thư viện",
|
||||
"scanCompleted": "Quét thư viện hoàn tất"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "Đang quét...",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
|
||||
"welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"buttonCreateAdmin": "Tạo quản trị viên",
|
||||
"auth_check_error": "Hãy đăng nhập để tiếp tục",
|
||||
"user_menu": "Profile",
|
||||
"username": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"sign_in": "Đăng nhập",
|
||||
"sign_in_error": "Xác thực thất bại, hãy thử lại",
|
||||
"logout": "Đăng xuất",
|
||||
"insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
|
||||
"passwordDoesNotMatch": "Mật khẩu không đúng",
|
||||
"required": "Yêu cầu",
|
||||
"minLength": "Ít nhất là %{min} ký tự",
|
||||
"maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
|
||||
"minValue": "Ít nhất là %{min}",
|
||||
"maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
|
||||
"number": "Phải là một số",
|
||||
"email": "Phải là một email ",
|
||||
"oneOf": "Phải là một trong các lựa chọn sau: %{options}",
|
||||
"regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
|
||||
"unique": "Phải đặc biệt",
|
||||
"url": "Phải là một URL hợp lệ"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Thêm bộ lọc",
|
||||
"add": "Thêm",
|
||||
"back": "Quay lại",
|
||||
"bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
|
||||
"cancel": "Hủy",
|
||||
"clear_input_value": "Xóa thiết đặt",
|
||||
"clone": "Nhân bản",
|
||||
"confirm": "Xác nhận",
|
||||
"create": "Tạo",
|
||||
"delete": "Xóa",
|
||||
"edit": "Sửa",
|
||||
"export": "Xuất",
|
||||
"list": "Danh sách",
|
||||
"refresh": "Làm mới",
|
||||
"remove_filter": "Bỏ bộ lọc này",
|
||||
"remove": "Gỡ bỏ",
|
||||
"save": "Lưu lại",
|
||||
"search": "Tìm kiếm",
|
||||
"show": "Hiển thị",
|
||||
"sort": "Lọc",
|
||||
"undo": "Hoàn tác",
|
||||
"expand": "Mở rộng",
|
||||
"close": "Đóng",
|
||||
"open_menu": "Mở menu",
|
||||
"close_menu": "Đóng menu",
|
||||
"unselect": "Bỏ chọn",
|
||||
"skip": "Bỏ qua",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Chia sẻ",
|
||||
"download": "Tải xuống"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Có",
|
||||
"false": "Không"
|
||||
},
|
||||
"page": {
|
||||
"create": "Tạo %{name}",
|
||||
"dashboard": "Trang chủ",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Có gì đó không ổn",
|
||||
"list": "%{name}",
|
||||
"loading": "Đang tải",
|
||||
"not_found": "Không tìm thấy",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Chưa có %{name}",
|
||||
"invite": "Bạn muốn thêm vào không ?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Không thể tìm thấy dữ liệu",
|
||||
"many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
|
||||
"single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ẩn mật khẩu",
|
||||
"toggle_hidden": "Hiện mật khẩu"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Giới thiệu",
|
||||
"are_you_sure": "Bạn chắc chứ ?",
|
||||
"bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
|
||||
"bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
|
||||
"delete_content": "Xác nhận xóa ?",
|
||||
"delete_title": "Xóa %{name} #%{id}",
|
||||
"details": "Chi tiết",
|
||||
"error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
|
||||
"invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
|
||||
"loading": "Trang đang được tải, hãy kiên nhận",
|
||||
"no": "Không",
|
||||
"not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
|
||||
"yes": "Có",
|
||||
"unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Không tìm thấy kết quả",
|
||||
"no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
|
||||
"page_out_of_boundaries": "Trang %{page} không hợp lệ",
|
||||
"page_out_from_end": "Bạn đang ở trang cuối rồi",
|
||||
"page_out_from_begin": "Không thể quay về trước trang 1",
|
||||
"page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
|
||||
"page_rows_per_page": "Số mục mỗi trang :",
|
||||
"next": "Tiếp theo",
|
||||
"prev": "Trước",
|
||||
"skip_nav": "Bỏ qua đến nội dung"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
|
||||
"created": "Đã tạo mục mới",
|
||||
"deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
|
||||
"bad_item": "Mục không đúng",
|
||||
"item_doesnt_exist": "Mục không tồn tại",
|
||||
"http_error": "Lỗi kết nối đến máy chủ",
|
||||
"data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
|
||||
"i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
|
||||
"canceled": "Hành động đã bị hủy",
|
||||
"logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
|
||||
"new_version": "Có phiên bản mới! Hãy làm mới trang"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Các cột hiển thị",
|
||||
"layout": "Bố cục",
|
||||
"grid": "Lưới",
|
||||
"table": "Bảng"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Lưu ý",
|
||||
"transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
|
||||
"transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
|
||||
"songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
|
||||
"noPlaylistsAvailable": "Không có danh sách phát",
|
||||
"delete_user_title": "Xóa người dùng '%{name}'",
|
||||
"delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
|
||||
"notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
|
||||
"notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "Mở trong Last.fm",
|
||||
"musicbrainz": "Mở trong MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Đọc thêm...",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
|
||||
"listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
|
||||
"downloadOriginalFormat": "Tải xuống ở định dạng gốc",
|
||||
"shareOriginalFormat": "Chia sẻ ở định dạng gốc",
|
||||
"shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
|
||||
"shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
|
||||
"downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": "",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": "",
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Thư viện",
|
||||
"settings": "Cài đặt",
|
||||
"version": "Phiên bản",
|
||||
"theme": "Theme",
|
||||
"personal": {
|
||||
"name": "Cá nhân hóa",
|
||||
"options": {
|
||||
"theme": "Theme",
|
||||
"language": "Ngôn ngữ",
|
||||
"defaultView": "",
|
||||
"desktop_notifications": "Thông báo trên desktop",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "Chế độ ReplayGain",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Tắt",
|
||||
"album": "Dùng Album Gain",
|
||||
"track": "Dùng Track Gain"
|
||||
},
|
||||
"lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Về",
|
||||
"playlists": "Danh sách phát",
|
||||
"sharedPlaylists": "Danh sách phát được chia sẻ",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Tất cả thư viện (%{count})",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": "Không có"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Danh sách chờ",
|
||||
"openText": "Mở",
|
||||
"closeText": "Thoát",
|
||||
"notContentText": "Không có bài hát",
|
||||
"clickToPlayText": "Nhấp để phát",
|
||||
"clickToPauseText": "Nhấp để tạm dừng",
|
||||
"nextTrackText": "Track tiếp theo",
|
||||
"previousTrackText": "Track trước đó",
|
||||
"reloadText": "Làm mới",
|
||||
"volumeText": "Âm lượng",
|
||||
"toggleLyricText": "Bật lời bài hát",
|
||||
"toggleMiniModeText": "Thu nhỏ",
|
||||
"destroyText": "Xóa",
|
||||
"downloadText": "Tải xuống",
|
||||
"removeAudioListsText": "Xóa danh sách ",
|
||||
"clickToDeleteText": "Nhấp để xóa %{name}",
|
||||
"emptyLyricText": "Không có lời",
|
||||
"playModeText": {
|
||||
"order": "Theo thứ tự",
|
||||
"orderLoop": "Lặp lại",
|
||||
"singleLoop": "Lặp lại một lần",
|
||||
"shufflePlay": "Phát ngẫu nhiên"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Trang chủ",
|
||||
"source": "Mã nguồn",
|
||||
"featureRequests": "Yêu cầu tính năng",
|
||||
"lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
|
||||
"insights": {
|
||||
"disabled": "Đã tắt",
|
||||
"waiting": "Đang chờ"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "",
|
||||
"config": ""
|
||||
},
|
||||
"config": {
|
||||
"configName": "",
|
||||
"environmentVariable": "",
|
||||
"currentValue": "",
|
||||
"configurationFile": "",
|
||||
"exportToml": "",
|
||||
"exportSuccess": "",
|
||||
"exportFailed": "",
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Hoạt động",
|
||||
"totalScanned": "Tổng Folder đã quét",
|
||||
"quickScan": "Quét nhanh",
|
||||
"fullScan": "Quét toàn bộ",
|
||||
"serverUptime": "Server Uptime",
|
||||
"serverDown": "Ngoại tuyến",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Phím tắt của Navidrome",
|
||||
"hotkeys": {
|
||||
"show_help": "Hiện giúp đỡ",
|
||||
"toggle_menu": "Bật thanh phát bên",
|
||||
"toggle_play": "Phát / tạm dừng",
|
||||
"prev_song": "Bài hát trước đó",
|
||||
"next_song": "Bài hát sau đó",
|
||||
"vol_up": "Tăng âm lượng",
|
||||
"vol_down": "Giảm âm lượng",
|
||||
"toggle_love": "Thêm track này vào yêu thích",
|
||||
"current_song": "Đi đến bài hát hiện tại"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
||||
@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
var index *int
|
||||
if len(pq.Items) > 0 {
|
||||
index = &pq.Current
|
||||
}
|
||||
|
||||
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
CurrentIndex: index,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
var err error
|
||||
var currentIndex int
|
||||
|
||||
if len(ids) > 0 {
|
||||
currentIndex, err = p.Int("currentIndex")
|
||||
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(3),
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"openSubsonic": true,
|
||||
"playQueue": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue username="" changedBy=""></playQueue>
|
||||
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
"position": 243,
|
||||
"username": "user1",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": "a_client"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@ -60,6 +60,7 @@ type Subsonic struct {
|
||||
// OpenSubsonic extensions
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@ -439,12 +440,21 @@ type TopSongs struct {
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type PlayQueueByIndex struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
|
||||
@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
|
||||
response.PlayQueue.Username = "user1"
|
||||
response.PlayQueue.Current = "111"
|
||||
response.PlayQueue.Position = 243
|
||||
response.PlayQueue.Changed = &time.Time{}
|
||||
response.PlayQueue.Changed = time.Time{}
|
||||
response.PlayQueue.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlayQueueByIndex", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex = &PlayQueueByIndex{}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex.Username = "user1"
|
||||
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
|
||||
response.PlayQueueByIndex.Position = 243
|
||||
response.PlayQueueByIndex.Changed = time.Time{}
|
||||
response.PlayQueueByIndex.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
response.PlayQueueByIndex.Entry = child
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Shares", func() {
|
||||
BeforeEach(func() {
|
||||
response.Shares = &Shares{}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { ThemeProvider } from '@material-ui/core/styles'
|
||||
import {
|
||||
@ -16,32 +16,28 @@ import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import config from '../config'
|
||||
import useStyle from './styles'
|
||||
import AudioTitle from './AudioTitle'
|
||||
import {
|
||||
clearQueue,
|
||||
currentPlaying,
|
||||
setPlayMode,
|
||||
setVolume,
|
||||
syncQueue,
|
||||
} from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
import { sendNotification } from '../utils'
|
||||
import subsonic from '../subsonic'
|
||||
import locale from './locale'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import keyHandlers from './keyHandlers'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
import { useScrobbling } from './hooks/useScrobbling'
|
||||
import { useReplayGain } from './hooks/useReplayGain'
|
||||
import { usePreloading } from './hooks/usePreloading'
|
||||
import { usePlayerState } from './hooks/usePlayerState'
|
||||
import { useAudioInstance } from './hooks/useAudioInstance'
|
||||
|
||||
/**
|
||||
* Player component for Navidrome music streaming application.
|
||||
* Renders an audio player with scrobbling, replay gain, preloading, and other features.
|
||||
*
|
||||
* @returns {JSX.Element} The rendered Player component.
|
||||
*/
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
const translate = useTranslate()
|
||||
const playerTheme = theme.player?.theme || 'dark'
|
||||
const dataProvider = useDataProvider()
|
||||
const playerState = useSelector((state) => state.player)
|
||||
const dispatch = useDispatch()
|
||||
const [startTime, setStartTime] = useState(null)
|
||||
const [scrobbled, setScrobbled] = useState(false)
|
||||
const [preloaded, setPreload] = useState(false)
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isMobilePlayer =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
@ -49,6 +45,39 @@ const Player = () => {
|
||||
)
|
||||
|
||||
const { authenticated } = useAuthState()
|
||||
const showNotifications = useSelector(
|
||||
(state) => state.settings.notifications || false,
|
||||
)
|
||||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
|
||||
// Custom hooks for separated concerns
|
||||
const {
|
||||
playerState,
|
||||
dispatch,
|
||||
dispatchCurrentPlaying,
|
||||
dispatchSetPlayMode,
|
||||
dispatchSetVolume,
|
||||
dispatchSyncQueue,
|
||||
dispatchClearQueue,
|
||||
} = usePlayerState()
|
||||
|
||||
const {
|
||||
startTime,
|
||||
setStartTime,
|
||||
scrobbled,
|
||||
onAudioProgress,
|
||||
onAudioPlayTrackChange,
|
||||
onAudioEnded,
|
||||
} = useScrobbling(playerState, dispatch, dataProvider)
|
||||
|
||||
const { preloaded, preloadNextSong, resetPreloading } =
|
||||
usePreloading(playerState)
|
||||
|
||||
const { audioInstance, setAudioInstance, onAudioPlay } =
|
||||
useAudioInstance(isMobilePlayer)
|
||||
|
||||
const { context } = useReplayGain(audioInstance, playerState, gainInfo)
|
||||
|
||||
const visible = authenticated && playerState.queue.length > 0
|
||||
const isRadio = playerState.current?.isRadio || false
|
||||
const classes = useStyle({
|
||||
@ -56,44 +85,6 @@ const Player = () => {
|
||||
visible,
|
||||
enableCoverAnimation: config.enableCoverAnimation,
|
||||
})
|
||||
const showNotifications = useSelector(
|
||||
(state) => state.settings.notifications || false,
|
||||
)
|
||||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
context === null &&
|
||||
audioInstance &&
|
||||
config.enableReplayGain &&
|
||||
'AudioContext' in window &&
|
||||
(gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track')
|
||||
) {
|
||||
const ctx = new AudioContext()
|
||||
// we need this to support radios in firefox
|
||||
audioInstance.crossOrigin = 'anonymous'
|
||||
const source = ctx.createMediaElementSource(audioInstance)
|
||||
const gain = ctx.createGain()
|
||||
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
setContext(ctx)
|
||||
setGainNode(gain)
|
||||
}
|
||||
}, [audioInstance, context, gainInfo.gainMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (gainNode) {
|
||||
const current = playerState.current || {}
|
||||
const song = current.song || {}
|
||||
|
||||
const numericGain = calculateGain(gainInfo, song)
|
||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||
}
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
@ -129,140 +120,84 @@ const Player = () => {
|
||||
locale: locale(translate),
|
||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||
}),
|
||||
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
|
||||
[playerTheme, playerState.mode, isDesktop, gainInfo, translate],
|
||||
)
|
||||
|
||||
// Memoize expensive computations
|
||||
const audioLists = useMemo(
|
||||
() => playerState.queue.map((item) => item),
|
||||
[playerState.queue],
|
||||
)
|
||||
|
||||
const currentTrack = playerState.current || {}
|
||||
|
||||
const options = useMemo(() => {
|
||||
const current = playerState.current || {}
|
||||
return {
|
||||
...defaultOptions,
|
||||
audioLists: playerState.queue.map((item) => item),
|
||||
audioLists,
|
||||
playIndex: playerState.playIndex,
|
||||
autoPlay: playerState.clear || playerState.playIndex === 0,
|
||||
clearPriorAudioLists: playerState.clear,
|
||||
extendsContent: (
|
||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
||||
<PlayerToolbar
|
||||
id={currentTrack.trackId}
|
||||
isRadio={currentTrack.isRadio}
|
||||
/>
|
||||
),
|
||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||
showMediaSession: !current.isRadio,
|
||||
showMediaSession: !currentTrack.isRadio,
|
||||
}
|
||||
}, [playerState, defaultOptions, isMobilePlayer])
|
||||
}, [
|
||||
defaultOptions,
|
||||
audioLists,
|
||||
playerState.playIndex,
|
||||
playerState.clear,
|
||||
playerState.volume,
|
||||
isMobilePlayer,
|
||||
currentTrack.trackId,
|
||||
currentTrack.isRadio,
|
||||
])
|
||||
|
||||
const onAudioListsChange = useCallback(
|
||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const nextSong = useCallback(() => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx + 1] : null
|
||||
}, [playerState])
|
||||
|
||||
const onAudioProgress = useCallback(
|
||||
(info) => {
|
||||
if (info.ended) {
|
||||
document.title = 'Navidrome'
|
||||
}
|
||||
|
||||
const progress = (info.currentTime / info.duration) * 100
|
||||
if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (info.isRadio) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!preloaded) {
|
||||
const next = nextSong()
|
||||
if (next != null) {
|
||||
const audio = new Audio()
|
||||
audio.src = next.musicSrc
|
||||
}
|
||||
setPreload(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!scrobbled) {
|
||||
info.trackId && subsonic.scrobble(info.trackId, startTime)
|
||||
setScrobbled(true)
|
||||
}
|
||||
},
|
||||
[startTime, scrobbled, nextSong, preloaded],
|
||||
(_, audioLists, audioInfo) => dispatchSyncQueue(audioInfo, audioLists),
|
||||
[dispatchSyncQueue],
|
||||
)
|
||||
|
||||
const onAudioVolumeChange = useCallback(
|
||||
// sqrt to compensate for the logarithmic volume
|
||||
(volume) => dispatch(setVolume(Math.sqrt(volume))),
|
||||
[dispatch],
|
||||
(volume) => dispatchSetVolume(volume),
|
||||
[dispatchSetVolume],
|
||||
)
|
||||
|
||||
const onAudioPlay = useCallback(
|
||||
const handleAudioPlay = useCallback(
|
||||
(info) => {
|
||||
// Do this to start the context; on chrome-based browsers, the context
|
||||
// will start paused since it is created prior to user interaction
|
||||
if (context && context.state !== 'running') {
|
||||
context.resume()
|
||||
}
|
||||
|
||||
dispatch(currentPlaying(info))
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
if (info.duration) {
|
||||
const song = info.song
|
||||
document.title = `${song.title} - ${song.artist} - Navidrome`
|
||||
if (!info.isRadio) {
|
||||
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
||||
subsonic.nowPlaying(info.trackId, pos)
|
||||
}
|
||||
setPreload(false)
|
||||
if (config.gaTrackingId) {
|
||||
ReactGA.event({
|
||||
category: 'Player',
|
||||
action: 'Play song',
|
||||
label: `${song.title} - ${song.artist}`,
|
||||
})
|
||||
}
|
||||
if (showNotifications) {
|
||||
sendNotification(
|
||||
song.title,
|
||||
`${song.artist} - ${song.album}`,
|
||||
info.cover,
|
||||
)
|
||||
}
|
||||
}
|
||||
onAudioPlay(
|
||||
context,
|
||||
info,
|
||||
(info) => dispatchCurrentPlaying(info),
|
||||
showNotifications,
|
||||
sendNotification,
|
||||
startTime,
|
||||
setStartTime,
|
||||
resetPreloading,
|
||||
config,
|
||||
ReactGA,
|
||||
)
|
||||
},
|
||||
[context, dispatch, showNotifications, startTime],
|
||||
[
|
||||
onAudioPlay,
|
||||
context,
|
||||
dispatchCurrentPlaying,
|
||||
showNotifications,
|
||||
startTime,
|
||||
setStartTime,
|
||||
resetPreloading,
|
||||
],
|
||||
)
|
||||
|
||||
const onAudioPlayTrackChange = useCallback(() => {
|
||||
if (scrobbled) {
|
||||
setScrobbled(false)
|
||||
}
|
||||
if (startTime !== null) {
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [scrobbled, startTime])
|
||||
|
||||
const onAudioPause = useCallback(
|
||||
(info) => dispatch(currentPlaying(info)),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const onAudioEnded = useCallback(
|
||||
(currentPlayId, audioLists, info) => {
|
||||
setScrobbled(false)
|
||||
setStartTime(null)
|
||||
dispatch(currentPlaying(info))
|
||||
dataProvider
|
||||
.getOne('keepalive', { id: info.trackId })
|
||||
// eslint-disable-next-line no-console
|
||||
.catch((e) => console.log('Keepalive error:', e))
|
||||
},
|
||||
[dispatch, dataProvider],
|
||||
(info) => dispatchCurrentPlaying(info),
|
||||
[dispatchCurrentPlaying],
|
||||
)
|
||||
|
||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||
@ -273,10 +208,10 @@ const Player = () => {
|
||||
|
||||
const onBeforeDestroy = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch(clearQueue())
|
||||
dispatchClearQueue()
|
||||
reject()
|
||||
})
|
||||
}, [dispatch])
|
||||
}, [dispatchClearQueue])
|
||||
|
||||
if (!visible) {
|
||||
document.title = 'Navidrome'
|
||||
@ -287,30 +222,32 @@ const Player = () => {
|
||||
[audioInstance, playerState],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobilePlayer && audioInstance) {
|
||||
audioInstance.volume = 1
|
||||
}
|
||||
}, [isMobilePlayer, audioInstance])
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
onAudioVolumeChange={onAudioVolumeChange}
|
||||
onAudioProgress={onAudioProgress}
|
||||
onAudioPlay={onAudioPlay}
|
||||
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
||||
onAudioPause={onAudioPause}
|
||||
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
|
||||
onAudioEnded={onAudioEnded}
|
||||
onCoverClick={onCoverClick}
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
<div role="region" aria-label="Audio Player" aria-live="polite">
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
onAudioVolumeChange={onAudioVolumeChange}
|
||||
onAudioProgress={onAudioProgress}
|
||||
onAudioPlay={handleAudioPlay}
|
||||
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
||||
onAudioPause={onAudioPause}
|
||||
onPlayModeChange={dispatchSetPlayMode}
|
||||
onAudioEnded={onAudioEnded}
|
||||
onCoverClick={onCoverClick}
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
aria-label="Music Player"
|
||||
/>
|
||||
<GlobalHotKeys
|
||||
handlers={handlers}
|
||||
keyMap={keyMap}
|
||||
allowChanges
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
166
ui/src/audioplayer/Player.test.jsx
Normal file
166
ui/src/audioplayer/Player.test.jsx
Normal file
@ -0,0 +1,166 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetOne: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
LoveButton: ({ className, disabled }) => (
|
||||
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||
Love
|
||||
</button>
|
||||
),
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
openSaveQueueDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-hotkeys', () => ({
|
||||
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||
}))
|
||||
|
||||
describe('<PlayerToolbar />', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
const mockDispatch = vi.fn()
|
||||
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Desktop layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
fireEvent.click(saveQueueButton)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||
})
|
||||
|
||||
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
|
||||
// Cleanup and test mobile layout
|
||||
cleanup()
|
||||
useMediaQuery.mockReturnValue(false)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables buttons when id is not provided', () => {
|
||||
render(<PlayerToolbar />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal file
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal file
@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import subsonic from '../../subsonic'
|
||||
|
||||
/**
|
||||
* Custom hook for managing the audio instance and related effects.
|
||||
* Handles audio element setup and mobile volume adjustments.
|
||||
*
|
||||
* @param {boolean} isMobilePlayer - Whether the player is running on a mobile device.
|
||||
* @returns {Object} Audio instance-related state and handlers.
|
||||
* @returns {HTMLAudioElement|null} audioInstance - The audio element instance.
|
||||
* @returns {Function} setAudioInstance - Setter for the audio instance.
|
||||
* @returns {Function} onAudioPlay - Handler for audio play events.
|
||||
*
|
||||
* @example
|
||||
* const { audioInstance, setAudioInstance, onAudioPlay } = useAudioInstance(isMobilePlayer);
|
||||
*/
|
||||
export const useAudioInstance = (isMobilePlayer) => {
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
|
||||
/**
|
||||
* Handles audio play events, resuming context if needed and updating document title.
|
||||
*
|
||||
* @param {AudioContext|null} audioContext - Web Audio API context from replay gain hook.
|
||||
* @param {Object} info - Audio play information.
|
||||
* @param {Object} info.song - Song metadata.
|
||||
* @param {number} info.duration - Track duration.
|
||||
* @param {boolean} info.isRadio - Whether it's a radio stream.
|
||||
* @param {string} info.trackId - Track identifier.
|
||||
* @param {number} info.currentTime - Current playback time.
|
||||
* @param {Function} dispatchCurrentPlaying - Function to dispatch current playing action.
|
||||
* @param {boolean} showNotifications - Whether to show notifications.
|
||||
* @param {Function} sendNotification - Function to send notifications.
|
||||
* @param {number|null} startTime - Start time for scrobbling.
|
||||
* @param {Function} setStartTime - Setter for start time.
|
||||
* @param {Function} resetPreloading - Function to reset preloading.
|
||||
* @param {Object} config - Application configuration.
|
||||
* @param {Object} ReactGA - Google Analytics instance.
|
||||
*/
|
||||
const onAudioPlay = useCallback(
|
||||
(
|
||||
audioContext,
|
||||
info,
|
||||
dispatchCurrentPlaying,
|
||||
showNotifications,
|
||||
sendNotification,
|
||||
startTime,
|
||||
setStartTime,
|
||||
resetPreloading,
|
||||
config,
|
||||
ReactGA,
|
||||
) => {
|
||||
// Resume audio context if suspended
|
||||
if (audioContext && audioContext.state !== 'running') {
|
||||
try {
|
||||
audioContext.resume()
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error resuming audio context:', error)
|
||||
}
|
||||
}
|
||||
|
||||
dispatchCurrentPlaying(info)
|
||||
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
|
||||
if (info.duration) {
|
||||
const song = info.song
|
||||
document.title = `${song.title} - ${song.artist} - Navidrome`
|
||||
|
||||
if (!info.isRadio) {
|
||||
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
||||
try {
|
||||
subsonic.nowPlaying(info.trackId, pos)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error updating now playing:', error)
|
||||
}
|
||||
}
|
||||
|
||||
resetPreloading()
|
||||
|
||||
if (config.gaTrackingId) {
|
||||
try {
|
||||
ReactGA.event({
|
||||
category: 'Player',
|
||||
action: 'Play song',
|
||||
label: `${song.title} - ${song.artist}`,
|
||||
})
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Google Analytics error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (showNotifications) {
|
||||
try {
|
||||
sendNotification(
|
||||
song.title,
|
||||
`${song.artist} - ${song.album}`,
|
||||
info.cover,
|
||||
)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Notification error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Mobile volume adjustment effect
|
||||
useEffect(() => {
|
||||
if (isMobilePlayer && audioInstance) {
|
||||
try {
|
||||
audioInstance.volume = 1
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error setting mobile volume:', error)
|
||||
}
|
||||
}
|
||||
}, [isMobilePlayer, audioInstance])
|
||||
|
||||
return {
|
||||
audioInstance,
|
||||
setAudioInstance,
|
||||
onAudioPlay,
|
||||
}
|
||||
}
|
||||
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal file
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal file
@ -0,0 +1,84 @@
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import {
|
||||
clearQueue,
|
||||
currentPlaying,
|
||||
setPlayMode,
|
||||
setVolume,
|
||||
syncQueue,
|
||||
} from '../../actions'
|
||||
|
||||
/**
|
||||
* Custom hook for managing player state and actions via Redux.
|
||||
* Centralizes access to player-related state and dispatch functions.
|
||||
*
|
||||
* @returns {Object} Player state and action dispatchers.
|
||||
* @returns {Object} playerState - Current player state from Redux store.
|
||||
* @returns {Function} dispatch - Redux dispatch function.
|
||||
* @returns {Function} dispatchCurrentPlaying - Dispatches current playing action.
|
||||
* @returns {Function} dispatchSetPlayMode - Dispatches set play mode action.
|
||||
* @returns {Function} dispatchSetVolume - Dispatches set volume action.
|
||||
* @returns {Function} dispatchSyncQueue - Dispatches sync queue action.
|
||||
* @returns {Function} dispatchClearQueue - Dispatches clear queue action.
|
||||
*
|
||||
* @example
|
||||
* const { playerState, dispatchCurrentPlaying } = usePlayerState();
|
||||
*/
|
||||
export const usePlayerState = () => {
|
||||
const playerState = useSelector((state) => state.player)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
/**
|
||||
* Dispatches the current playing action.
|
||||
*
|
||||
* @param {Object} info - Audio information.
|
||||
*/
|
||||
const dispatchCurrentPlaying = (info) => {
|
||||
dispatch(currentPlaying(info))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the set play mode action.
|
||||
*
|
||||
* @param {string} mode - Play mode (e.g., 'single', 'loop', 'shuffle').
|
||||
*/
|
||||
const dispatchSetPlayMode = (mode) => {
|
||||
dispatch(setPlayMode(mode))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the set volume action with square root compensation.
|
||||
*
|
||||
* @param {number} volume - Volume level (0-1).
|
||||
*/
|
||||
const dispatchSetVolume = (volume) => {
|
||||
// sqrt to compensate for the logarithmic volume
|
||||
dispatch(setVolume(Math.sqrt(volume)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the sync queue action.
|
||||
*
|
||||
* @param {Object} audioInfo - Audio information.
|
||||
* @param {Array} audioLists - List of audio tracks.
|
||||
*/
|
||||
const dispatchSyncQueue = (audioInfo, audioLists) => {
|
||||
dispatch(syncQueue(audioInfo, audioLists))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the clear queue action.
|
||||
*/
|
||||
const dispatchClearQueue = () => {
|
||||
dispatch(clearQueue())
|
||||
}
|
||||
|
||||
return {
|
||||
playerState,
|
||||
dispatch,
|
||||
dispatchCurrentPlaying,
|
||||
dispatchSetPlayMode,
|
||||
dispatchSetVolume,
|
||||
dispatchSyncQueue,
|
||||
dispatchClearQueue,
|
||||
}
|
||||
}
|
||||
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal file
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal file
@ -0,0 +1,106 @@
|
||||
/* eslint-env node */
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { usePlayerState } from './usePlayerState'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest'
|
||||
|
||||
// Mock react-redux
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
useSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock actions
|
||||
vi.mock('../../actions', () => ({
|
||||
clearQueue: vi.fn(() => ({ type: 'CLEAR_QUEUE' })),
|
||||
currentPlaying: vi.fn(() => ({ type: 'CURRENT_PLAYING' })),
|
||||
setPlayMode: vi.fn(() => ({ type: 'SET_PLAY_MODE' })),
|
||||
setVolume: vi.fn(() => ({ type: 'SET_VOLUME' })),
|
||||
syncQueue: vi.fn(() => ({ type: 'SYNC_QUEUE' })),
|
||||
}))
|
||||
|
||||
// Import the mocked actions
|
||||
import * as actions from '../../actions'
|
||||
|
||||
describe('usePlayerState', () => {
|
||||
const mockPlayerState = {
|
||||
queue: [],
|
||||
current: null,
|
||||
mode: 'single',
|
||||
volume: 0.8,
|
||||
}
|
||||
|
||||
const mockDispatch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
useSelector.mockReturnValue(mockPlayerState)
|
||||
})
|
||||
|
||||
it('should return player state and dispatch functions', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
|
||||
expect(result.current.playerState).toEqual(mockPlayerState)
|
||||
expect(typeof result.current.dispatch).toBe('function')
|
||||
expect(typeof result.current.dispatchCurrentPlaying).toBe('function')
|
||||
expect(typeof result.current.dispatchSetPlayMode).toBe('function')
|
||||
expect(typeof result.current.dispatchSetVolume).toBe('function')
|
||||
expect(typeof result.current.dispatchSyncQueue).toBe('function')
|
||||
expect(typeof result.current.dispatchClearQueue).toBe('function')
|
||||
})
|
||||
|
||||
it('should dispatch current playing action', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
const mockInfo = { trackId: 'track1' }
|
||||
|
||||
result.current.dispatchCurrentPlaying(mockInfo)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CURRENT_PLAYING' })
|
||||
})
|
||||
|
||||
it('should dispatch set play mode action', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
|
||||
result.current.dispatchSetPlayMode('loop')
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_PLAY_MODE' })
|
||||
})
|
||||
|
||||
it('should dispatch set volume action with square root compensation', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
|
||||
result.current.dispatchSetVolume(0.5)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_VOLUME' })
|
||||
// Verify square root calculation
|
||||
expect(actions.setVolume).toHaveBeenCalledWith(Math.sqrt(0.5))
|
||||
})
|
||||
|
||||
it('should dispatch sync queue action', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
const mockAudioInfo = { trackId: 'track1' }
|
||||
const mockAudioLists = [{ id: '1' }]
|
||||
|
||||
result.current.dispatchSyncQueue(mockAudioInfo, mockAudioLists)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SYNC_QUEUE' })
|
||||
})
|
||||
|
||||
it('should dispatch clear queue action', () => {
|
||||
const { result } = renderHook(() => usePlayerState())
|
||||
|
||||
result.current.dispatchClearQueue()
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLEAR_QUEUE' })
|
||||
})
|
||||
|
||||
it('should use correct Redux hooks', () => {
|
||||
renderHook(() => usePlayerState())
|
||||
|
||||
expect(useDispatch).toHaveBeenCalled()
|
||||
expect(useSelector).toHaveBeenCalledWith(expect.any(Function))
|
||||
})
|
||||
})
|
||||
72
ui/src/audioplayer/hooks/usePreloading.js
Normal file
72
ui/src/audioplayer/hooks/usePreloading.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Custom hook for managing audio preloading functionality.
|
||||
* Preloads the next song in the queue to improve playback continuity.
|
||||
*
|
||||
* @param {Object} playerState - The current player state from Redux store.
|
||||
* @returns {Object} Preloading-related state and handlers.
|
||||
* @returns {boolean} preloaded - Whether the next song has been preloaded.
|
||||
* @returns {Function} preloadNextSong - Function to preload the next song.
|
||||
* @returns {Function} resetPreloading - Function to reset preloading state.
|
||||
*
|
||||
* @example
|
||||
* const { preloaded, preloadNextSong } = usePreloading(playerState);
|
||||
*/
|
||||
export const usePreloading = (playerState) => {
|
||||
const [preloaded, setPreloaded] = useState(false)
|
||||
|
||||
/**
|
||||
* Finds the next song in the queue.
|
||||
*
|
||||
* @returns {Object|null} The next song object or null if not found.
|
||||
*/
|
||||
const nextSong = useCallback(() => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current?.uuid,
|
||||
)
|
||||
return idx !== -1 ? playerState.queue[idx + 1] : null
|
||||
}, [playerState])
|
||||
|
||||
/**
|
||||
* Preloads the next song by creating an Audio element.
|
||||
* This helps reduce buffering delays during playback.
|
||||
*/
|
||||
const preloadNextSong = useCallback(() => {
|
||||
if (!preloaded) {
|
||||
const next = nextSong()
|
||||
if (next != null) {
|
||||
try {
|
||||
const audio = new Audio()
|
||||
audio.src = next.musicSrc
|
||||
// Optional: Add load event listeners for better control
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
// Preload complete
|
||||
})
|
||||
audio.addEventListener('error', (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Preloading error:', error)
|
||||
})
|
||||
setPreloaded(true)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during preloading:', error)
|
||||
// Continue without preloading
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [preloaded, nextSong])
|
||||
|
||||
/**
|
||||
* Resets the preloading state. Useful for track changes or manual resets.
|
||||
*/
|
||||
const resetPreloading = useCallback(() => {
|
||||
setPreloaded(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
preloaded,
|
||||
preloadNextSong,
|
||||
resetPreloading,
|
||||
}
|
||||
}
|
||||
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal file
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { usePreloading } from './usePreloading'
|
||||
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest'
|
||||
|
||||
describe('usePreloading', () => {
|
||||
const mockPlayerState = {
|
||||
queue: [
|
||||
{ uuid: '1', musicSrc: 'song1.mp3' },
|
||||
{ uuid: '2', musicSrc: 'song2.mp3' },
|
||||
],
|
||||
current: { uuid: '1' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock Audio constructor
|
||||
global.Audio = vi.fn().mockImplementation(function () {
|
||||
this.src = ''
|
||||
this.addEventListener = vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete global.Audio
|
||||
})
|
||||
|
||||
it('should initialize with preloaded false', () => {
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
expect(result.current.preloaded).toBe(false)
|
||||
expect(typeof result.current.preloadNextSong).toBe('function')
|
||||
expect(typeof result.current.resetPreloading).toBe('function')
|
||||
})
|
||||
|
||||
it('should preload next song when called', () => {
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(result.current.preloaded).toBe(true)
|
||||
expect(global.Audio).toHaveBeenCalled()
|
||||
expect(global.Audio.mock.instances[0].src).toBe('song2.mp3')
|
||||
})
|
||||
|
||||
it('should not preload if already preloaded', () => {
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(result.current.preloaded).toBe(true)
|
||||
|
||||
// Call again - should not create new Audio instance
|
||||
const audioCallCount = global.Audio.mock.calls.length
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(global.Audio.mock.calls.length).toBe(audioCallCount)
|
||||
})
|
||||
|
||||
it('should return null when no next song exists', () => {
|
||||
const stateWithNoNext = {
|
||||
queue: [{ uuid: '1', musicSrc: 'song1.mp3' }],
|
||||
current: { uuid: '1' },
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePreloading(stateWithNoNext))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(result.current.preloaded).toBe(false)
|
||||
expect(global.Audio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset preloading state', () => {
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(result.current.preloaded).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.resetPreloading()
|
||||
})
|
||||
|
||||
expect(result.current.preloaded).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle Audio constructor errors gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
global.Audio = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Audio creation failed')
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error during preloading:',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result.current.preloaded).toBe(false) // Should remain false on error
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle audio load errors gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
global.Audio = vi.fn().mockImplementation(function () {
|
||||
this.src = ''
|
||||
this.addEventListener = vi.fn((event, callback) => {
|
||||
if (event === 'error') {
|
||||
callback(new Event('error'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||
|
||||
act(() => {
|
||||
result.current.preloadNextSong()
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Preloading error:',
|
||||
expect.any(Event),
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal file
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal file
@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { calculateGain } from '../../utils/calculateReplayGain'
|
||||
|
||||
/**
|
||||
* Custom hook for managing replay gain functionality using Web Audio API.
|
||||
* Adjusts audio gain based on track or album replay gain metadata.
|
||||
*
|
||||
* @param {Object} audioInstance - The HTML audio element instance.
|
||||
* @param {Object} playerState - The current player state from Redux store.
|
||||
* @param {Object} gainInfo - Replay gain configuration from Redux store.
|
||||
* @returns {Object} Replay gain-related state.
|
||||
* @returns {AudioContext|null} context - Web Audio API context.
|
||||
* @returns {GainNode|null} gainNode - Gain node for audio manipulation.
|
||||
*
|
||||
* @example
|
||||
* const { context, gainNode } = useReplayGain(audioInstance, playerState, gainInfo);
|
||||
*/
|
||||
export const useReplayGain = (audioInstance, playerState, gainInfo) => {
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
context === null &&
|
||||
audioInstance &&
|
||||
'AudioContext' in window &&
|
||||
(gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track')
|
||||
) {
|
||||
try {
|
||||
const ctx = new AudioContext()
|
||||
// Support radios in Firefox
|
||||
if (audioInstance) {
|
||||
audioInstance.crossOrigin = 'anonymous'
|
||||
}
|
||||
const source = ctx.createMediaElementSource(audioInstance)
|
||||
const gain = ctx.createGain()
|
||||
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
setContext(ctx)
|
||||
setGainNode(gain)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'Error initializing Web Audio API for replay gain:',
|
||||
error,
|
||||
)
|
||||
// Fallback: continue without replay gain
|
||||
}
|
||||
}
|
||||
}, [audioInstance, context, gainInfo.gainMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (gainNode && context) {
|
||||
try {
|
||||
const current = playerState.current || {}
|
||||
const song = current.song || {}
|
||||
|
||||
const numericGain = calculateGain(gainInfo, song)
|
||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error applying replay gain:', error)
|
||||
// Continue playback without gain adjustment
|
||||
}
|
||||
}
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
return {
|
||||
context,
|
||||
gainNode,
|
||||
}
|
||||
}
|
||||
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal file
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal file
@ -0,0 +1,160 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { useReplayGain } from './useReplayGain'
|
||||
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest'
|
||||
|
||||
// Mock calculateGain utility
|
||||
vi.mock('../../utils/calculateReplayGain', () => ({
|
||||
calculateGain: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import the mocked module
|
||||
import * as calculateReplayGain from '../../utils/calculateReplayGain'
|
||||
|
||||
describe('useReplayGain', () => {
|
||||
const mockCalculateGain = calculateReplayGain.calculateGain
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock Web Audio API
|
||||
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||
this.createMediaElementSource = vi.fn(() => ({
|
||||
connect: vi.fn(),
|
||||
}))
|
||||
this.createGain = vi.fn(() => ({
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}))
|
||||
this.currentTime = 0
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete global.AudioContext
|
||||
})
|
||||
|
||||
it('should initialize with null context and gainNode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(null, { current: {} }, { gainMode: 'track' }),
|
||||
)
|
||||
|
||||
expect(result.current.context).toBeNull()
|
||||
expect(result.current.gainNode).toBeNull()
|
||||
})
|
||||
|
||||
it('should create audio context when conditions are met', () => {
|
||||
const mockAudioInstance = { crossOrigin: '' }
|
||||
const mockPlayerState = {
|
||||
current: { song: { title: 'Test Song' } },
|
||||
}
|
||||
const mockGainInfo = { gainMode: 'track' }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||
)
|
||||
|
||||
expect(global.AudioContext).toHaveBeenCalled()
|
||||
expect(result.current.context).toBeInstanceOf(AudioContext)
|
||||
})
|
||||
|
||||
it('should apply gain when gainNode exists', () => {
|
||||
const mockAudioInstance = { crossOrigin: '' }
|
||||
const mockPlayerState = {
|
||||
current: { song: { title: 'Test Song' } },
|
||||
}
|
||||
const mockGainInfo = { gainMode: 'track' }
|
||||
|
||||
mockCalculateGain.mockReturnValue(0.8)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||
)
|
||||
|
||||
expect(mockCalculateGain).toHaveBeenCalledWith(
|
||||
mockGainInfo,
|
||||
mockPlayerState.current.song,
|
||||
)
|
||||
expect(result.current.gainNode.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
0.8,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Web Audio API errors gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Mock AudioContext to throw error
|
||||
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||
throw new Error('Web Audio API not supported')
|
||||
})
|
||||
|
||||
const mockAudioInstance = {}
|
||||
const mockPlayerState = { current: {} }
|
||||
const mockGainInfo = { gainMode: 'track' }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error initializing Web Audio API for replay gain:',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result.current.context).toBeNull()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle gain application errors gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const mockAudioInstance = { crossOrigin: '' }
|
||||
const mockPlayerState = {
|
||||
current: { song: { title: 'Test Song' } },
|
||||
}
|
||||
const mockGainInfo = { gainMode: 'track' }
|
||||
|
||||
// Mock gain.setValueAtTime to throw error
|
||||
const mockGainNode = {
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(() => {
|
||||
throw new Error('Gain application failed')
|
||||
}),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||
this.createMediaElementSource = vi.fn(() => ({
|
||||
connect: vi.fn(),
|
||||
}))
|
||||
this.createGain = vi.fn(() => mockGainNode)
|
||||
this.currentTime = 0
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error applying replay gain:',
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not initialize when gainMode is not album or track', () => {
|
||||
const mockAudioInstance = {}
|
||||
const mockPlayerState = { current: {} }
|
||||
const mockGainInfo = { gainMode: 'off' }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||
)
|
||||
|
||||
expect(global.AudioContext).not.toHaveBeenCalled()
|
||||
expect(result.current.context).toBeNull()
|
||||
})
|
||||
})
|
||||
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal file
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal file
@ -0,0 +1,120 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import subsonic from '../../subsonic'
|
||||
|
||||
/**
|
||||
* Custom hook for managing scrobbling functionality in the audio player.
|
||||
* Handles scrobbling state and logic for tracking played songs to external services.
|
||||
*
|
||||
* @param {Object} playerState - The current player state from Redux store.
|
||||
* @param {Function} dispatch - Redux dispatch function.
|
||||
* @param {Object} dataProvider - Data provider for API calls.
|
||||
* @returns {Object} Scrobbling-related state and handlers.
|
||||
* @returns {number|null} startTime - Timestamp when playback started.
|
||||
* @returns {boolean} scrobbled - Whether the current track has been scrobbled.
|
||||
* @returns {Function} onAudioProgress - Handler for audio progress events.
|
||||
* @returns {Function} onAudioPlayTrackChange - Handler for track change events.
|
||||
* @returns {Function} onAudioEnded - Handler for audio ended events.
|
||||
* @returns {Function} resetScrobbling - Function to reset scrobbling state.
|
||||
*
|
||||
* @example
|
||||
* const { startTime, scrobbled, onAudioProgress, onAudioEnded } = useScrobbling(playerState, dispatch, dataProvider);
|
||||
*/
|
||||
export const useScrobbling = (playerState, dispatch, dataProvider) => {
|
||||
const [startTime, setStartTime] = useState(null)
|
||||
const [scrobbled, setScrobbled] = useState(false)
|
||||
|
||||
/**
|
||||
* Handles audio progress events for scrobbling logic.
|
||||
* Scrobbles the track if it has been played for more than 50% or 4 minutes.
|
||||
*
|
||||
* @param {Object} info - Audio progress information.
|
||||
* @param {number} info.currentTime - Current playback time.
|
||||
* @param {number} info.duration - Total duration of the track.
|
||||
* @param {boolean} info.isRadio - Whether the current track is a radio stream.
|
||||
* @param {string} info.trackId - Unique identifier of the track.
|
||||
*/
|
||||
const onAudioProgress = useCallback(
|
||||
(info) => {
|
||||
if (info.ended) {
|
||||
document.title = 'Navidrome'
|
||||
}
|
||||
|
||||
const progress = (info.currentTime / info.duration) * 100
|
||||
if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (info.isRadio) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!scrobbled) {
|
||||
try {
|
||||
if (info.trackId) {
|
||||
subsonic.scrobble(info.trackId, startTime)
|
||||
}
|
||||
setScrobbled(true)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Scrobbling error:', error)
|
||||
// Continue without failing the player
|
||||
}
|
||||
}
|
||||
},
|
||||
[startTime, scrobbled],
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles track change events by resetting scrobbling state.
|
||||
*/
|
||||
const onAudioPlayTrackChange = useCallback(() => {
|
||||
if (scrobbled) {
|
||||
setScrobbled(false)
|
||||
}
|
||||
if (startTime !== null) {
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [scrobbled, startTime])
|
||||
|
||||
/**
|
||||
* Handles audio ended events, resetting state and performing keepalive.
|
||||
*
|
||||
* @param {string} currentPlayId - ID of the current playing track.
|
||||
* @param {Array} audioLists - List of audio tracks.
|
||||
* @param {Object} info - Audio information.
|
||||
*/
|
||||
const onAudioEnded = useCallback(
|
||||
(currentPlayId, audioLists, info) => {
|
||||
setScrobbled(false)
|
||||
setStartTime(null)
|
||||
try {
|
||||
dataProvider
|
||||
.getOne('keepalive', { id: info.trackId })
|
||||
// eslint-disable-next-line no-console
|
||||
.catch((e) => console.log('Keepalive error:', e))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Keepalive error:', error)
|
||||
}
|
||||
},
|
||||
[dataProvider],
|
||||
)
|
||||
|
||||
/**
|
||||
* Resets the scrobbling state. Useful for manual resets or testing.
|
||||
*/
|
||||
const resetScrobbling = useCallback(() => {
|
||||
setScrobbled(false)
|
||||
setStartTime(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
startTime,
|
||||
setStartTime,
|
||||
scrobbled,
|
||||
onAudioProgress,
|
||||
onAudioPlayTrackChange,
|
||||
onAudioEnded,
|
||||
resetScrobbling,
|
||||
}
|
||||
}
|
||||
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal file
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal file
@ -0,0 +1,166 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { useScrobbling } from './useScrobbling'
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest'
|
||||
|
||||
// Mock subsonic module
|
||||
vi.mock('../../subsonic', () => ({
|
||||
default: {
|
||||
scrobble: vi.fn(),
|
||||
nowPlaying: vi.fn(),
|
||||
},
|
||||
scrobble: vi.fn(),
|
||||
nowPlaying: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import the mocked module
|
||||
import * as subsonic from '../../subsonic'
|
||||
|
||||
// Mock dataProvider
|
||||
const mockDataProvider = {
|
||||
getOne: vi.fn(),
|
||||
}
|
||||
|
||||
describe('useScrobbling', () => {
|
||||
const mockPlayerState = {
|
||||
queue: [
|
||||
{ uuid: '1', musicSrc: 'song1.mp3' },
|
||||
{ uuid: '2', musicSrc: 'song2.mp3' },
|
||||
],
|
||||
current: { uuid: '1', trackId: 'track1' },
|
||||
}
|
||||
|
||||
const mockDispatch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDataProvider.getOne.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
expect(result.current.startTime).toBeNull()
|
||||
expect(result.current.scrobbled).toBe(false)
|
||||
expect(typeof result.current.onAudioProgress).toBe('function')
|
||||
expect(typeof result.current.onAudioPlayTrackChange).toBe('function')
|
||||
expect(typeof result.current.onAudioEnded).toBe('function')
|
||||
})
|
||||
|
||||
it('should handle audio progress and scrobble when conditions are met', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
const mockInfo = {
|
||||
currentTime: 300, // 5 minutes
|
||||
duration: 240, // 4 minutes
|
||||
isRadio: false,
|
||||
trackId: 'track1',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.onAudioProgress(mockInfo)
|
||||
})
|
||||
|
||||
// Should scrobble since progress > 50% and time > 4 minutes
|
||||
expect(subsonic.default.scrobble).toHaveBeenCalledWith('track1', null)
|
||||
expect(result.current.scrobbled).toBe(true)
|
||||
})
|
||||
|
||||
it('should not scrobble radio streams', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
const mockInfo = {
|
||||
currentTime: 300,
|
||||
duration: 240,
|
||||
isRadio: true,
|
||||
trackId: 'track1',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.onAudioProgress(mockInfo)
|
||||
})
|
||||
|
||||
expect(subsonic.scrobble).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset scrobbling state on track change', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
// Set initial state
|
||||
act(() => {
|
||||
const mockInfo = {
|
||||
currentTime: 300,
|
||||
duration: 240,
|
||||
isRadio: false,
|
||||
trackId: 'track1',
|
||||
}
|
||||
result.current.onAudioProgress(mockInfo)
|
||||
})
|
||||
|
||||
expect(result.current.scrobbled).toBe(true)
|
||||
|
||||
// Track change should reset
|
||||
act(() => {
|
||||
result.current.onAudioPlayTrackChange()
|
||||
})
|
||||
|
||||
expect(result.current.scrobbled).toBe(false)
|
||||
expect(result.current.startTime).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle audio ended and perform keepalive', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
const mockInfo = { trackId: 'track1' }
|
||||
|
||||
act(() => {
|
||||
result.current.onAudioEnded('playId', [], mockInfo)
|
||||
})
|
||||
|
||||
expect(result.current.scrobbled).toBe(false)
|
||||
expect(result.current.startTime).toBeNull()
|
||||
expect(mockDataProvider.getOne).toHaveBeenCalledWith('keepalive', {
|
||||
id: 'track1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle scrobbling errors gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
// const mockSubsonic = subsonic
|
||||
subsonic.default.scrobble.mockImplementation(() => {
|
||||
throw new Error('Scrobbling failed')
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||
)
|
||||
|
||||
const mockInfo = {
|
||||
currentTime: 300,
|
||||
duration: 240,
|
||||
isRadio: false,
|
||||
trackId: 'track1',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.onAudioProgress(mockInfo)
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Scrobbling error:',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result.current.scrobbled).toBe(false) // Should not set to true on error
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user