96 Commits

Author SHA1 Message Date
b272e26ba1 chore: update .dockerignore to exclude docs and regenerate CHANGELOG with git-cliff
Some checks failed
Lint and Build / lint-and-build (push) Failing after 57s
2026-01-25 01:39:58 +00:00
c0b089647b docs: add CHANGELOG and commit rewriting script 2026-01-25 01:30:56 +00:00
b760465f1c fix: docker startup issue, add GitHub release workflow and changelog config 2026-01-25 01:29:13 +00:00
995f5406e2 fix: use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code 2026-01-25 01:22:54 +00:00
0f719ab3d5 feat: fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists 2026-01-25 01:16:17 +00:00
f77a280e34 feat: add pagination to library/songs and remove listening streaks 2026-01-25 00:46:15 +00:00
da6ffd189c chore: organize documentation - move markdown files to docs/ folder 2026-01-25 00:39:08 +00:00
b5c7fd80cc refactor: simplify service worker by removing offline download functionality 2026-01-25 00:37:42 +00:00
1d013bb9f6 refactor: remove all offline download and caching functionality 2026-01-25 00:35:58 +00:00
6b0e7f73d4 chore: Update version to 2026.01.24 and add changelog for January 2026 release 2026-01-25 00:16:58 +00:00
4a110ebf0f chore: update pnpm-lock.yaml to match new overrides configuration 2026-01-25 00:14:02 +00:00
6b7f0d8db2 chore: remove PostHog analytics and update dependencies to latest minor versions 2026-01-25 00:12:04 +00:00
98b085d8c4 feat: Improve SortableQueueItem component with enhanced click handling and styling 2025-08-16 17:07:18 -05:00
9e7cc703bd feat: Add keyboard shortcuts and queue management features
- Implement global keyboard shortcuts for playback controls, volume adjustments, and navigation.
- Introduce drag-and-drop functionality for queue reordering with visual feedback.
- Add context menus for tracks, albums, and artists with quick action options.
- Develop Spotlight Search feature with Last.fm integration for enhanced music discovery.
- Create GlobalSearchProvider for managing search state and keyboard shortcuts.
- Ensure accessibility and keyboard navigation support across all new features.
2025-08-12 13:09:33 +00:00
d467796b31 feat: Add ListeningStreakCard component for tracking listening streaks
feat: Implement InfiniteScroll component for loading more items on scroll

feat: Create useListeningStreak hook to manage listening streak data and statistics

feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums

feat: Implement background sync service worker for automatic data synchronization
2025-08-11 14:50:57 +00:00
02d60f4805 feat: Move service worker registration to a dedicated component for improved client-side handling 2025-08-11 12:35:50 +00:00
8b5dbbe854 feat: Refactor service worker registration and enhance offline download manager with client-side checks 2025-08-11 12:31:08 +00:00
452af2f6f0 feat: Enhance OfflineManagement component with improved card styling and layout 2025-08-11 05:05:00 +00:00
ba91d3ee28 feat: Implement Auto-Tagging Settings and MusicBrainz integration
- Added AutoTaggingSettings component for configuring auto-tagging preferences.
- Integrated localStorage for saving user preferences and options.
- Developed useAutoTagging hook for fetching and applying metadata from MusicBrainz.
- Created MusicBrainz API client for searching and retrieving music metadata.
- Enhanced metadata structure with additional fields for tracks and albums.
- Implemented rate-limiting for MusicBrainz API requests.
- Added UI components for user interaction and feedback during the tagging process.
2025-08-10 15:02:49 +00:00
cfd4f88b5e feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component 2025-08-10 02:57:55 +00:00
192148adf2 feat: Update cover art retrieval to use higher resolution images and enhance download manager with new features 2025-08-10 02:06:39 +00:00
4b0997c6b4 feat: Enhance UI with Framer Motion animations for album artwork and artist icons 2025-08-08 21:38:58 +00:00
437cb9db28 feat: Add page transition animations and notification settings for audio playback 2025-08-08 21:29:01 +00:00
ba84271d78 feat: Implement offline library synchronization with IndexedDB
- Added `useOfflineLibrarySync` hook for managing offline library sync operations.
- Created `OfflineLibrarySync` component for UI integration.
- Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists.
- Implemented sync operations for starred items, playlists, and scrobbling.
- Added auto-sync functionality when coming back online.
- Included metadata management for sync settings and statistics.
- Enhanced error handling and user feedback through toasts.
2025-08-08 20:04:06 +00:00
f6a6ee5d2e feat: Implement offline library management with IndexedDB support
- Added `useOfflineLibrary` hook for managing offline library state and synchronization.
- Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API.
- Implemented methods for retrieving and storing albums, artists, songs, and playlists.
- Added support for offline favorites management (star/unstar).
- Implemented playlist creation, updating, and deletion functionalities.
- Added search functionality for offline data.
- Created a manifest file for PWA support with icons and shortcuts.
- Added service worker file for caching and offline capabilities.
2025-08-07 22:07:53 +00:00
af5e24b80e style: update README formatting and improve content clarity 2025-08-06 02:15:29 +00:00
f1be95edf2 chore: merge pull request #39 from sillyangel/dependabot/npm_and_yarn/dev-99ea30e4b7
chore(deps-dev): bump the dev group across 1 directory with 2 updates
2025-08-03 09:39:18 -05:00
dependabot[bot]
4e0b187a1f chore: c
b


u
-
-
-

u
-
-
-

-
u
-
 
 
 
 
-
 
 
 
 
.

shore(deps-dev): bump the dev group across 1 directory with 2 updates

umps the dev group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [typescript](https://github.com/microsoft/TypeScript).


pdates `eslint-config-next` from 15.4.4 to 15.4.5
 [Release notes](https://github.com/vercel/next.js/releases)
 [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
 [Commits](https://github.com/vercel/next.js/commits/v15.4.5/packages/eslint-config-next)

pdates `typescript` from 5.8.3 to 5.9.2
 [Release notes](https://github.com/microsoft/TypeScript/releases)
 [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
 [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

--
pdated-dependencies:
 dependency-name: eslint-config-next
 dependency-version: 15.4.5
 dependency-type: direct:development
 update-type: version-update:semver-patch
 dependency-group: dev
 dependency-name: typescript
 dependency-version: 5.9.2
 dependency-type: direct:development
 update-type: version-update:semver-minor
 dependency-group: dev
..

igned-off-by: dependabot[bot] <support@github.com>
2025-08-01 18:32:37 +00:00
9cda875605 Merge pull request #31 from sillyangel/mobile-support
Some checks failed
Release Docker Image / push_to_registry (push) Failing after 7s
YAY
2025-08-01 13:30:28 -05:00
ca92ff717e Merge branch 'dev' into mobile-support 2025-08-01 13:27:49 -05:00
fd7690b725 Add debug logging for audio source handling and API initialization; improve error handling in AudioPlayer and AudioPlayerContext 2025-08-01 18:17:09 +00:00
be2266bf3a Update .env.local with new commit SHA; comment out 'Browse' heading in LibraryPage for future reference 2025-08-01 17:29:43 +00:00
462f04c208 Merge pull request #32 from sillyangel/dependabot/npm_and_yarn/dev-380966b8c2
chore(deps-dev): bump the dev group with 3 updates
2025-07-31 17:30:58 -05:00
71de7884c3 Merge branch 'dev' into dependabot/npm_and_yarn/dev-380966b8c2 2025-07-31 17:30:42 -05:00
346d70c85b Merge pull request #36 from sillyangel/dependabot/npm_and_yarn/hookform/resolvers-5.2.0
chore(deps): bump @hookform/resolvers from 3.10.0 to 5.2.0
2025-07-31 17:30:02 -05:00
4412c38d2e Merge pull request #35 from sillyangel/dependabot/npm_and_yarn/zod-4.0.10
chore(deps): bump zod from 4.0.5 to 4.0.10
2025-07-31 17:29:47 -05:00
d7cf308470 Merge pull request #34 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-progress-1.1.7
chore(deps): bump @radix-ui/react-progress from 1.1.1 to 1.1.7
2025-07-31 17:29:40 -05:00
ee956ba2e4 Merge pull request #33 from sillyangel/dependabot/npm_and_yarn/axios-1.11.0
chore(deps): bump axios from 1.8.2 to 1.11.0
2025-07-31 17:29:30 -05:00
b6fa101816 Merge pull request #37 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-373e2693b3
chore(deps): bump @eslint/plugin-kit from 0.3.3 to 0.3.4 in the npm_and_yarn group
2025-07-31 17:28:56 -05:00
dependabot[bot]
a5fe3be9fd chore(deps): bump @eslint/plugin-kit in the npm_and_yarn group
Bumps the npm_and_yarn group with 1 update: [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit).


Updates `@eslint/plugin-kit` from 0.3.3 to 0.3.4
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.4/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-29 09:37:26 +00:00
0c32c056c6 Refactor FullScreenPlayer layout for improved readability; update button placement for favorites toggle; replace multiple divs with a single heading element; update album preview images 2025-07-28 23:28:46 +00:00
dependabot[bot]
fed1ce4ee8 chore(deps): bump @hookform/resolvers from 3.10.0 to 5.2.0
Bumps [@hookform/resolvers](https://github.com/react-hook-form/resolvers) from 3.10.0 to 5.2.0.
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v3.10.0...v5.2.0)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:32 +00:00
dependabot[bot]
d2a9c72f1f chore(deps): bump zod from 4.0.5 to 4.0.10
Bumps [zod](https://github.com/colinhacks/zod) from 4.0.5 to 4.0.10.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.0.10)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:29 +00:00
dependabot[bot]
843db69df8 chore(deps): bump @radix-ui/react-progress from 1.1.1 to 1.1.7
Bumps [@radix-ui/react-progress](https://github.com/radix-ui/primitives) from 1.1.1 to 1.1.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:11 +00:00
dependabot[bot]
61a19d7914 chore(deps): bump axios from 1.8.2 to 1.11.0
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.11.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.11.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:10 +00:00
dependabot[bot]
4a327b420c chore(deps-dev): bump the dev group with 3 updates
Bumps the dev group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `@types/node` from 24.0.15 to 24.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.31.0 to 9.32.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.31.0...v9.32.0)

Updates `eslint-config-next` from 15.4.2 to 15.4.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.4.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint
  dependency-version: 9.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint-config-next
  dependency-version: 15.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:26:47 +00:00
23d6f48ee1 Update AudioPlayer and FullScreenPlayer for improved mobile audio handling; refactor WhatsNewPopup for better dialog structure; clean up LoginForm by removing unused settings 2025-07-25 22:49:52 +00:00
1dfb86fa15 Refactor code structure for improved readability and maintainability 2025-07-25 18:45:46 +00:00
745f164482 Update UserProfile avatar size; add development-only debug tools in SettingsPage 2025-07-25 17:58:12 +00:00
25e9bd6912 Refactor UI components for improved spacing; add UserProfile component for user info display 2025-07-25 17:35:07 +00:00
74b9648eef Enhance FullScreenPlayer for improved iOS scrolling; add debug tools in SettingsPage for localStorage management 2025-07-25 15:46:38 +00:00
86e198aa24 Comment out year display in album cards for cleaner UI 2025-07-25 14:22:31 +00:00
284bb4b29f Remove star button from AlbumPage; conditionally render PopularSongs section in ArtistPage based on mobile view 2025-07-25 14:05:10 +00:00
f4c01e2d20 Update FullScreenPlayer for improved iOS scrolling compatibility; adjust LibraryPage layout for better spacing and Card component padding 2025-07-25 14:02:01 +00:00
a957398f63 Increase cover art resolution to 300px for improved image quality in AlbumPage and SongRecommendations components 2025-07-25 04:54:07 +00:00
fe40c0264c Refactor SongRecommendations and LibraryPage components for improved mobile responsiveness and code clarity 2025-07-25 04:52:46 +00:00
940ed94579 Update app version and changelog for July End of Month Update; remove unused code in entrypoint script 2025-07-25 04:39:30 +00:00
57c4070bed Implement code changes to enhance functionality and improve performance 2025-07-24 22:45:10 +00:00
a5e8cbf982 update package manager to pnpm@10.13.1 and add pnpm workspace configuration 2025-07-24 22:40:28 +00:00
a50be24cc2 Merge branch 'dev' into mobile-support 2025-07-24 17:32:24 -05:00
7412af6626 Merge pull request #24 from sillyangel/dependabot/npm_and_yarn/zod-4.0.5
chore(deps): bump zod from 3.25.70 to 4.0.5
2025-07-24 17:32:07 -05:00
7184f0f01d Merge pull request #25 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-context-menu-2.2.15
chore(deps): bump @radix-ui/react-context-menu from 2.2.4 to 2.2.15
2025-07-24 17:32:03 -05:00
9b8b0df551 Merge pull request #26 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-separator-1.1.7
chore(deps): bump @radix-ui/react-separator from 1.1.1 to 1.1.7
2025-07-24 17:31:58 -05:00
033a510b77 Merge pull request #27 from sillyangel/dependabot/npm_and_yarn/react-hook-form-7.60.0
chore(deps): bump react-hook-form from 7.54.2 to 7.60.0
2025-07-24 17:31:39 -05:00
d98486a9a6 Merge pull request #29 from sillyangel/dependabot/npm_and_yarn/dev-0a4cb714a2
chore(deps-dev): bump the dev group across 1 directory with 3 updates
2025-07-24 17:31:13 -05:00
d207da743f Merge pull request #30 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-e04d5d616f
chore(deps): bump form-data from 4.0.3 to 4.0.4 in the npm_and_yarn group
2025-07-24 17:31:01 -05:00
c8bc5e80d9 feat: enhance SongRecommendations component for mobile and desktop views, add Apple Touch Icons and viewport settings 2025-07-24 22:03:58 +00:00
abf29caacb feat: implement swipe gesture controls for mobile audio player and enhance theme color handling 2025-07-23 16:10:11 +00:00
abfe2bb3ef feat: optimize cover art URLs for songs and playlists with dynamic sizing 2025-07-23 15:56:27 +00:00
8906b2d81e feat: implement responsive album layout for mobile and desktop views, add debugging configurations for Next.js 2025-07-23 15:37:50 +00:00
3a3c065916 feat: add responsive image size hooks and utility functions for optimal image sizing 2025-07-23 15:23:09 +00:00
fccf3c5d13 feat: enhance FullScreenPlayer with improved lyric scrolling and background styling for mobile 2025-07-23 05:50:01 +00:00
31f8f5dbee feat: add iOS togglefavorite action and enhance mobile player layout with tab navigation 2025-07-23 05:13:06 +00:00
bbdee30f92 fix: update cover art URL size for improved image quality in audio player and playlist 2025-07-23 04:47:47 +00:00
463be90779 feat: enhance mobile audio player with initialization and styling improvements 2025-07-23 04:05:55 +00:00
dependabot[bot]
6953add0c2 chore(deps): bump form-data in the npm_and_yarn group
Bumps the npm_and_yarn group with 1 update: [form-data](https://github.com/form-data/form-data).


Updates `form-data` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 11:10:09 +00:00
dependabot[bot]
599ce057c8 chore(deps-dev): bump the dev group across 1 directory with 3 updates
Bumps the dev group with 3 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `@types/node` from 24.0.10 to 24.0.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.30.1 to 9.31.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.30.1...v9.31.0)

Updates `eslint-config-next` from 15.3.5 to 15.4.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.4.2/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
- dependency-name: eslint
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint-config-next
  dependency-version: 15.4.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 00:20:32 +00:00
a6e65888ab fix: correct image qualities configuration in next.config.mjs 2025-07-20 21:51:32 +00:00
f494ff57d4 fix: update NEXT_PUBLIC_COMMIT_SHA in environment file 2025-07-20 21:24:41 +00:00
ea21150112 Revert "feat: add BottomNavigation and page components"
This reverts commit f4ebfcf478.
2025-07-14 23:16:59 +00:00
f4ebfcf478 feat: add BottomNavigation and page components 2025-07-14 23:15:42 +00:00
dependabot[bot]
a9dd78b5a9 chore(deps): bump @radix-ui/react-separator from 1.1.1 to 1.1.7
Bumps [@radix-ui/react-separator](https://github.com/radix-ui/primitives) from 1.1.1 to 1.1.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:10 +00:00
dependabot[bot]
2c4798f63f chore(deps): bump react-hook-form from 7.54.2 to 7.60.0
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.60.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.60.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.60.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:10 +00:00
dependabot[bot]
69c271313b chore(deps): bump @radix-ui/react-context-menu from 2.2.4 to 2.2.15
Bumps [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives) from 2.2.4 to 2.2.15.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-context-menu"
  dependency-version: 2.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:07 +00:00
dependabot[bot]
67f8458a99 chore(deps): bump zod from 3.25.70 to 4.0.5
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.70 to 4.0.5.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.70...v4.0.5)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:54:18 +00:00
6e4a320097 fix: update mice service image version and port configuration in docker-compose 2025-07-12 18:27:00 +00:00
f09ac2a535 fix: update healthcheck URL for mice service in docker-compose 2025-07-12 18:26:53 +00:00
437640c9a9 feat: implement library page with recent albums and navigation links 2025-07-12 18:20:56 +00:00
3eb16a7b7a fix: adjust z-index values for fullscreen player elements to improve layering 2025-07-11 21:42:32 +00:00
c101ac79eb feat: implement bottom navigation for mobile and enhance audio player with media session support 2025-07-11 21:34:57 +00:00
14d5036e8b feat: enhance mobile experience with responsive audio player and navigation improvements 2025-07-11 20:47:56 +00:00
d8a853401f fix: update example environment variables for mice service in docker-compose 2025-07-11 05:11:36 +00:00
00bd099b26 fix: update mice service image version and comment out sensitive environment variables in docker-compose example 2025-07-11 05:08:11 +00:00
732b3a84c0 Merge pull request #23 from sillynano/dev
remove this worthless piece of junk
2025-07-10 23:27:54 -05:00
Sillynano
caac806baf remove this worthless piece of junk
sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma
lebron
2025-07-10 23:26:14 -05:00
9f3fa3fccb Update release.yml 2025-07-10 19:02:53 -05:00
100 changed files with 12780 additions and 4408 deletions

View File

@@ -22,3 +22,18 @@ build
.turbo .turbo
.github .github
4xnored.png 4xnored.png
# Documentation and non-runtime files
docs/
CHANGELOG.md
cliff.toml
*.md
!README.md
# Docker compose files
docker-compose*.yml
Dockerfile
# Git and backup files
.git*
backup-*

View File

@@ -11,10 +11,6 @@ PORT=3000
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password # NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
POSTHOG_KEY=
POSTHOG_HOST=
# Example for external Navidrome server: # Example for external Navidrome server:
# NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username

View File

@@ -3,15 +3,9 @@ NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# For Docker deployment, use these variable names in your .env file: # For Docker deployment, use these variable names in your .env file:
# NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password # NAVIDROME_PASSWORD=your_password
# POSTHOG_KEY=your_posthog_key
# POSTHOG_HOST=https://us.i.posthog.com
# HOST_PORT=3000 # HOST_PORT=3000
# PORT=3000 # PORT=3000

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=35febc5 NEXT_PUBLIC_COMMIT_SHA=b5fc053

33
.github/workflows/github-release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: GitHub Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
- name: Create Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.content }}
draft: false
prerelease: false

View File

@@ -81,12 +81,6 @@ jobs:
cache-to: | cache-to: |
type=gha,mode=max,scope=deps-only type=gha,mode=max,scope=deps-only
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# - name: Docker Hub Description # - name: Docker Hub Description
# uses: peter-evans/dockerhub-description@v4 # uses: peter-evans/dockerhub-description@v4

5
.gitignore vendored
View File

@@ -70,6 +70,11 @@ next-env.d.ts
# database # database
still-database/ still-database/
# Debug related files
scripts/sleep-debug.js
.vscode/launch.json
source-map-support/
.next/ .next/
certificates certificates
.vercel .vercel

85
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Merow",
"program": "${workspaceFolder}/scripts/sleep-debug.js",
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"trace": true
},
{
"name": "Debug: Next.js Development",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["dev"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "development"
},
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Development (Verbose)",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
},
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Next.js Production",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["start"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "production"
},
"preLaunchTask": "Build: Production Build Only",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "start"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
}
]
}

114
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,114 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Dev: Start Development Server",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "build",
"isDefault": true
},
"isBackground": true,
"problemMatcher": [
"$tsc-watch"
],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development"
}
}
},
{
"label": "Prod: Build and Start Production",
"type": "shell",
"command": "bash",
"args": [
"-c",
"pnpm run build && pnpm run start"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"],
"dependsOrder": "sequence"
},
{
"label": "Debug: Development with Debug Info",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "test",
"isDefault": false
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
}
},
"problemMatcher": ["$tsc-watch"]
},
{
"label": "Build: Production Build Only",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"build"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"]
}
]
}

50
CHANGELOG.md Normal file
View File

@@ -0,0 +1,50 @@
# Changelog
All notable changes to this project will be documented in this file.
## [unreleased]
### Bug Fixes
- Use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code
- Docker startup issue, add GitHub release workflow and changelog config
### Documentation
- Add CHANGELOG and commit rewriting script
### Features
- Implement offline library management with IndexedDB support
- Implement offline library synchronization with IndexedDB
- Add page transition animations and notification settings for audio playback
- Enhance UI with Framer Motion animations for album artwork and artist icons
- Update cover art retrieval to use higher resolution images and enhance download manager with new features
- Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component
- Implement Auto-Tagging Settings and MusicBrainz integration
- Enhance OfflineManagement component with improved card styling and layout
- Refactor service worker registration and enhance offline download manager with client-side checks
- Move service worker registration to a dedicated component for improved client-side handling
- Add ListeningStreakCard component for tracking listening streaks
- Add keyboard shortcuts and queue management features
- Improve SortableQueueItem component with enhanced click handling and styling
- Add pagination to library/songs and remove listening streaks
- Fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists
### Miscellaneous
- C
- Merge pull request #39 from sillyangel/dependabot/npm_and_yarn/dev-99ea30e4b7
- Remove PostHog analytics and update dependencies to latest minor versions
- Update pnpm-lock.yaml to match new overrides configuration
- Update version to 2026.01.24 and add changelog for January 2026 release
- Organize documentation - move markdown files to docs/ folder
### Refactoring
- Remove all offline download and caching functionality
- Simplify service worker by removing offline download functionality
### Styling
- Update README formatting and improve content clarity

View File

@@ -24,13 +24,9 @@ COPY README.md /app/
ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD
ENV NEXT_PUBLIC_POSTHOG_KEY=NEXT_PUBLIC_POSTHOG_KEY ENV NEXT_PUBLIC_COMMIT_SHA=docker-build
ENV NEXT_PUBLIC_POSTHOG_HOST=NEXT_PUBLIC_POSTHOG_HOST
ENV PORT=3000 ENV PORT=3000
# Generate git commit hash for build info (fallback if not available)
RUN echo "NEXT_PUBLIC_COMMIT_SHA=docker-build" > .env.local
# Build the application # Build the application
RUN pnpm build RUN pnpm build

View File

@@ -1,68 +0,0 @@
# GitHub Actions Docker Publishing Setup
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to Docker Hub.
## Workflow Overview
The workflow (`/.github/workflows/publish-docker.yml`) automatically:
1. **Builds** the Docker image using multi-platform support (AMD64 and ARM64)
2. **Publishes** to `sillyangel/mice`
3. **Tags** images appropriately based on git refs
4. **Caches** layers for faster subsequent builds
5. **Generates** build provenance attestations for security
## Trigger Conditions
The workflow runs on:
- **Push to main/master branch** → Creates `latest` tag
- **Push tags** (e.g., `2025.07.02`) → Creates date-based version tags
- **Pull requests** → Creates PR-specific tags for testing
- **Manual dispatch** → Can be triggered manually from GitHub UI
## Image Tags Generated
Based on different triggers, the workflow creates these tags:
### Main Branch Push
- `sillyangel/mice:latest`
### Tag Push (e.g., `2025.07.02`)
- `sillyangel/mice:2025.07.02`
- `sillyangel/mice:latest`
### Pull Request
- `sillyangel/mice:pr-123`
## Multi-Platform Support
The workflow builds for multiple architectures:
- `linux/amd64` (Intel/AMD 64-bit)
- `linux/arm64` (ARM 64-bit, Apple Silicon, etc.)
## Usage After Setup
Once the workflow is set up:
1. **Push to main** → New `latest` image published
2. **Create a release** → Versioned images published
3. **Users can pull**: `docker pull sillyangel/mice:latest`
## Manual Image Building
You can also build and push manually:
```bash
# Build for multiple platforms
docker buildx build --platform linux/amd64,linux/arm64 \
-t sillyangel/mice:latest \
--push .
# Login first (if needed)
echo $DOCKERHUB_TOKEN | docker login -u USERNAME --password-stdin
```

View File

@@ -1,15 +1,13 @@
<p align="left" style="display: flex; align-items: center; gap: 12px;"> <p align="center">
<img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="64" style="border-radius: 12px;" /> <img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="120" />
<strong style="font-size: 2rem;">Mice | Navidrome Client</strong>
</p> </p>
<h1 align="center"><strong>Mice | Navidrome Client</strong></h1>
#
> Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template. > Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template.
<!-- This is a music streaming web application built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/), now powered by **Navidrome** for a complete self-hosted music streaming experience. --> <!-- this looks like "ai" lol but its not -->
This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). It creates a beautiful, responsive music streaming web application that connects to your Navidrome server, and fully able to self-host. This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). It creates a beautiful, responsive music streaming web application that connects to your Navidrome server, and fully able to self-host using docker!
## Features ## Features
@@ -20,7 +18,7 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n
- **Search** - Find music across your entire library - **Search** - Find music across your entire library
- **Audio Player** with queue management - **Audio Player** with queue management
- **Scrobbling** - Track your listening history - **Scrobbling** - Track your listening history
<!-- - **Playlist Management** - Create and manage playlists --> - **Playlist Management** - Create and manage playlists
### Preview ### Preview
![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true) ![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true)
@@ -58,8 +56,6 @@ Next, open the new `.env` file and update it with your Navidrome server credenti
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
``` ```
> **Tip:** If you dont have your own Navidrome server yet, you can use the public demo credentials: > **Tip:** If you dont have your own Navidrome server yet, you can use the public demo credentials:
@@ -117,7 +113,7 @@ docker run -p 3000:3000 \
sillyangel/mice:latest sillyangel/mice:latest
``` ```
📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)** **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
## Tech Stack ## Tech Stack

View File

@@ -10,9 +10,9 @@ import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext' import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
import Loading from "@/app/components/loading"; import Loading from "@/app/components/loading";
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { getNavidromeAPI } from '@/lib/navidrome'; import { getNavidromeAPI } from '@/lib/navidrome';
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums'; import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
import { useIsMobile } from '@/hooks/use-mobile';
export default function AlbumPage() { export default function AlbumPage() {
const { id } = useParams(); const { id } = useParams();
@@ -24,6 +24,7 @@ export default function AlbumPage() {
const { getAlbum, starItem, unstarItem } = useNavidrome(); const { getAlbum, starItem, unstarItem } = useNavidrome();
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer(); const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums(); const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
const isMobile = useIsMobile();
const api = getNavidromeAPI(); const api = getNavidromeAPI();
useEffect(() => { useEffect(() => {
@@ -119,110 +120,163 @@ export default function AlbumPage() {
const seconds = duration % 60; const seconds = duration % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}; };
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api // Dynamic cover art URLs based on image size
? api.getCoverArtUrl(album.coverArt, 300) const getMobileCoverArtUrl = () => {
: '/default-user.jpg'; return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 600)
: '/default-user.jpg';
};
const getDesktopCoverArtUrl = () => {
return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 600)
: '/default-user.jpg';
};
return ( return (
<> <>
<div className="h-full px-4 py-6 lg:px-8"> <div className="h-full px-4 py-6 lg:px-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-6"> {isMobile ? (
<Image /* Mobile Layout */
src={coverArtUrl} <div className="space-y-6">
alt={album.name} {/* Album Cover - Centered */}
width={300} <div className="flex justify-center">
height={300} <Image
className="rounded-md" src={getMobileCoverArtUrl()}
/> alt={album.name}
<div className="space-y-2"> width={600}
<div className="flex items-center space-x-4"> height={600}
<p className="text-3xl font-semibold tracking-tight">{album.name}</p> className="rounded-md shadow-lg"
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}> />
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
</Button>
</div> </div>
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
<Button className="px-5" onClick={() => playAlbum(album.id)}>
Play
</Button>
<div className="text-sm text-muted-foreground">
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
{/* Album Info and Controls */}
<div className="flex justify-between items-start gap-4">
{/* Left side - Album Info */}
<div className="flex-1 space-y-1">
<h1 className="text-2xl font-bold text-left">{album.name}</h1>
<Link href={`/artist/${album.artistId}`}>
<p className="text-lg text-primary underline text-left">{album.artist}</p>
</Link>
<p className="text-sm text-muted-foreground text-left">{album.genre} {album.year}</p>
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
</div> </div>
{/* Right side - Controls */}
<div className="flex flex-col items-center gap-3">
<Button
className="w-12 h-12 rounded-full p-0"
onClick={() => playAlbum(album.id)}
title="Play Album"
>
<Play className="w-6 h-6" />
</Button>
</div>
</div>
</div> </div>
</div> ) : (
/* Desktop Layout */
<div className="flex items-start gap-6">
<Image
src={getDesktopCoverArtUrl()}
alt={album.name}
width={600}
height={600}
className="rounded-md"
/>
<div className="space-y-2">
<div className="flex items-center space-x-4">
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
</Button>
</div>
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
{/* Controls row */}
<div className="flex items-center gap-3">
<Button className="px-5" onClick={() => playAlbum(album.id)}>
Play
</Button>
</div>
{/* Album info */}
<div className="text-sm text-muted-foreground">
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
</div>
</div>
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<Separator /> <Separator />
<ScrollArea className="h-[calc(100vh-500px)]"> {tracklist.length === 0 ? (
{tracklist.length === 0 ? ( <div className="text-center py-12">
<div className="text-center py-12"> <p className="text-muted-foreground">No tracks available.</p>
<p className="text-muted-foreground">No tracks available.</p> </div>
</div> ) : (
) : ( <div className="space-y-1 pb-32">
<div className="space-y-1"> {tracklist.map((song, index) => (
{tracklist.map((song, index) => ( <div
<div key={song.id}
key={song.id} className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`} onClick={() => handlePlayClick(song)}
onClick={() => handlePlayClick(song)} >
> {/* Track Number / Play Indicator */}
{/* Track Number / Play Indicator */} <div className="w-8 text-center text-sm text-muted-foreground mr-3">
<div className="w-8 text-center text-sm text-muted-foreground mr-3"> <>
<> <span className="group-hover:hidden">{song.track || index + 1}</span>
<span className="group-hover:hidden">{song.track || index + 1}</span> <Play className="w-4 h-4 mx-auto hidden group-hover:block" />
<Play className="w-4 h-4 mx-auto hidden group-hover:block" /> </>
</> </div>
</div>
{/* Song Info */} {/* Song Info */}
<div className="flex-1 min-w-0 mr-4"> <div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<p className={`font-semibold truncate ${ <p className={`font-semibold truncate ${
isCurrentlyPlaying(song) ? 'text-primary' : '' isCurrentlyPlaying(song) ? 'text-primary' : ''
}`}> }`}>
{song.title} {song.title}
</p> </p>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<span className="truncate">{song.artist}</span>
</div> </div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<span className="truncate">{song.artist}</span>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleSongStar(song);
}}
className="h-8 w-8 p-0"
>
<Heart
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
/>
</Button>
</div> </div>
</div> </div>
))}
</div> {/* Duration */}
)} <div className="flex items-center text-sm text-muted-foreground mr-4">
</ScrollArea> {formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleSongStar(song);
}}
className="h-8 w-8 p-0"
>
<Heart
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
/>
</Button>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -15,6 +15,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import Loading from '@/app/components/loading'; import Loading from '@/app/components/loading';
import { getNavidromeAPI } from '@/lib/navidrome'; import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useIsMobile } from '@/hooks/use-mobile';
export default function ArtistPage() { export default function ArtistPage() {
const { artist: artistId } = useParams(); const { artist: artistId } = useParams();
@@ -27,6 +28,7 @@ export default function ArtistPage() {
const { getArtist, starItem, unstarItem } = useNavidrome(); const { getArtist, starItem, unstarItem } = useNavidrome();
const { playArtist } = useAudioPlayer(); const { playArtist } = useAudioPlayer();
const { toast } = useToast(); const { toast } = useToast();
const isMobile = useIsMobile();
const api = getNavidromeAPI(); const api = getNavidromeAPI();
useEffect(() => { useEffect(() => {
@@ -103,7 +105,7 @@ export default function ArtistPage() {
} }
// Get artist image URL with proper fallback // Get artist image URL with proper fallback
const artistImageUrl = artist.coverArt && api const artistImageUrl = artist.coverArt && api
? api.getCoverArtUrl(artist.coverArt, 300) ? api.getCoverArtUrl(artist.coverArt, 1200)
: '/default-user.jpg'; : '/default-user.jpg';
return ( return (
@@ -152,7 +154,7 @@ export default function ArtistPage() {
<ArtistBio artistName={artist.name} /> <ArtistBio artistName={artist.name} />
{/* Popular Songs Section */} {/* Popular Songs Section */}
{popularSongs.length > 0 && ( {!isMobile && popularSongs.length > 0 && (
<PopularSongs songs={popularSongs} artistName={artist.name} /> <PopularSongs songs={popularSongs} artistName={artist.name} />
)} )}

View File

@@ -1,90 +1,55 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { AlbumArtwork } from '@/app/components/album-artwork'; import { AlbumArtwork } from '@/app/components/album-artwork';
import { ArtistIcon } from '@/app/components/artist-icon'; import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext'; import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Album } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Shuffle } from 'lucide-react'; import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading';
import {
Shuffle,
ArrowDown,
RefreshCcw,
Loader2
} from 'lucide-react';
import Loading from '@/app/components/loading'; import Loading from '@/app/components/loading';
import { useInView } from 'react-intersection-observer';
export default function BrowsePage() { export default function BrowsePage() {
const { artists, isLoading: contextLoading } = useNavidrome(); const { artists: allArtists, isLoading: contextLoading } = useNavidrome();
// Filter to only show album artists (artists with at least one album)
const artists = allArtists.filter(artist => artist.albumCount && artist.albumCount > 0);
const { shuffleAllAlbums } = useAudioPlayer(); const { shuffleAllAlbums } = useAudioPlayer();
const [albums, setAlbums] = useState<Album[]>([]);
const [currentPage, setCurrentPage] = useState(0); // Use our progressive loading hook
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false); const {
const [hasMoreAlbums, setHasMoreAlbums] = useState(true); albums,
const albumsPerPage = 84; isLoading,
hasMore,
const api = getNavidromeAPI(); loadMoreAlbums,
const loadAlbums = async (page: number, append: boolean = false) => { refreshAlbums
if (!api) { } = useProgressiveAlbumLoading('alphabeticalByName');
console.error('Navidrome API not available');
return; // Infinite scroll with intersection observer
} const { ref, inView } = useInView({
threshold: 0.1,
try { triggerOnce: false
setIsLoadingAlbums(true); });
const offset = page * albumsPerPage;
// Load more albums when the load more sentinel comes into view
// Use alphabeticalByName to get all albums in alphabetical order
const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset);
if (append) {
setAlbums(prev => [...prev, ...newAlbums]);
} else {
setAlbums(newAlbums);
}
// If we got fewer albums than requested, we've reached the end
setHasMoreAlbums(newAlbums.length === albumsPerPage);
} catch (error) {
console.error('Failed to load albums:', error);
} finally {
setIsLoadingAlbums(false);
}
};
useEffect(() => { useEffect(() => {
loadAlbums(0); if (inView && hasMore && !isLoading) {
// eslint-disable-next-line react-hooks/exhaustive-deps loadMoreAlbums();
}, []);
// Infinite scroll handler
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (!target || isLoadingAlbums || !hasMoreAlbums) return;
const { scrollTop, scrollHeight, clientHeight } = target;
const threshold = 200; // Load more when 200px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMore();
}
};
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
if (scrollArea) {
scrollArea.addEventListener('scroll', handleScroll);
return () => scrollArea.removeEventListener('scroll', handleScroll);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [inView, hasMore, isLoading, loadMoreAlbums]);
}, [isLoadingAlbums, hasMoreAlbums, currentPage]);
// Pull-to-refresh simulation
const loadMore = () => { const handleRefresh = useCallback(() => {
if (isLoadingAlbums || !hasMoreAlbums) return; refreshAlbums();
const nextPage = currentPage + 1; }, [refreshAlbums]);
setCurrentPage(nextPage);
loadAlbums(nextPage, true);
};
if (contextLoading) { if (contextLoading) {
return <Loading />; return <Loading />;
@@ -115,12 +80,13 @@ export default function BrowsePage() {
<div className="relative"> <div className="relative">
<ScrollArea> <ScrollArea>
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{artists.map((artist) => ( {artists.map((artist, index) => (
<ArtistIcon <ArtistIcon
key={artist.id} key={artist.id}
artist={artist} artist={artist}
className="shrink-0 overflow-hidden" className="shrink-0 overflow-hidden"
size={190} size={190}
loading={index < 10 ? 'eager' : 'lazy'}
/> />
))} ))}
</div> </div>
@@ -137,13 +103,17 @@ export default function BrowsePage() {
Browse the full collection of albums ({albums.length} loaded). Browse the full collection of albums ({albums.length} loaded).
</p> </p>
</div> </div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCcw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="relative grow"> <div className="relative grow">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8">
{albums.map((album) => ( {albums.map((album, index) => (
<AlbumArtwork <AlbumArtwork
key={album.id} key={album.id}
album={album} album={album}
@@ -151,27 +121,51 @@ export default function BrowsePage() {
aspectRatio="square" aspectRatio="square"
width={200} width={200}
height={200} height={200}
loading={index < 20 ? 'eager' : 'lazy'}
/> />
))} ))}
</div> </div>
{hasMoreAlbums && ( {/* Load more sentinel */}
<div className="flex justify-center p-4 pb-24"> {hasMore && (
<div
ref={ref}
className="flex justify-center p-4 pb-24"
>
<Button <Button
onClick={loadMore} onClick={loadMoreAlbums}
disabled={isLoadingAlbums} disabled={isLoading}
variant="outline" variant="ghost"
className="flex flex-col items-center gap-2"
> >
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`} {isLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<ArrowDown className="h-6 w-6" />
)}
<span className="text-sm">
{isLoading ? 'Loading...' : 'Load More Albums'}
</span>
</Button> </Button>
</div> </div>
)} )}
{!hasMoreAlbums && albums.length > 0 && (
{!hasMore && albums.length > 0 && (
<div className="flex justify-center p-4 pb-24"> <div className="flex justify-center p-4 pb-24">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
All albums loaded ({albums.length} total) All albums loaded ({albums.length} total)
</p> </p>
</div> </div>
)} )}
{albums.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center p-12">
<p className="text-lg font-medium mb-2">No albums found</p>
<p className="text-sm text-muted-foreground mb-4">
Try refreshing or check your connection
</p>
<Button onClick={handleRefresh}>Refresh</Button>
</div>
)}
</div> </div>
<ScrollBar orientation="vertical" /> <ScrollBar orientation="vertical" />
</ScrollArea> </ScrollArea>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Song, Album, Artist } from '@/lib/navidrome'; import { Song } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome'; import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { AudioEffects } from '@/lib/audio-effects';
export interface Track { export interface Track {
id: string; id: string;
@@ -15,8 +16,16 @@ export interface Track {
coverArt?: string; coverArt?: string;
albumId: string; albumId: string;
artistId: string; artistId: string;
autoPlay?: boolean; // Flag to control auto-play autoPlay?: boolean;
starred?: boolean; // Flag for starred/favorited tracks starred?: boolean;
replayGain?: number; // Added ReplayGain field
}
interface AudioSettings {
crossfadeDuration: number;
equalizer: string;
replayGainEnabled: boolean;
gaplessPlayback: boolean;
} }
interface AudioPlayerContextProps { interface AudioPlayerContextProps {
@@ -24,12 +33,14 @@ interface AudioPlayerContextProps {
playTrack: (track: Track, autoPlay?: boolean) => void; playTrack: (track: Track, autoPlay?: boolean) => void;
queue: Track[]; queue: Track[];
addToQueue: (track: Track) => void; addToQueue: (track: Track) => void;
insertAtBeginningOfQueue: (track: Track) => void;
playNextTrack: () => void; playNextTrack: () => void;
clearQueue: () => void; clearQueue: () => void;
addAlbumToQueue: (albumId: string) => Promise<void>; addAlbumToQueue: (albumId: string) => Promise<void>;
playAlbum: (albumId: string) => Promise<void>; playAlbum: (albumId: string) => Promise<void>;
playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>; playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>;
removeTrackFromQueue: (index: number) => void; removeTrackFromQueue: (index: number) => void;
reorderQueue: (oldIndex: number, newIndex: number) => void;
skipToTrackInQueue: (index: number) => void; skipToTrackInQueue: (index: number) => void;
addArtistToQueue: (artistId: string) => Promise<void>; addArtistToQueue: (artistId: string) => Promise<void>;
playPreviousTrack: () => void; playPreviousTrack: () => void;
@@ -42,18 +53,47 @@ interface AudioPlayerContextProps {
clearHistory: () => void; clearHistory: () => void;
toggleCurrentTrackStar: () => Promise<void>; toggleCurrentTrackStar: () => Promise<void>;
updateTrackStarred: (trackId: string, starred: boolean) => void; updateTrackStarred: (trackId: string, starred: boolean) => void;
// Audio settings
audioSettings: AudioSettings;
updateAudioSettings: (settings: Partial<AudioSettings>) => void;
equalizerPreset: string;
setEqualizerPreset: (preset: string) => void;
audioEffects: AudioEffects | null;
// Playback state
isPlaying: boolean;
togglePlayPause: () => Promise<void>;
} }
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined); const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
export // Default audio settings
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
crossfadeDuration: 3,
equalizer: 'normal',
replayGainEnabled: true,
gaplessPlayback: true
};
export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null); const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [queue, setQueue] = useState<Track[]>([]); const [queue, setQueue] = useState<Track[]>([]);
const [playedTracks, setPlayedTracks] = useState<Track[]>([]); const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [shuffle, setShuffle] = useState(false); const [shuffle, setShuffle] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioSettings>(DEFAULT_AUDIO_SETTINGS);
const [equalizerPreset, setEqualizerPreset] = useState('normal');
const [audioEffects, setAudioEffects] = useState<AudioEffects | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const api = useMemo(() => getNavidromeAPI(), []); const api = useMemo(() => {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.warn('⚠️ Navidrome API not configured');
} else {
console.log('✅ Navidrome API initialized');
}
return navidromeApi;
}, []);
useEffect(() => { useEffect(() => {
const savedQueue = localStorage.getItem('navidrome-audioQueue'); const savedQueue = localStorage.getItem('navidrome-audioQueue');
@@ -75,8 +115,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (savedCurrentTrack) { if (savedCurrentTrack) {
try { try {
const track = JSON.parse(savedCurrentTrack); const track = JSON.parse(savedCurrentTrack);
// Clear autoPlay flag when loading from localStorage to prevent auto-play on refresh // Check if there's a saved playback position - if so, user was likely playing
track.autoPlay = false; const savedTime = localStorage.getItem('navidrome-current-track-time');
track.autoPlay = savedTime !== null && parseFloat(savedTime) > 0;
setCurrentTrack(track); setCurrentTrack(track);
} catch (error) { } catch (error) {
console.error('Failed to parse saved current track:', error); console.error('Failed to parse saved current track:', error);
@@ -94,21 +135,93 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
} }
}, [currentTrack]); }, [currentTrack]);
// Initialize audio effects when audio element is available
useEffect(() => {
const audioElement = audioRef.current;
if (audioElement && !audioEffects) {
const effects = new AudioEffects(audioElement);
setAudioEffects(effects);
// Load saved audio settings
const savedSettings = localStorage.getItem('navidrome-audio-settings');
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
setAudioSettings(settings);
effects.setPreset(settings.equalizer);
setEqualizerPreset(settings.equalizer);
} catch (error) {
console.error('Failed to load audio settings:', error);
}
}
return () => {
effects.disconnect();
};
}
}, [audioEffects]);
// Save all audio-related settings
const saveSettings = useCallback(() => {
try {
// Save audio settings
localStorage.setItem('navidrome-audio-settings', JSON.stringify(audioSettings));
// Save equalizer preset
localStorage.setItem('navidrome-equalizer-preset', equalizerPreset);
// Save other playback settings
const playbackSettings = {
replayGainEnabled: audioSettings.replayGainEnabled,
gaplessPlayback: audioSettings.gaplessPlayback,
crossfadeDuration: audioSettings.crossfadeDuration,
volume: audioRef.current?.volume || 1,
lastPosition: audioRef.current?.currentTime || 0
};
localStorage.setItem('navidrome-playback-settings', JSON.stringify(playbackSettings));
} catch (error) {
console.error('Failed to save settings:', error);
}
}, [audioSettings, equalizerPreset]);
// Save settings whenever they change
useEffect(() => {
saveSettings();
}, [audioSettings, equalizerPreset, saveSettings]);
// Update equalizer when preset changes
useEffect(() => {
if (audioEffects) {
audioEffects.setPreset(equalizerPreset);
}
}, [equalizerPreset, audioEffects]);
const updateAudioSettings = useCallback((settings: Partial<AudioSettings>) => {
setAudioSettings(prev => {
const newSettings = { ...prev, ...settings };
localStorage.setItem('navidrome-audio-settings', JSON.stringify(newSettings));
return newSettings;
});
}, []);
const songToTrack = useMemo(() => (song: Song): Track => { const songToTrack = useMemo(() => (song: Song): Track => {
if (!api) { if (!api) {
throw new Error('Navidrome API not configured'); throw new Error('Navidrome API not configured');
} }
const streamUrl = api.getStreamUrl(song.id);
console.log('🎵 Creating track with stream URL:', streamUrl);
return { return {
id: song.id, id: song.id,
name: song.title, name: song.title,
url: api.getStreamUrl(song.id), url: streamUrl,
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred,
replayGain: song.replayGain || 0 // Add ReplayGain support
}; };
}, [api]); }, [api]);
@@ -147,6 +260,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}); });
}, [shuffle]); }, [shuffle]);
const insertAtBeginningOfQueue = useCallback((track: Track) => {
setQueue((prevQueue) => [track, ...prevQueue]);
}, []);
const clearQueue = useCallback(() => { const clearQueue = useCallback(() => {
setQueue([]); setQueue([]);
}, []); }, []);
@@ -155,6 +272,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index)); setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index));
}, []); }, []);
const reorderQueue = useCallback((oldIndex: number, newIndex: number) => {
setQueue((prevQueue) => {
const newQueue = [...prevQueue];
const [movedItem] = newQueue.splice(oldIndex, 1);
newQueue.splice(newIndex, 0, movedItem);
return newQueue;
});
}, []);
const playNextTrack = useCallback(() => { const playNextTrack = useCallback(() => {
// Clear saved timestamp when changing tracks // Clear saved timestamp when changing tracks
localStorage.removeItem('navidrome-current-track-time'); localStorage.removeItem('navidrome-current-track-time');
@@ -561,15 +687,43 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
} }
} }
}, []); }, []);
// Track playback state
const [isPlaying, setIsPlaying] = useState(false);
// Shared playback control function
const togglePlayPause = useCallback(async () => {
const audioElement = audioRef.current;
if (!audioElement || !currentTrack) return;
try {
if (isPlaying) {
audioElement.pause();
setIsPlaying(false);
} else {
await audioElement.play();
setIsPlaying(true);
}
} catch (error) {
console.error('Failed to toggle playback:', error);
toast({
variant: "destructive",
title: "Playback Error",
description: "Failed to control playback. Please try again.",
});
}
}, [currentTrack, isPlaying, toast]);
const contextValue = useMemo(() => ({ const contextValue = useMemo(() => ({
currentTrack, currentTrack,
playTrack, playTrack,
queue, queue,
addToQueue, addToQueue,
insertAtBeginningOfQueue,
playNextTrack, playNextTrack,
clearQueue, clearQueue,
addAlbumToQueue, addAlbumToQueue,
removeTrackFromQueue, removeTrackFromQueue,
reorderQueue,
addArtistToQueue, addArtistToQueue,
playPreviousTrack, playPreviousTrack,
isLoading, isLoading,
@@ -582,6 +736,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playArtist, playArtist,
playedTracks, playedTracks,
clearHistory, clearHistory,
// Audio settings
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects,
// Playback state
isPlaying,
togglePlayPause,
toggleCurrentTrackStar: async () => { toggleCurrentTrackStar: async () => {
if (!currentTrack || !api) { if (!currentTrack || !api) {
toast({ toast({
@@ -656,10 +819,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
isLoading, isLoading,
playTrack, playTrack,
addToQueue, addToQueue,
insertAtBeginningOfQueue,
playNextTrack, playNextTrack,
clearQueue, clearQueue,
addAlbumToQueue, addAlbumToQueue,
removeTrackFromQueue, removeTrackFromQueue,
reorderQueue,
addArtistToQueue, addArtistToQueue,
playPreviousTrack, playPreviousTrack,
playAlbum, playAlbum,
@@ -672,7 +837,14 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playedTracks, playedTracks,
clearHistory, clearHistory,
api, api,
toast toast,
audioEffects,
audioSettings,
equalizerPreset,
updateAudioSettings,
setEqualizerPreset,
isPlaying,
togglePlayPause
]); ]);
return ( return (

View File

@@ -0,0 +1,102 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useAudioPlayer } from "./AudioPlayerContext";
import { presets } from "@/lib/audio-effects";
interface AudioSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AudioSettingsDialog({ isOpen, onClose }: AudioSettingsDialogProps) {
const {
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
} = useAudioPlayer();
const handleCrossfadeChange = (value: number[]) => {
updateAudioSettings({ crossfadeDuration: value[0] });
};
const handleReplayGainToggle = (enabled: boolean) => {
updateAudioSettings({ replayGainEnabled: enabled });
};
const handleGaplessToggle = (enabled: boolean) => {
updateAudioSettings({ gaplessPlayback: enabled });
};
const handleEqualizerPresetChange = (preset: string) => {
setEqualizerPreset(preset);
updateAudioSettings({ equalizer: preset });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Configure playback settings and audio effects
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label>Crossfade Duration ({audioSettings.crossfadeDuration}s)</Label>
<Slider
value={[audioSettings.crossfadeDuration]}
onValueChange={handleCrossfadeChange}
min={0}
max={5}
step={0.5}
/>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<Label>ReplayGain</Label>
<Switch
checked={audioSettings.replayGainEnabled}
onCheckedChange={handleReplayGainToggle}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<Label>Gapless Playback</Label>
<Switch
checked={audioSettings.gaplessPlayback}
onCheckedChange={handleGaplessToggle}
/>
</div>
{/* Equalizer Presets */}
<div className="space-y-2">
<Label>Equalizer Preset</Label>
<div className="grid grid-cols-2 gap-2">
{Object.keys(presets).map((preset) => (
<Button
key={preset}
variant={preset === equalizerPreset ? "default" : "outline"}
onClick={() => handleEqualizerPresetChange(preset)}
className="w-full"
>
{presets[preset].name}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { MusicIcon, TagIcon, InfoIcon } from 'lucide-react';
import { AutoTaggingDialog } from './AutoTaggingDialog';
interface AutoTagContextMenuProps {
children: React.ReactNode;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export function AutoTagContextMenu({
children,
mode,
itemId,
itemName,
artistName
}: AutoTagContextMenuProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem
onClick={() => setIsDialogOpen(true)}
className="cursor-pointer"
>
<TagIcon className="mr-2 h-4 w-4" />
Auto-Tag {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</ContextMenuItem>
{mode === 'track' && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<InfoIcon className="mr-2 h-4 w-4" />
View Track Details
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<MusicIcon className="mr-2 h-4 w-4" />
Edit Track Metadata
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<AutoTaggingDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
mode={mode}
itemId={itemId}
itemName={itemName}
artistName={artistName}
/>
</>
);
}
export default AutoTagContextMenu;

View File

@@ -0,0 +1,319 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/hooks/use-toast";
import { useAutoTagging, EnhancedTrackMetadata, EnhancedAlbumMetadata } from "@/hooks/use-auto-tagging";
import { useIsMobile } from "@/hooks/use-mobile";
import {
MusicIcon,
AlbumIcon,
UsersIcon,
CheckCircle2Icon,
XCircleIcon,
AlertTriangleIcon,
InfoIcon
} from 'lucide-react';
import Image from 'next/image';
interface AutoTaggingDialogProps {
isOpen: boolean;
onClose: () => void;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export const AutoTaggingDialog: React.FC<AutoTaggingDialogProps> = ({
isOpen,
onClose,
mode,
itemId,
itemName,
artistName
}) => {
const isMobile = useIsMobile();
const { toast } = useToast();
const [confidenceThreshold, setConfidenceThreshold] = useState(70);
const [activeTab, setActiveTab] = useState<'tracks' | 'albums'>('tracks');
const [isApplying, setIsApplying] = useState(false);
const {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
} = useAutoTagging();
// Start auto-tagging when the dialog is opened
useEffect(() => {
if (isOpen && itemId && !isProcessing && progress === 0) {
// Wrap in try/catch to handle any errors that might occur during auto-tagging
try {
startAutoTagging(mode, itemId, confidenceThreshold);
} catch (error) {
console.error('Failed to start auto-tagging:', error);
toast({
title: "Auto-Tagging Error",
description: error instanceof Error ? error.message : "Failed to start auto-tagging",
variant: "destructive",
});
onClose();
}
}
}, [isOpen, itemId, mode, isProcessing, progress, startAutoTagging, confidenceThreshold, toast, onClose]);
// Set the active tab based on the mode
useEffect(() => {
if (mode === 'track') {
setActiveTab('tracks');
} else if (mode === 'album' || mode === 'artist') {
setActiveTab('albums');
}
}, [mode]);
const handleApplyMetadata = async () => {
try {
setIsApplying(true);
await applyEnhancedMetadata(
enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold),
enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold)
);
onClose();
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Error",
description: "Failed to apply metadata",
variant: "destructive",
});
} finally {
setIsApplying(false);
}
};
// Get match statistics
const matchedTracks = enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold).length;
const totalTracks = enhancedTracks.length;
const matchedAlbums = enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold).length;
const totalAlbums = enhancedAlbums.length;
const getStatusIcon = (status: 'pending' | 'matched' | 'failed' | 'applied', confidence: number) => {
if (status === 'pending') return <AlertTriangleIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'failed') return <XCircleIcon className="w-4 h-4 text-red-500" />;
if (status === 'matched' && confidence >= confidenceThreshold) return <CheckCircle2Icon className="w-4 h-4 text-green-500" />;
if (status === 'matched' && confidence < confidenceThreshold) return <InfoIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'applied') return <CheckCircle2Icon className="w-4 h-4 text-blue-500" />;
return null;
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'bg-green-500';
if (confidence >= 70) return 'bg-green-400';
if (confidence >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
// Render the appropriate dialog/sheet based on mobile status
const DialogComponent = isMobile ? Sheet : Dialog;
const DialogContentComponent = isMobile ? SheetContent : DialogContent;
const DialogHeaderComponent = isMobile ? SheetHeader : DialogHeader;
const DialogTitleComponent = isMobile ? SheetTitle : DialogTitle;
const DialogDescriptionComponent = isMobile ? SheetDescription : DialogDescription;
return (
<DialogComponent open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContentComponent className={isMobile ? "p-0 pt-8" : "max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"}>
<DialogHeaderComponent className={isMobile ? "p-6 pb-2" : ""}>
<DialogTitleComponent>
Auto-Tagging {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</DialogTitleComponent>
<DialogDescriptionComponent>
{isProcessing ? (
`Analyzing ${mode === 'track' ? 'track' : mode === 'album' ? 'album' : 'artist'} "${itemName}"`
) : (
`Found metadata for ${matchedTracks} of ${totalTracks} tracks${totalAlbums > 0 ? ` and ${matchedAlbums} of ${totalAlbums} albums` : ''}`
)}
</DialogDescriptionComponent>
{/* Progress bar */}
{(isProcessing || isApplying) && (
<div className="my-4">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground mt-2">
{isProcessing ? 'Analyzing metadata...' : 'Applying metadata...'}
</p>
</div>
)}
</DialogHeaderComponent>
{/* Tabs for tracks and albums */}
{!isProcessing && !isApplying && (
<div className={`flex-1 overflow-hidden flex flex-col ${isMobile ? "px-6" : ""}`}>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'tracks' | 'albums')} className="flex-1 flex flex-col">
<div className="flex justify-between items-center mb-2">
<TabsList>
<TabsTrigger value="tracks" disabled={totalTracks === 0}>
<MusicIcon className="w-4 h-4 mr-2" /> Tracks ({matchedTracks}/{totalTracks})
</TabsTrigger>
<TabsTrigger value="albums" disabled={totalAlbums === 0}>
<AlbumIcon className="w-4 h-4 mr-2" /> Albums ({matchedAlbums}/{totalAlbums})
</TabsTrigger>
</TabsList>
{/* Confidence threshold slider */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Min. Confidence: {confidenceThreshold}%</span>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(parseInt(e.target.value))}
className="w-24"
/>
</div>
</div>
{/* Tracks tab content */}
<TabsContent value="tracks" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="rounded-md border">
<div className="bg-muted p-2 grid grid-cols-12 gap-2 text-sm font-medium">
<div className="col-span-1"></div>
<div className="col-span-4">Title</div>
<div className="col-span-3">Artist</div>
<div className="col-span-2">Album</div>
<div className="col-span-2 text-right">Confidence</div>
</div>
<div className="divide-y max-h-[50vh] overflow-auto">
{enhancedTracks.map(track => (
<div key={track.id} className="grid grid-cols-12 gap-2 p-2 items-center">
<div className="col-span-1">
{getStatusIcon(track.status, track.confidence)}
</div>
<div className="col-span-4 truncate">
{track.title}
</div>
<div className="col-span-3 truncate">
{track.artist}
</div>
<div className="col-span-2 truncate">
{track.album}
</div>
<div className="col-span-2 flex justify-end items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(track.confidence)}`}
style={{ width: `${track.confidence}%` }}
/>
</div>
<span className="text-xs">{track.confidence}%</span>
</div>
</div>
))}
</div>
</div>
</TabsContent>
{/* Albums tab content */}
<TabsContent value="albums" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[50vh] overflow-auto p-1">
{enhancedAlbums.map(album => (
<div key={album.id} className="border rounded-lg overflow-hidden">
<div className="flex">
{/* Album cover */}
<div className="relative w-24 h-24">
{album.coverArtUrl ? (
<Image
src={album.coverArtUrl}
alt={album.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<AlbumIcon className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Status badge */}
<div className="absolute top-1 left-1">
{getStatusIcon(album.status, album.confidence)}
</div>
</div>
{/* Album info */}
<div className="flex-1 p-3">
<h4 className="font-medium text-sm truncate">{album.name}</h4>
<p className="text-xs text-muted-foreground truncate">{album.artist}</p>
<div className="mt-2 flex items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(album.confidence)}`}
style={{ width: `${album.confidence}%` }}
/>
</div>
<span className="text-xs">{album.confidence}%</span>
</div>
{album.year && (
<p className="text-xs mt-1">Year: {album.year}</p>
)}
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter className={`${isMobile ? "p-6 pt-4" : "mt-4"}`}>
<div className="w-full flex flex-col md:flex-row justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isProcessing || isApplying}
>
Cancel
</Button>
<Button
onClick={handleApplyMetadata}
disabled={
isProcessing ||
isApplying ||
(matchedTracks === 0 && matchedAlbums === 0)
}
>
Apply Metadata
</Button>
</div>
</DialogFooter>
</DialogContentComponent>
</DialogComponent>
);
};
export default AutoTaggingDialog;

View File

@@ -0,0 +1,221 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { FaTags } from 'react-icons/fa';
import { useToast } from '@/hooks/use-toast';
import { AutoTaggingDialog } from './AutoTaggingDialog';
export const AutoTaggingSettings = () => {
const { toast } = useToast();
const [isClient, setIsClient] = useState(false);
const [autoTaggingEnabled, setAutoTaggingEnabled] = useState(false);
const [autoTagDialogOpen, setAutoTagDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState({
id: '',
name: 'Library',
mode: 'artist' as 'track' | 'album' | 'artist'
});
const [autoTagOptions, setAutoTagOptions] = useState({
rateLimit: 1000, // milliseconds between requests
autoProcess: false,
preferLocalMetadata: true,
tagsToUpdate: ['title', 'artist', 'album', 'year', 'genre'],
});
useEffect(() => {
setIsClient(true);
// Load saved preferences from localStorage
const savedAutoTagging = localStorage.getItem('auto-tagging-enabled');
if (savedAutoTagging !== null) {
setAutoTaggingEnabled(savedAutoTagging === 'true');
}
// Load saved auto-tag options
const savedOptions = localStorage.getItem('auto-tagging-options');
if (savedOptions !== null) {
try {
setAutoTagOptions(JSON.parse(savedOptions));
} catch (error) {
console.error('Failed to parse stored auto-tagging options:', error);
}
}
}, []);
const handleAutoTaggingToggle = (enabled: boolean) => {
setAutoTaggingEnabled(enabled);
if (isClient) {
localStorage.setItem('auto-tagging-enabled', enabled.toString());
}
toast({
title: enabled ? 'Auto-Tagging Enabled' : 'Auto-Tagging Disabled',
description: enabled
? 'Music will be automatically tagged with metadata from MusicBrainz'
: 'Auto-tagging has been disabled',
});
};
const handleOptionsChange = (key: string, value: unknown) => {
setAutoTagOptions(prev => {
const newOptions = { ...prev, [key]: value };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const handleTagSelectionChange = (tag: string, isSelected: boolean) => {
setAutoTagOptions(prev => {
const currentTags = [...prev.tagsToUpdate];
const newTags = isSelected
? [...currentTags, tag]
: currentTags.filter(t => t !== tag);
const newOptions = { ...prev, tagsToUpdate: newTags };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const isTagSelected = (tag: string) => {
return autoTagOptions.tagsToUpdate.includes(tag);
};
return (
<>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaTags className="w-5 h-5" />
Auto-Tagging
</CardTitle>
<CardDescription>
Configure metadata auto-tagging with MusicBrainz
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Enable Auto-Tagging</p>
<p className="text-sm text-muted-foreground">
Automatically fetch and apply metadata from MusicBrainz
</p>
</div>
<Switch
checked={autoTaggingEnabled}
onCheckedChange={handleAutoTaggingToggle}
/>
</div>
{autoTaggingEnabled && (
<>
<div className="space-y-2">
<Label htmlFor="rate-limit">API Rate Limit (ms)</Label>
<Input
id="rate-limit"
type="number"
min={500}
max={5000}
step={100}
value={autoTagOptions.rateLimit}
onChange={(e) => handleOptionsChange('rateLimit', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Time between API requests in milliseconds (min: 500ms)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Auto Process Results</p>
<p className="text-sm text-muted-foreground">
Automatically apply best matches without confirmation
</p>
</div>
<Switch
checked={autoTagOptions.autoProcess}
onCheckedChange={(checked) => handleOptionsChange('autoProcess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Prefer Local Metadata</p>
<p className="text-sm text-muted-foreground">
Keep existing metadata when confidence is low
</p>
</div>
<Switch
checked={autoTagOptions.preferLocalMetadata}
onCheckedChange={(checked) => handleOptionsChange('preferLocalMetadata', checked)}
/>
</div>
<div className="space-y-2">
<Label>Tags to Update</Label>
<div className="grid grid-cols-2 gap-2">
{['title', 'artist', 'album', 'year', 'genre', 'albumArtist', 'trackNumber', 'discNumber'].map(tag => (
<div key={tag} className="flex items-center space-x-2">
<Switch
id={`tag-${tag}`}
checked={isTagSelected(tag)}
onCheckedChange={(checked) => handleTagSelectionChange(tag, checked)}
/>
<Label htmlFor={`tag-${tag}`} className="capitalize">
{tag === 'albumArtist' ? 'Album Artist' :
tag === 'trackNumber' ? 'Track Number' :
tag === 'discNumber' ? 'Disc Number' : tag}
</Label>
</div>
))}
</div>
</div>
<div className="pt-2">
<Button onClick={() => {
// Set selected item to represent the whole library
setSelectedItem({
id: 'library',
name: 'Full Library',
mode: 'artist'
});
setAutoTagDialogOpen(true);
}} variant="outline">
<FaTags className="w-4 h-4 mr-2" />
Open Auto-Tagging Tool
</Button>
</div>
</>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Metadata is fetched from MusicBrainz when you play tracks</li>
<li>Tags can be applied automatically or manually reviewed</li>
<li>Right-click on tracks or albums to tag them manually</li>
<li>MusicBrainz API has rate limits, so don&apos;t set too fast</li>
</ul>
</div>
</CardContent>
</Card>
<AutoTaggingDialog
isOpen={autoTagDialogOpen}
onClose={() => setAutoTagDialogOpen(false)}
mode={selectedItem.mode}
itemId={selectedItem.id}
itemName={selectedItem.name}
/>
</>
);
};

View File

@@ -0,0 +1,89 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSearch } from './GlobalSearchProvider';
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigationItems: NavItem[] = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/library', label: 'Library', icon: Music },
{ href: '/queue', label: 'Queue', icon: List },
];
export function BottomNavigation() {
const router = useRouter();
const pathname = usePathname();
const { openSpotlight } = useGlobalSearch();
const handleNavigation = (href: string) => {
if (href === '/search') {
// Use spotlight search instead of navigating to search page
openSpotlight();
} else {
router.push(href);
}
};
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<div className="fixed bottom-0 left-0 right-0 z-[50] bg-background/95 backdrop-blur-sm border-t border-border">
<div className="flex items-center justify-around px-2 py-2 pb-safe mb-2">
{navigationItems.map((item) => {
const isItemActive = isActive(item.href);
const Icon = item.icon;
return (
<motion.button
key={item.href}
onClick={() => handleNavigation(item.href)}
className={cn(
"flex flex-col items-center justify-center p-2 rounded-lg transition-all duration-200 min-w-[60px] touch-manipulation",
"active:scale-95 active:bg-primary/20",
isItemActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
whileTap={{ scale: 0.95 }}
whileHover={{ y: -1 }}
>
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
<span className={cn(
"text-xs font-medium",
isItemActive ? "text-primary" : "text-muted-foreground"
)}>
{item.label}
</span>
<AnimatePresence>
{isItemActive && (
<motion.div
layoutId="bottom-nav-underline"
className="h-0.5 w-6 bg-primary mt-1 rounded"
initial={{ opacity: 0, scaleX: 0.6 }}
animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0.6 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</motion.button>
);
})}
</div>
</div>
);
}

View File

@@ -1,223 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Database,
Trash2,
RefreshCw,
HardDrive
} from 'lucide-react';
import { CacheManager } from '@/lib/cache';
export function CacheManagement() {
const [cacheStats, setCacheStats] = useState({
total: 0,
expired: 0,
size: '0 B'
});
const [isClearing, setIsClearing] = useState(false);
const [lastCleared, setLastCleared] = useState<string | null>(null);
const loadCacheStats = () => {
if (typeof window === 'undefined') return;
let total = 0;
let expired = 0;
let totalSize = 0;
const now = Date.now();
// Check localStorage for cache entries
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
total++;
const value = localStorage.getItem(key);
if (value) {
totalSize += key.length + value.length;
try {
const parsed = JSON.parse(value);
if (parsed.expiresAt && now > parsed.expiresAt) {
expired++;
}
} catch (error) {
expired++;
}
}
}
}
// Convert bytes to human readable format
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
setCacheStats({
total,
expired,
size: formatSize(totalSize * 2) // *2 for UTF-16 encoding
});
};
useEffect(() => {
loadCacheStats();
// Check if there's a last cleared timestamp
const lastClearedTime = localStorage.getItem('cache-last-cleared');
if (lastClearedTime) {
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
}
}, []);
const handleClearCache = async () => {
setIsClearing(true);
try {
// Clear all cache using the CacheManager
CacheManager.clearAll();
// Also clear any other cache-related localStorage items
if (typeof window !== 'undefined') {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') ||
key.startsWith('navidrome-cache-') ||
key.startsWith('library-cache-') ||
key.includes('album') ||
key.includes('artist') ||
key.includes('song')) {
localStorage.removeItem(key);
}
});
// Set last cleared timestamp
localStorage.setItem('cache-last-cleared', Date.now().toString());
}
// Update stats
loadCacheStats();
setLastCleared(new Date().toLocaleString());
// Show success feedback
setTimeout(() => {
setIsClearing(false);
}, 1000);
} catch (error) {
console.error('Failed to clear cache:', error);
setIsClearing(false);
}
};
const handleCleanExpired = () => {
if (typeof window === 'undefined') return;
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
try {
const value = localStorage.getItem(key);
if (value) {
const parsed = JSON.parse(value);
if (parsed.expiresAt && now > parsed.expiresAt) {
keysToRemove.push(key);
}
}
} catch (error) {
// Invalid cache item, remove it
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
loadCacheStats();
};
return (
<Card className="break-inside-avoid">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Cache Management
</CardTitle>
<CardDescription>
Manage application cache to improve performance and free up storage
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Cache Statistics */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.total}</p>
<p className="text-xs text-muted-foreground">Total Items</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.expired}</p>
<p className="text-xs text-muted-foreground">Expired</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.size}</p>
<p className="text-xs text-muted-foreground">Storage Used</p>
</div>
</div>
{/* Cache Actions */}
<div className="space-y-2">
<div className="flex gap-2">
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
size="sm"
className="flex-1"
>
{isClearing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isClearing ? 'Clearing...' : 'Clear All Cache'}
</Button>
<Button
onClick={handleCleanExpired}
variant="outline"
size="sm"
className="flex-1"
>
<HardDrive className="h-4 w-4 mr-2" />
Clean Expired
</Button>
</div>
<Button
onClick={loadCacheStats}
variant="ghost"
size="sm"
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Stats
</Button>
</div>
{/* Cache Info */}
<div className="text-sm text-muted-foreground space-y-1">
<p>Cache includes albums, artists, songs, and image URLs to improve loading times.</p>
{lastCleared && (
<p>Last cleared: {lastCleared}</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Play,
Plus,
ListMusic,
Heart,
SkipForward,
UserIcon,
Disc3,
Star,
Share,
Info
} from 'lucide-react';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Track } from '@/app/components/AudioPlayerContext';
interface TrackContextMenuProps {
children: React.ReactNode;
track: Track;
showPlayOptions?: boolean;
showQueueOptions?: boolean;
showFavoriteOption?: boolean;
showAlbumArtistOptions?: boolean;
}
export function TrackContextMenu({
children,
track,
showPlayOptions = true,
showQueueOptions = true,
showFavoriteOption = true,
showAlbumArtistOptions = true
}: TrackContextMenuProps) {
const {
playTrack,
addToQueue,
insertAtBeginningOfQueue,
toggleCurrentTrackStar,
currentTrack,
queue
} = useAudioPlayer();
const handlePlayTrack = () => {
playTrack(track, true);
};
const handleAddToQueue = () => {
addToQueue(track);
};
const handlePlayNext = () => {
// Add track to the beginning of the queue to play next
insertAtBeginningOfQueue(track);
};
const handleToggleFavorite = () => {
if (currentTrack?.id === track.id) {
toggleCurrentTrackStar();
}
// For non-current tracks, we'd need a separate function to toggle favorites
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
{showPlayOptions && (
<>
<ContextMenuItem onClick={handlePlayTrack} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Now
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showQueueOptions && (
<>
<ContextMenuItem onClick={handlePlayNext} className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Next
</ContextMenuItem>
<ContextMenuItem onClick={handleAddToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add to Queue
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showFavoriteOption && (
<>
<ContextMenuItem onClick={handleToggleFavorite} className="cursor-pointer">
<Heart className={`mr-2 h-4 w-4 ${track.starred ? 'fill-current text-red-500' : ''}`} />
{track.starred ? 'Remove from Favorites' : 'Add to Favorites'}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showAlbumArtistOptions && (
<>
<ContextMenuItem className="cursor-pointer">
<Disc3 className="mr-2 h-4 w-4" />
Go to Album
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Track Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Additional context menus for albums and artists
interface AlbumContextMenuProps {
children: React.ReactNode;
albumId: string;
albumName: string;
}
export function AlbumContextMenu({
children,
albumId,
albumName
}: AlbumContextMenuProps) {
const { playAlbum, addAlbumToQueue } = useAudioPlayer();
const handlePlayAlbum = () => {
playAlbum(albumId);
};
const handleAddAlbumToQueue = () => {
addAlbumToQueue(albumId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayAlbum} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Album
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddAlbumToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add Album to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Album Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Album Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Album
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
interface ArtistContextMenuProps {
children: React.ReactNode;
artistId: string;
artistName: string;
}
export function ArtistContextMenu({
children,
artistId,
artistName
}: ArtistContextMenuProps) {
const { playArtist, addArtistToQueue } = useAudioPlayer();
const handlePlayArtist = () => {
playArtist(artistId);
};
const handleAddArtistToQueue = () => {
addArtistToQueue(artistId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayArtist} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play All Songs
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddArtistToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add All to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play All Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Artist Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Artist
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,478 @@
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import { motion, PanInfo, AnimatePresence } from 'framer-motion';
import { useAudioPlayer, Track } from './AudioPlayerContext';
import { FaPlay, FaPause, FaExpand, FaForward, FaBackward, FaVolumeHigh, FaVolumeXmark } from 'react-icons/fa6';
import { Heart } from 'lucide-react';
import { constrain } from '@/lib/utils';
import { Progress } from '@/components/ui/progress';
import { extractDominantColor } from '@/lib/image-utils';
interface DraggableMiniPlayerProps {
onExpand: () => void;
}
export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpand }) => {
const {
currentTrack,
playPreviousTrack,
playNextTrack,
toggleCurrentTrackStar,
isPlaying,
togglePlayPause
} = useAudioPlayer();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dominantColor, setDominantColor] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [volume, setVolume] = useState(1);
const [clickCount, setClickCount] = useState(0);
const [clickTimer, setClickTimer] = useState<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({ x: 0, y: 0 });
// Save position to localStorage when it changes
useEffect(() => {
if (!isDragging) {
localStorage.setItem('mini-player-position', JSON.stringify(position));
}
}, [position, isDragging]);
// Extract dominant color from album art
useEffect(() => {
if (!currentTrack?.coverArt) {
setDominantColor(null);
return;
}
extractDominantColor(currentTrack.coverArt)
.then(color => setDominantColor(color))
.catch(error => {
console.error('Failed to extract color:', error);
setDominantColor(null);
});
}, [currentTrack?.coverArt]);
// Track progress from main audio player
useEffect(() => {
const updateProgress = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement && audioElement.duration) {
setProgress((audioElement.currentTime / audioElement.duration) * 100);
}
};
const updateVolume = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement) {
setVolume(audioElement.volume);
}
};
const interval = setInterval(updateProgress, 250);
updateVolume(); // Initial volume
// Set up event listener for volume changes
const audioElement = document.querySelector('audio');
if (audioElement) {
audioElement.addEventListener('volumechange', updateVolume);
}
return () => {
clearInterval(interval);
if (audioElement) {
audioElement.removeEventListener('volumechange', updateVolume);
}
};
}, [currentTrack]);
// Detect double clicks for expanding
const handleContainerClick = useCallback(() => {
setClickCount(prev => prev + 1);
if (clickTimer) {
clearTimeout(clickTimer);
}
const timer = setTimeout(() => {
// If single click, do nothing
if (clickCount === 0) {
// Nothing
}
// If double click, expand
else if (clickCount === 1) {
onExpand();
}
setClickCount(0);
}, 300);
setClickTimer(timer as unknown as NodeJS.Timeout);
}, [clickCount, clickTimer, onExpand]);
// Handle seeking in track
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percent = clickX / rect.width;
audioElement.currentTime = percent * audioElement.duration;
};
// Handle volume change
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const newVolume = parseFloat(e.target.value);
audioElement.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch (error) {
console.error('Failed to save volume:', error);
}
};
// Keyboard controls for the mini player
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard shortcuts if the mini player is focused
if (document.activeElement?.tagName === 'INPUT') return;
const step = e.shiftKey ? 100 : 10; // Larger steps with shift key
switch (e.key) {
case 'ArrowLeft':
setPosition(prev => ({
...prev,
x: constrain(
prev.x - step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowRight':
setPosition(prev => ({
...prev,
x: constrain(
prev.x + step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowUp':
setPosition(prev => ({
...prev,
y: constrain(
prev.y - step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'ArrowDown':
setPosition(prev => ({
...prev,
y: constrain(
prev.y + step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'Escape':
onExpand();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onExpand]);
// Load saved position on mount
useEffect(() => {
const savedPosition = localStorage.getItem('mini-player-position');
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition);
setPosition(pos);
} catch (error) {
console.error('Failed to parse saved mini player position:', error);
}
}
}, []);
// Ensure player stays within viewport bounds and implement edge snapping
useEffect(() => {
const constrainToViewport = () => {
if (!containerRef.current || isDragging) return;
const rect = containerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Add some padding from edges
const padding = 16;
// Calculate constrained position
let newX = constrain(
position.x,
-(viewportWidth - rect.width) / 2 + padding,
(viewportWidth - rect.width) / 2 - padding
);
let newY = constrain(
position.y,
-(viewportHeight - rect.height) / 2 + padding,
(viewportHeight - rect.height) / 2 - padding
);
// Edge snapping logic
const snapThreshold = 24; // Pixels from edge to trigger snap
const snapPositions = {
left: -(viewportWidth - rect.width) / 2 + padding,
right: (viewportWidth - rect.width) / 2 - padding,
top: -(viewportHeight - rect.height) / 2 + padding,
bottom: (viewportHeight - rect.height) / 2 - padding,
};
// Snap to left or right edge
if (Math.abs(newX - snapPositions.left) < snapThreshold) {
newX = snapPositions.left;
} else if (Math.abs(newX - snapPositions.right) < snapThreshold) {
newX = snapPositions.right;
}
// Snap to top or bottom edge
if (Math.abs(newY - snapPositions.top) < snapThreshold) {
newY = snapPositions.top;
} else if (Math.abs(newY - snapPositions.bottom) < snapThreshold) {
newY = snapPositions.bottom;
}
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
}
};
constrainToViewport();
window.addEventListener('resize', constrainToViewport);
return () => window.removeEventListener('resize', constrainToViewport);
}, [position, isDragging]);
const handleDragStart = () => {
setIsDragging(true);
dragStartRef.current = position;
};
const handleDrag = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
setPosition({
x: dragStartRef.current.x + info.offset.x,
y: dragStartRef.current.y + info.offset.y
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
if (!currentTrack) return null;
return (
<AnimatePresence>
<motion.div
ref={containerRef}
drag
dragMomentum={false}
dragElastic={0}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={{
x: position.x + window.innerWidth / 2,
y: position.y + window.innerHeight / 2,
scale: isDragging ? 1.02 : 1,
opacity: isDragging ? 0.8 : 1
}}
transition={{ type: 'spring', damping: 20 }}
style={{
position: 'fixed',
zIndex: 100,
transform: `translate(-50%, -50%)`
}}
className="cursor-grab active:cursor-grabbing"
onClick={handleContainerClick}
>
<div
className="backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.15)')}`
: 'var(--background-color, rgba(0, 0, 0, 0.8))',
borderColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.3)')}`
: 'var(--border-color, rgba(255, 255, 255, 0.1))'
}}
>
{/* Progress bar at the top */}
<div className="mb-3" onClick={handleProgressClick}>
<Progress
value={progress}
className="h-1 cursor-pointer"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.2)')}`
: undefined,
'--progress-color': dominantColor || undefined
} as React.CSSProperties}
/>
</div>
<div className="flex items-center gap-3">
{/* Album Art - Animated transition */}
<AnimatePresence mode="wait">
<motion.div
key={currentTrack.id}
className="relative w-12 h-12 shrink-0"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
>
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
fill
className="rounded-md object-cover shadow-md"
sizes="48px"
priority
/>
</motion.div>
</AnimatePresence>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Keyboard shortcut hint */}
<div className="text-xs text-muted-foreground text-center mt-2 px-2">
Double-click to expand Arrow keys to move
</div>
{/* Controls */}
<div className="flex items-center justify-between mt-2 px-2">
<button
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
/>
</button>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
playPreviousTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaBackward className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
className="p-3 hover:bg-muted/50 rounded-full transition-colors"
>
{isPlaying ? (
<FaPause className="w-4 h-4" />
) : (
<FaPlay className="w-4 h-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
playNextTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaForward className="w-3 h-3" />
</button>
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowVolumeSlider(prev => !prev);
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title="Volume"
>
{volume === 0 ? (
<FaVolumeXmark className="w-4 h-4" />
) : (
<FaVolumeHigh className="w-4 h-4" />
)}
</button>
{/* Volume Slider */}
{showVolumeSlider && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg"
onClick={e => e.stopPropagation()}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-24 accent-foreground"
/>
</div>
)}
</div>
</div>
{/* Expand button in top-right corner */}
<button
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="absolute top-2 right-2 p-1.5 hover:bg-muted/50 rounded-full transition-colors"
title="Expand"
>
<FaExpand className="w-3 h-3" />
</button>
</div>
</motion.div>
</AnimatePresence>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
'use client';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { SpotlightSearch } from './SpotlightSearch';
interface GlobalSearchContextProps {
isSpotlightOpen: boolean;
openSpotlight: () => void;
closeSpotlight: () => void;
}
const GlobalSearchContext = createContext<GlobalSearchContextProps | undefined>(undefined);
export function GlobalSearchProvider({ children }: { children: React.ReactNode }) {
const [isSpotlightOpen, setIsSpotlightOpen] = useState(false);
const openSpotlight = useCallback(() => {
setIsSpotlightOpen(true);
}, []);
const closeSpotlight = useCallback(() => {
setIsSpotlightOpen(false);
}, []);
return (
<GlobalSearchContext.Provider value={{
isSpotlightOpen,
openSpotlight,
closeSpotlight
}}>
{children}
<SpotlightSearch
isOpen={isSpotlightOpen}
onClose={closeSpotlight}
/>
</GlobalSearchContext.Provider>
);
}
export function useGlobalSearch() {
const context = useContext(GlobalSearchContext);
if (!context) {
throw new Error('useGlobalSearch must be used within a GlobalSearchProvider');
}
return context;
}

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
className="contents"
>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -95,7 +95,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0"> <div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
{song.coverArt && api && ( {song.coverArt && api && (
<Image <Image
src={api.getCoverArtUrl(song.coverArt, 96)} src={api.getCoverArtUrl(song.coverArt, 300)}
alt={song.album} alt={song.album}
width={48} width={48}
height={48} height={48}

View File

@@ -1,50 +0,0 @@
"use client"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
function PathnameTracker() {
const posthogClient = usePostHog()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (posthogClient) {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
}
}, [posthogClient, pathname, searchParams])
return null
}
function SuspendedPostHogPageView() {
return (
<Suspense fallback={null}>
<PathnameTracker />
</Suspense>
)
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
})
}, [])
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
}

View File

@@ -1,19 +1,40 @@
"use client"; "use client";
import React from "react"; import React, { useEffect } from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext"; import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext"; import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext"; import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider"; import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
import { WhatsNewPopup } from "../components/WhatsNewPopup"; import { WhatsNewPopup } from "../components/WhatsNewPopup";
import Ihateserverside from "./ihateserverside"; import Ihateserverside from "./ihateserverside";
import DynamicViewportTheme from "./DynamicViewportTheme"; import DynamicViewportTheme from "./DynamicViewportTheme";
import ThemeColorHandler from "./ThemeColorHandler";
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen"; import { LoginForm } from "./start-screen";
import Image from "next/image"; import Image from "next/image";
import PageTransition from "./PageTransition";
import { GlobalSearchProvider } from "./GlobalSearchProvider";
// ServiceWorkerRegistration component to handle registration
function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered successfully:', registration);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
}, []);
return null;
}
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) { function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
const { error } = useNavidrome(); // For now, since we're switching to offline-first, we'll handle errors differently
// The offline provider will handle connectivity issues automatically
const [isClient, setIsClient] = React.useState(false); const [isClient, setIsClient] = React.useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
@@ -56,10 +77,9 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// Show start screen ONLY if: // Show start screen ONLY if first-time user (no onboarding completed)
// 1. First-time user (no onboarding completed), OR // In offline-first mode, we don't need to check for errors since the app works offline
// 2. User has completed onboarding BUT there's an error AND no config exists const shouldShowStartScreen = !hasCompletedOnboarding;
const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
if (shouldShowStartScreen) { if (shouldShowStartScreen) {
return ( return (
@@ -80,22 +100,24 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
export default function RootLayoutClient({ children }: { children: React.ReactNode }) { export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
return ( return (
<PostHogProvider> <ThemeProvider>
<ThemeProvider> <DynamicViewportTheme />
<DynamicViewportTheme /> <ThemeColorHandler />
<NavidromeConfigProvider> <ServiceWorkerRegistration />
<NavidromeProvider> <NavidromeConfigProvider>
<NavidromeErrorBoundary> <NavidromeProvider>
<AudioPlayerProvider> <NavidromeErrorBoundary>
<AudioPlayerProvider>
<GlobalSearchProvider>
<Ihateserverside> <Ihateserverside>
{children} <PageTransition>{children}</PageTransition>
</Ihateserverside> </Ihateserverside>
<WhatsNewPopup /> <WhatsNewPopup />
</AudioPlayerProvider> </GlobalSearchProvider>
</NavidromeErrorBoundary> </AudioPlayerProvider>
</NavidromeProvider> </NavidromeErrorBoundary>
</NavidromeConfigProvider> </NavidromeProvider>
</ThemeProvider> </NavidromeConfigProvider>
</PostHogProvider> </ThemeProvider>
); );
} }

View File

@@ -38,7 +38,7 @@ export function SettingsManagement() {
}; };
return ( return (
<Card> <Card className="py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />

View File

@@ -153,7 +153,7 @@ export function SidebarCustomization() {
return ( return (
<Card> <Card className="py-5">
<CardHeader> <CardHeader>
<CardTitle>Sidebar Customization</CardTitle> <CardTitle>Sidebar Customization</CardTitle>
<CardDescription> <CardDescription>

View File

@@ -1,89 +1,103 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song } from '@/lib/navidrome'; import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext'; import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Play, Heart, Music, Shuffle } from 'lucide-react'; import { Play, Heart, Music, Shuffle } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { UserProfile } from './UserProfile';
interface SongRecommendationsProps { interface SongRecommendationsProps {
userName?: string; userName?: string;
} }
export function SongRecommendations({ userName }: SongRecommendationsProps) { export function SongRecommendations({ userName }: SongRecommendationsProps) {
const { api, isConnected } = useNavidrome(); const { api } = useNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer(); const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]); const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({}); const [songStates, setSongStates] = useState<Record<string, boolean>>({});
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
// Get greeting based on time of day // Memoize the greeting to prevent recalculation
const hour = new Date().getHours(); const greeting = useMemo(() => {
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; const hour = new Date().getHours();
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}, []);
// Memoized callbacks to prevent re-renders
const handleImageLoad = useCallback(() => {
// Image loaded - no state update needed to prevent re-renders
}, []);
const handleImageError = useCallback(() => {
// Image error - no state update needed to prevent re-renders
}, []);
useEffect(() => { useEffect(() => {
const loadRecommendations = async () => { const loadRecommendations = async () => {
if (!api || !isConnected) return;
setLoading(true); setLoading(true);
try { try {
// Get random albums and extract songs from them const api = getNavidromeAPI();
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
const allSongs: Song[] = [];
// Get songs from first few albums if (api) {
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) { // Use server-side recommendations
try { const randomAlbums = await api.getAlbums('random', 10);
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id); if (isMobile) {
allSongs.push(...albumSongs); setRecommendedAlbums(randomAlbums.slice(0, 6));
} catch (error) { } else {
console.error('Failed to get album songs:', error); const allSongs: Song[] = [];
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
try {
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
allSongs.push(...albumSongs);
} catch (error) {
console.error('Failed to get album songs:', error);
}
}
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
setSongStates(states);
} }
} }
// Shuffle and limit to 6 songs
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states and image loading states
const states: Record<string, boolean> = {};
const imageStates: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
imageStates[song.id] = true; // Start with loading state
});
setSongStates(states);
setImageLoadingStates(imageStates);
} catch (error) { } catch (error) {
console.error('Failed to load song recommendations:', error); console.error('Failed to load recommendations:', error);
setRecommendedAlbums([]);
setRecommendedSongs([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadRecommendations(); loadRecommendations();
}, [api, isConnected]); }, [isMobile]);
const handlePlaySong = async (song: Song) => { const handlePlaySong = async (song: Song) => {
if (!api) return;
try { try {
const api = getNavidromeAPI();
if (!api) return;
const url = api.getStreamUrl(song.id);
const coverArt = song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
const track = { const track = {
id: song.id, id: song.id,
name: song.title, name: song.title,
url: api.getStreamUrl(song.id), url,
artist: song.artist || 'Unknown Artist', artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '', artistId: song.artistId || '',
album: song.album || 'Unknown Album', album: song.album || 'Unknown Album',
albumId: song.albumId || '', albumId: song.albumId || '',
duration: song.duration || 0, duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt,
starred: !!song.starred starred: !!song.starred
}; };
await playTrack(track, true); await playTrack(track, true);
@@ -92,17 +106,53 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
} }
}; };
const handlePlayAlbum = async (album: Album) => {
try {
const api = getNavidromeAPI();
if (!api) return;
const albumSongs = await api.getAlbumSongs(album.id);
if (albumSongs.length > 0) {
const first = albumSongs[0];
const url = api.getStreamUrl(first.id);
const coverArt = first.coverArt ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
const track = {
id: first.id,
name: first.title,
url,
artist: first.artist || 'Unknown Artist',
artistId: first.artistId || '',
album: first.album || 'Unknown Album',
albumId: first.albumId || '',
duration: first.duration || 0,
coverArt,
starred: !!first.starred
};
await playTrack(track, true);
}
} catch (error) {
console.error('Failed to play album:', error);
}
};
const handleShuffleAll = async () => { const handleShuffleAll = async () => {
if (recommendedSongs.length === 0) return; if (isMobile && recommendedAlbums.length === 0) return;
if (!isMobile && recommendedSongs.length === 0) return;
// Enable shuffle if not already on // Enable shuffle if not already on
if (!shuffle) { if (!shuffle) {
toggleShuffle(); toggleShuffle();
} }
// Play a random song from recommendations if (isMobile) {
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)]; // Play a random album
await handlePlaySong(randomSong); const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
await handlePlayAlbum(randomAlbum);
} else {
// Play a random song from recommendations
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
await handlePlaySong(randomSong);
}
}; };
const formatDuration = (duration: number): string => { const formatDuration = (duration: number): string => {
@@ -118,11 +168,19 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<div className="h-8 w-48 bg-muted animate-pulse rounded" /> <div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-64 bg-muted animate-pulse rounded" /> <div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> {isMobile ? (
{Array.from({ length: 6 }).map((_, i) => ( <div className="grid grid-cols-3 gap-3">
<div key={i} className="h-16 bg-muted animate-pulse rounded" /> {Array.from({ length: 6 }).map((_, i) => (
))} <div key={i} className="aspect-square bg-muted animate-pulse rounded" />
</div> ))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
)}
</div> </div>
); );
} }
@@ -135,95 +193,153 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
{greeting}{userName ? `, ${userName}` : ''}! {greeting}{userName ? `, ${userName}` : ''}!
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Here are some songs you might enjoy {isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
</p> </p>
</div> </div>
{recommendedSongs.length > 0 && ( <div className="flex items-center gap-3">
<Button onClick={handleShuffleAll} variant="outline" size="sm"> {/* Mobile User Profile */}
<Shuffle className="w-4 h-4 mr-2" /> {isMobile && <UserProfile variant="mobile" />}
Shuffle All
</Button> {/* Shuffle All Button (Desktop only) */}
)} {(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
</Button>
)}
</div>
</div> </div>
{recommendedSongs.length > 0 ? ( {isMobile ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> /* Mobile: Show albums in 3x2 grid */
{recommendedSongs.map((song) => ( recommendedAlbums.length > 0 ? (
<Card <div className="grid grid-cols-3 gap-3">
key={song.id} {recommendedAlbums.map((album) => (
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2" <div key={album.id} className="space-y-2">
onClick={() => handlePlaySong(song)} <Link
> href={`/album/${album.id}`}
<CardContent className="px-2"> className="group cursor-pointer block"
<div className="flex items-center gap-3"> >
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0"> <div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{song.coverArt && api ? ( {album.coverArt && getNavidromeAPI() ? (
<> <Image
{imageLoadingStates[song.id] && ( src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
<div className="absolute inset-0 bg-muted flex items-center justify-center"> alt={album.name}
<Music className="w-6 h-6 text-muted-foreground animate-pulse" /> width={600}
</div> height={600}
)} className="object-cover"
<Image sizes="(max-width: 768px) 33vw, 200px"
src={api.getCoverArtUrl(song.coverArt, 100)} onLoad={handleImageLoad}
alt={song.title} onError={handleImageError}
fill loading="lazy"
className={`object-cover transition-opacity duration-300 ${ />
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
}`}
sizes="48px"
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
/>
</>
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground" /> <Music className="w-8 h-8 text-muted-foreground" />
</div>
)}
{!imageLoadingStates[song.id] && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-4 h-4 text-white" />
</div> </div>
)} )}
</div> </div>
</Link>
<div className="flex-1 min-w-0"> <div className="space-y-1">
<p className="font-medium truncate">{song.title}</p> <Link
<div className="flex items-center gap-2 text-sm text-muted-foreground"> href={`/album/${album.id}`}
<Link className="font-medium text-sm truncate hover:underline block"
href={`/artist/${song.artistId}`} >
className="hover:underline truncate" {album.name}
onClick={(e) => e.stopPropagation()} </Link>
> <Link
{song.artist} href={`/artist/${album.artistId || album.artist}`}
</Link> className="text-xs text-muted-foreground truncate hover:underline block"
{song.duration && ( >
{album.artist}
</Link>
</div>
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-6 text-center">
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No albums available for recommendations
</p>
</CardContent>
</Card>
)
) : (
/* Desktop: Show songs in original format */
recommendedSongs.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedSongs.map((song) => (
<Card
key={song.id}
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
onClick={() => handlePlaySong(song)}
>
<CardContent className="px-2">
<div className="flex items-center gap-3">
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && getNavidromeAPI() ? (
<> <>
<span></span> <Image
<span>{formatDuration(song.duration)}</span> src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"
sizes="48px"
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-4 h-4 text-white" />
</div>
</> </>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground" />
</div>
)} )}
</div> </div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{song.title}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
href={`/artist/${song.artistId}`}
className="hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{song.artist}
</Link>
{song.duration && (
<>
<span></span>
<span>{formatDuration(song.duration)}</span>
</>
)}
</div>
</div>
{songStates[song.id] && (
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
)}
</div> </div>
</CardContent>
{songStates[song.id] && ( </Card>
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" /> ))}
)} </div>
</div> ) : (
</CardContent> <Card>
</Card> <CardContent className="p-6 text-center">
))} <Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
</div> <p className="text-muted-foreground">
) : ( No songs available for recommendations
<Card> </p>
<CardContent className="p-6 text-center"> </CardContent>
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" /> </Card>
<p className="text-muted-foreground"> )
No songs available for recommendations
</p>
</CardContent>
</Card>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,653 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Search,
X,
Music,
Disc,
User,
Clock,
Heart,
Play,
Plus,
ExternalLink,
Info,
Star,
TrendingUp,
Users,
Calendar,
Globe
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Song, Album, Artist, getNavidromeAPI } from '@/lib/navidrome';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import Image from 'next/image';
interface SpotlightSearchProps {
isOpen: boolean;
onClose: () => void;
}
interface LastFmTrackInfo {
name: string;
artist: {
name: string;
url: string;
};
album?: {
title: string;
image?: string;
};
wiki?: {
summary: string;
content: string;
};
duration?: string;
playcount?: string;
listeners?: string;
tags?: Array<{
name: string;
url: string;
}>;
}
interface LastFmTag {
name: string;
url: string;
}
interface LastFmArtist {
name: string;
url: string;
image?: Array<{ '#text': string; size: string }>;
}
interface LastFmBio {
summary: string;
content: string;
}
interface LastFmStats {
listeners: string;
playcount: string;
}
interface LastFmArtistInfo {
name: string;
bio?: LastFmBio;
stats?: LastFmStats;
tags?: {
tag: LastFmTag[];
};
similar?: {
artist: LastFmArtist[];
};
image?: Array<{ '#text': string; size: string }>;
}
interface SearchResult {
type: 'track' | 'album' | 'artist';
id: string;
title: string;
subtitle: string;
image?: string;
data: Song | Album | Artist;
lastFmData?: LastFmArtistInfo;
}
export function SpotlightSearch({ isOpen, onClose }: SpotlightSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [selectedResult, setSelectedResult] = useState<SearchResult | null>(null);
const [lastFmDetails, setLastFmDetails] = useState<LastFmArtistInfo | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const api = getNavidromeAPI();
const { search2 } = useNavidrome();
const { playTrack, addToQueue, insertAtBeginningOfQueue } = useAudioPlayer();
// Convert Song to Track with proper URL generation
const songToTrack = useCallback((song: Song) => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred,
replayGain: song.replayGain || 0
};
}, [api]);
// Focus input when opened
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Close on escape
useKeyboardShortcuts({
disabled: !isOpen
});
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelect(results[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (showDetails) {
setShowDetails(false);
setSelectedResult(null);
} else {
onClose();
}
break;
case 'Tab':
e.preventDefault();
if (results[selectedIndex]) {
handleShowDetails(results[selectedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex, showDetails, onClose]);
// Search function with debouncing
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const searchResults = await search2(searchQuery);
const formattedResults: SearchResult[] = [];
// Add tracks
searchResults.songs?.forEach(song => {
formattedResults.push({
type: 'track',
id: song.id,
title: song.title,
subtitle: `${song.artist}${song.album}`,
image: song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 256) : undefined,
data: song
});
});
// Add albums
searchResults.albums?.forEach(album => {
formattedResults.push({
type: 'album',
id: album.id,
title: album.name,
subtitle: `${album.artist}${album.songCount} tracks`,
image: album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 256) : undefined,
data: album
});
});
// Add artists
searchResults.artists?.forEach(artist => {
formattedResults.push({
type: 'artist',
id: artist.id,
title: artist.name,
subtitle: `${artist.albumCount} albums`,
image: artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 256) : undefined,
data: artist
});
});
setResults(formattedResults);
setSelectedIndex(0);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, [search2]);
// Debounced search
useEffect(() => {
const timeoutId = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeoutId);
}, [query, performSearch]);
const handleResultSelect = (result: SearchResult) => {
switch (result.type) {
case 'track':
const songData = result.data as Song;
const track = songToTrack(songData);
playTrack(track, true);
onClose();
break;
case 'album':
router.push(`/album/${result.id}`);
onClose();
break;
case 'artist':
router.push(`/artist/${result.id}`);
onClose();
break;
}
};
const handleShowDetails = async (result: SearchResult) => {
setSelectedResult(result);
setShowDetails(true);
setLastFmDetails(null);
// Fetch Last.fm data
try {
let lastFmData = null;
if (result.type === 'artist') {
const artistData = result.data as Artist;
lastFmData = await lastFmAPI.getArtistInfo(artistData.name);
} else if (result.type === 'album') {
// For albums, get artist info as Last.fm album info is limited
const albumData = result.data as Album;
lastFmData = await lastFmAPI.getArtistInfo(albumData.artist);
} else if (result.type === 'track') {
// For tracks, get artist info
const songData = result.data as Song;
lastFmData = await lastFmAPI.getArtistInfo(songData.artist);
}
setLastFmDetails(lastFmData);
} catch (error) {
console.error('Failed to fetch Last.fm data:', error);
}
};
const handlePlayNext = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
insertAtBeginningOfQueue(track);
}
};
const handleAddToQueue = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
addToQueue(track);
}
};
const getResultIcon = (type: string) => {
switch (type) {
case 'track': return <Music className="w-4 h-4" />;
case 'album': return <Disc className="w-4 h-4" />;
case 'artist': return <User className="w-4 h-4" />;
default: return <Search className="w-4 h-4" />;
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div className="flex items-start justify-center pt-[10vh] px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", duration: 0.4 }}
className="w-full max-w-2xl bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="flex items-center px-4 py-3 border-b border-border">
<Search className="w-5 h-5 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tracks, albums, artists..."
className="border-0 focus-visible:ring-0 text-lg bg-transparent"
/>
{query && (
<Button
variant="ghost"
size="sm"
onClick={() => setQuery('')}
className="p-1 h-auto"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Results */}
<div className="max-h-96 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
) : results.length > 0 ? (
<ScrollArea className="max-h-96" ref={resultsRef}>
<div className="py-2">
{results.map((result, index) => (
<div
key={`${result.type}-${result.id}`}
className={`flex items-center px-4 py-3 cursor-pointer transition-colors ${
index === selectedIndex ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => handleResultSelect(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{result.image ? (
<Image
src={result.image}
alt={result.title}
width={40}
height={40}
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
{getResultIcon(result.type)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
<div className="text-sm text-muted-foreground truncate">
{result.subtitle}
</div>
</div>
<Badge variant="secondary" className="capitalize">
{result.type}
</Badge>
</div>
{/* Quick Actions */}
<div className="flex items-center space-x-1 ml-3">
{result.type === 'track' && (
<>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePlayNext(result);
}}
className="h-8 w-8 p-0"
title="Play Next"
>
<Play className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddToQueue(result);
}}
className="h-8 w-8 p-0"
title="Add to Queue"
>
<Plus className="w-3 h-3" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleShowDetails(result);
}}
className="h-8 w-8 p-0"
title="Show Details (Tab)"
>
<Info className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
) : query.trim() ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Search className="w-8 h-8 mb-2" />
<p>No results found for &ldquo;{query}&rdquo;</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Music className="w-8 h-8 mb-2" />
<p>Start typing to search your music library</p>
<div className="text-xs mt-2 space-y-1">
<p> Use to navigate Enter to select</p>
<p> Tab for details Esc to close</p>
</div>
</div>
)}
</div>
</motion.div>
</div>
{/* Details Panel */}
<AnimatePresence>
{showDetails && selectedResult && (
<motion.div
initial={{ opacity: 0, x: 400 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 400 }}
transition={{ type: "spring", duration: 0.4 }}
className="fixed right-4 top-[10vh] bottom-4 w-80 bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold">Details</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Basic Info */}
<div className="space-y-3">
{selectedResult.image && (
<Image
src={selectedResult.image}
alt={selectedResult.title}
width={200}
height={200}
className="w-full aspect-square rounded object-cover"
/>
)}
<div>
<h4 className="font-semibold text-lg">{selectedResult.title}</h4>
<p className="text-muted-foreground">{selectedResult.subtitle}</p>
<Badge variant="secondary" className="mt-1 capitalize">
{selectedResult.type}
</Badge>
</div>
</div>
{/* Last.fm Data */}
{lastFmDetails && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center space-x-2">
<ExternalLink className="w-4 h-4" />
<span className="font-medium">Last.fm Info</span>
</div>
{/* Stats */}
{lastFmDetails.stats && (
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<Users className="w-3 h-3" />
<span className="text-xs font-medium">Listeners</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.listeners).toLocaleString()}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs font-medium">Plays</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.playcount).toLocaleString()}
</div>
</div>
</div>
)}
{/* Bio */}
{lastFmDetails.bio?.summary && (
<div>
<h5 className="font-medium mb-2">Biography</h5>
<p className="text-sm text-muted-foreground leading-relaxed">
{lastFmDetails.bio.summary.replace(/<[^>]*>/g, '').split('\n')[0]}
</p>
</div>
)}
{/* Tags */}
{lastFmDetails.tags?.tag && (
<div>
<h5 className="font-medium mb-2">Tags</h5>
<div className="flex flex-wrap gap-1">
{lastFmDetails.tags.tag.slice(0, 6).map((tag: LastFmTag, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
</div>
)}
{/* Similar Artists */}
{lastFmDetails.similar?.artist && (
<div>
<h5 className="font-medium mb-2">Similar Artists</h5>
<div className="space-y-2">
{lastFmDetails.similar.artist.slice(0, 4).map((artist: LastFmArtist, index: number) => (
<div key={index} className="flex items-center space-x-2">
<div className="w-8 h-8 bg-muted rounded flex items-center justify-center">
<User className="w-3 h-3" />
</div>
<span className="text-sm">{artist.name}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
{/* Actions */}
<Separator />
<div className="space-y-2">
<Button
onClick={() => handleResultSelect(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
{selectedResult.type === 'track' ? 'Play Track' :
selectedResult.type === 'album' ? 'View Album' : 'View Artist'}
</Button>
{selectedResult.type === 'track' && (
<>
<Button
variant="outline"
onClick={() => handlePlayNext(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
Play Next
</Button>
<Button
variant="outline"
onClick={() => handleAddToQueue(selectedResult)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add to Queue
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
export default function ThemeColorHandler() {
useViewportThemeColor();
return null;
}

View File

@@ -0,0 +1,210 @@
'use client';
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getGravatarUrl } from '@/lib/gravatar';
import { User as NavidromeUser } from '@/lib/navidrome';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
interface UserProfileProps {
variant?: 'desktop' | 'mobile';
}
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
const { api, isConnected } = useNavidrome();
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
if (!api || !isConnected) {
setLoading(false);
return;
}
try {
const user = await api.getUserInfo();
setUserInfo(user);
} catch (error) {
console.error('Failed to fetch user info:', error);
} finally {
setLoading(false);
}
};
fetchUserInfo();
}, [api, isConnected]);
const handleLogout = () => {
// Clear Navidrome config and reload
localStorage.removeItem('navidrome-config');
window.location.reload();
};
if (!userInfo) {
if (variant === 'desktop') {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
} else {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
}
}
const gravatarUrl = userInfo.email
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
: null;
if (variant === 'desktop') {
// Desktop: Only show profile icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-4 h-4 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-2 h-2 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
} else {
// Mobile: Show only icon with dropdown
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@@ -1,16 +1,57 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
// Current app version from package.json // Current app version from package.json
const APP_VERSION = '2025.07.10'; const APP_VERSION = '2026.01.24';
// Changelog data - add new versions at the top // Changelog data - add new versions at the top
const CHANGELOG = [ const CHANGELOG = [
{
version: '2026.01.24',
title: 'January 2026 Update',
changes: [
'Improved SortableQueueItem component with enhanced click handling and styling',
'Added keyboard shortcuts and queue management features',
'Added ListeningStreakCard component for tracking listening streaks',
'Moved service worker registration to dedicated component for improved client-side handling',
'Implemented Auto-Tagging Settings and MusicBrainz integration',
'Enhanced audio settings with ReplayGain, crossfade, and equalizer presets',
'Added AudioSettingsDialog component',
'Updated cover art retrieval to use higher resolution images',
'Enhanced UI with Framer Motion animations for album artwork and artist icons',
'Added page transition animations and notification settings for audio playback',
'Updated all npm subdependencies to latest minor versions',
],
fixes: [
'Updated README formatting and improved content clarity',
],
breaking: [
'Removed PostHog analytics tracking',
'Removed all offline download and caching functionality',
]
},
{
version: '2025.07.31',
title: 'July End of Month Update',
changes: [
'Native support for moblie devices (using pwa)',
],
fixes: [
'Fixed issue with mobile navigation bar not displaying correctly',
'Improved performance on mobile devices',
'Resolved layout issues on smaller screens',
'Fixed audio player controls not responding on mobile',
'Improved touch interactions for better usability',
'Fixed issue with album artwork not loading on mobile',
'Resolved bug with search functionality on mobile devices',
'Improved caching for faster load times on mobile',
],
breaking: [
]
},
{ {
version: '2025.07.10', version: '2025.07.10',
title: 'July Major Update', title: 'July Major Update',
@@ -30,7 +71,6 @@ const CHANGELOG = [
'Enhanced Home page layout and content', 'Enhanced Home page layout and content',
'Themes updated to use OKLCH (from HSL)', 'Themes updated to use OKLCH (from HSL)',
'All themes updated (light themes look similar)', 'All themes updated (light themes look similar)',
'Caching system added (incomplete)',
'Skeleton loading added across all pages' 'Skeleton loading added across all pages'
], ],
fixes: [ fixes: [
@@ -189,65 +229,86 @@ export function WhatsNewPopup() {
); );
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <>
<DialogContent className="max-w-2xl max-h-[80vh]"> {isOpen && (
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div> {/* Backdrop */}
<DialogTitle className="text-2xl font-bold flex items-center gap-2"> <div
What&apos;s New in Mice className="fixed inset-0 bg-black/50"
<Badge variant="outline"> onClick={handleClose}
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version} />
</Badge>
</DialogTitle> {/* Dialog content */}
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
What&apos;s New in Mice
<Badge variant="outline">
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
</Badge>
</h2>
</div>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2 px-6 pt-4 shrink-0">
<Button
variant={tab === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('latest')}
>
Latest
</Button>
<Button
variant={tab === 'archive' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('archive')}
disabled={archiveChangelogs.length === 0}
>
Archive
</Button>
{tab === 'archive' && archiveChangelogs.length > 0 && (
<select
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
value={selectedArchive}
onChange={e => setSelectedArchive(e.target.value)}
>
{archiveChangelogs.map(entry => (
<option key={entry.version} value={entry.version}>
{entry.version}
</option>
))}
</select>
)}
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
<div className="space-y-6">
{tab === 'latest'
? renderChangelog(currentVersionChangelog)
: archiveChangelog && renderChangelog(archiveChangelog)}
</div>
</div>
{/* Footer button */}
<div className="flex justify-center p-6 pt-4 shrink-0">
<Button onClick={handleClose}>
Got it!
</Button>
</div>
</div> </div>
</DialogHeader>
{/* Tabs */}
<>
<div className="flex gap-2 mb-4">
<Button
variant={tab === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('latest')}
>
Latest
</Button>
<Button
variant={tab === 'archive' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('archive')}
disabled={archiveChangelogs.length === 0}
>
Archive
</Button>
{tab === 'archive' && archiveChangelogs.length > 0 && (
<select
className="ml-2 border rounded px-2 py-1 text-sm"
value={selectedArchive}
onChange={e => setSelectedArchive(e.target.value)}
>
{archiveChangelogs.map(entry => (
<option key={entry.version} value={entry.version}>
{entry.version}
</option>
))}
</select>
)}
</div> </div>
<ScrollArea className="max-h-[60vh] pr-4"> )}
{tab === 'latest' </>
? renderChangelog(currentVersionChangelog)
: archiveChangelog && renderChangelog(archiveChangelog)}
</ScrollArea>
<div className="flex justify-center pt-4">
<Button onClick={handleClose}>
Got it!
</Button>
</div>
</>
</DialogContent>
</Dialog>
); );
} }

View File

@@ -3,6 +3,7 @@
import Image from "next/image" import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons" import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
@@ -20,18 +21,22 @@ import { useNavidrome } from "./NavidromeContext"
import Link from "next/link"; import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext"; import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
import { getNavidromeAPI } from "@/lib/navidrome"; import { getNavidromeAPI } from "@/lib/navidrome";
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon"; import { ArtistIcon } from "@/app/components/artist-icon";
import { Heart, Music, Disc, Mic, Play } from "lucide-react"; import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
import { Album, Artist, Song } from "@/lib/navidrome"; import { Album, Artist, Song } from "@/lib/navidrome";
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> { interface AlbumArtworkProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'onDrop'
> {
album: Album album: Album
aspectRatio?: "portrait" | "square" aspectRatio?: "portrait" | "square"
width?: number width?: number
height?: number height?: number
loading?: 'eager' | 'lazy'
} }
export function AlbumArtwork({ export function AlbumArtwork({
@@ -39,6 +44,7 @@ export function AlbumArtwork({
aspectRatio = "portrait", aspectRatio = "portrait",
width, width,
height, height,
loading = 'lazy',
className, className,
...props ...props
}: AlbumArtworkProps) { }: AlbumArtworkProps) {
@@ -46,13 +52,40 @@ export function AlbumArtwork({
const router = useRouter(); const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer(); const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome(); const { playlists, starItem, unstarItem } = useNavidrome();
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false); // Memoize cover art URL with dynamic sizing
const coverArtUrl = useMemo(() => {
if (!api || !album.coverArt) return '/default-user.jpg';
// Use width prop or default size for optimization
const imageSize = width || height || 300;
return api.getCoverArtUrl(album.coverArt, imageSize);
}, [api, album.coverArt, width, height]);
// Use callback to prevent function recreation on every render
const handleImageLoad = useCallback(() => {
// Image loaded successfully - no state update needed
}, []);
const handleImageError = useCallback(() => {
// Image failed to load - could set error state if needed
}, []);
const handleClick = () => { const handleClick = () => {
router.push(`/album/${album.id}`); router.push(`/album/${album.id}`);
}; };
const handlePrefetch = () => {
try {
// Next.js App Router will prefetch on hover when using Link with prefetch
// but we also call router.prefetch to ensure programmatic prefetch when present.
const r = router as unknown as { prefetch?: (href: string) => Promise<void> | void };
if (r && typeof r.prefetch === 'function') {
r.prefetch(`/album/${album.id}`);
}
} catch {}
};
const handleAddToQueue = () => { const handleAddToQueue = () => {
addAlbumToQueue(album.id); addAlbumToQueue(album.id);
}; };
@@ -80,7 +113,7 @@ export function AlbumArtwork({
artistId: song.artistId, artistId: song.artistId,
url: api.getStreamUrl(song.id), url: api.getStreamUrl(song.id),
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred starred: !!song.starred
})); }));
@@ -105,68 +138,51 @@ export function AlbumArtwork({
console.error('Failed to toggle favorite:', error); console.error('Failed to toggle favorite:', error);
} }
}; };
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
return ( return (
<div className={cn("space-y-3", className)} {...props}> <div className={cn("space-y-3", className)} {...props}>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}> <Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
<div className="aspect-square relative group"> <div className="aspect-square relative group">
{album.coverArt && api ? ( {album.coverArt && api ? (
<> <Image
{imageLoading && ( src={coverArtUrl}
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center"> alt={album.name}
<Disc className="w-12 h-12 text-muted-foreground animate-spin" /> fill
</div> className="w-full h-full object-cover transition-all"
)} sizes="(max-width: 768px) 100vw, 300px"
<Image onLoad={handleImageLoad}
src={api.getCoverArtUrl(album.coverArt)} onError={handleImageError}
alt={album.name} priority={false}
fill loading={loading}
className={`w-full h-full object-cover transition-opacity duration-300 ${ />
imageLoading ? 'opacity-0' : 'opacity-100' ) : (
}`} <div className="w-full h-full bg-muted rounded flex items-center justify-center">
sizes="(max-width: 768px) 100vw, 300px" <Disc className="w-12 h-12 text-muted-foreground" />
onLoad={() => setImageLoading(false)} </div>
onError={() => { )}
setImageLoading(false); <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
setImageError(true); <Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
}} </div>
/> </div>
</> <CardContent className="p-4">
) : ( <h3 className="font-semibold truncate">
<div className="w-full h-full bg-muted rounded flex items-center justify-center"> <Link href={`/album/${album.id}`} prefetch>{album.name}</Link>
<Disc className="w-12 h-12 text-muted-foreground" /> </h3>
</div> <p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
)} <p className="text-xs text-muted-foreground mt-1">
{!imageLoading && ( {album.songCount} songs {Math.floor(album.duration / 60)} min
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2"> </p>
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/> </CardContent>
</div> </Card>
)}
</div>
<CardContent className="p-4">
{imageLoading ? (
<>
<div className="h-5 w-3/4 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-1/2 bg-muted animate-pulse rounded mb-1" />
<div className="h-3 w-2/3 bg-muted animate-pulse rounded" />
</>
) : (
<>
<h3 className="font-semibold truncate">{album.name}</h3>
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
<p className="text-xs text-muted-foreground mt-1">
{album.songCount} songs {Math.floor(album.duration / 60)} min
</p>
</>
)}
</CardContent>
</Card>
{/* <div onClick={handleClick} className="overflow-hidden rounded-md"> {/* <div onClick={handleClick} className="overflow-hidden rounded-md">
<Image <Image
src={coverArtUrl} src={coverArtUrl}
@@ -223,6 +239,7 @@ export function AlbumArtwork({
<ContextMenuItem>Share</ContextMenuItem> <ContextMenuItem>Share</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</motion.div>
</div> </div>
) )
} }

View File

@@ -4,6 +4,7 @@ import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons" import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { motion } from 'framer-motion'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -26,6 +27,7 @@ interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
size?: number size?: number
imageOnly?: boolean imageOnly?: boolean
responsive?: boolean responsive?: boolean
loading?: 'eager' | 'lazy'
} }
export function ArtistIcon({ export function ArtistIcon({
@@ -33,6 +35,7 @@ export function ArtistIcon({
size = 150, size = 150,
imageOnly = false, imageOnly = false,
responsive = false, responsive = false,
loading = 'lazy',
className, className,
...props ...props
}: ArtistIconProps) { }: ArtistIconProps) {
@@ -76,6 +79,7 @@ export function ArtistIcon({
width={size} width={size}
height={size} height={size}
className="w-full h-full object-cover transition-all hover:scale-105" className="w-full h-full object-cover transition-all hover:scale-105"
loading={loading}
/> />
</div> </div>
); );
@@ -88,6 +92,13 @@ export function ArtistIcon({
<div className={cn("space-y-3", className)} {...props}> <div className={cn("space-y-3", className)} {...props}>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<motion.div
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}> <Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div <div
className="aspect-square relative group" className="aspect-square relative group"
@@ -108,6 +119,7 @@ export function ArtistIcon({
} }
)} )}
className={isResponsive ? "object-cover" : "object-cover w-full h-full"} className={isResponsive ? "object-cover" : "object-cover w-full h-full"}
loading={loading}
/> />
</div> </div>
</div> </div>
@@ -118,6 +130,7 @@ export function ArtistIcon({
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</motion.div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={handleStar}> <ContextMenuItem onClick={handleStar}>

View File

@@ -5,6 +5,7 @@ import { Menu } from "@/app/components/menu";
import { Sidebar } from "@/app/components/sidebar"; import { Sidebar } from "@/app/components/sidebar";
import { useNavidrome } from "@/app/components/NavidromeContext"; import { useNavidrome } from "@/app/components/NavidromeContext";
import { AudioPlayer } from "./AudioPlayer"; import { AudioPlayer } from "./AudioPlayer";
import { BottomNavigation } from './BottomNavigation';
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums"; import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
@@ -96,48 +97,74 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</div> </div>
); );
} }
return ( return (
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden"> <>
{/* Top Menu */} {/* Mobile Layout */}
<div <div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
className="sticky z-10 bg-background border-b w-full" {/* Top Menu */}
style={{ {/* <div className="shrink-0 bg-background border-b w-full">
left: 'env(titlebar-area-x, 0)', <Menu
top: 'env(titlebar-area-y, 0)', toggleSidebar={toggleSidebarVisibility}
}} isSidebarVisible={isSidebarVisible}
> toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
<Menu isStatusBarVisible={isStatusBarVisible}
toggleSidebar={toggleSidebarVisibility} />
isSidebarVisible={isSidebarVisible} </div> */}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div>
{/* Main Content Area */} {/* Main Content Area with bottom padding for audio player and bottom nav */}
<div className="flex-1 flex overflow-hidden w-full"> <div className="flex-1 overflow-y-auto pb-40">
{isSidebarVisible && ( <div>{children}</div>
<div className="w-16 shrink-0 border-r transition-all duration-200">
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
visible={isSidebarVisible}
favoriteAlbums={favoriteAlbums}
onRemoveFavoriteAlbum={removeFavoriteAlbum}
/>
</div>
)}
<div className="flex-1 overflow-y-auto min-w-0">
<div>{children}</div>
</div>
</div> </div>
{/* Floating Audio Player */} {/* Bottom Navigation for Mobile */}
{isStatusBarVisible && ( <BottomNavigation />
<AudioPlayer />
)} <Toaster />
<Toaster /> </div>
</div>
{/* Desktop Layout */}
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
{/* Top Menu */}
<div
className="sticky z-10 bg-background border-b w-full"
style={{
left: 'env(titlebar-area-x, 0)',
top: 'env(titlebar-area-y, 0)',
}}
>
<Menu
toggleSidebar={toggleSidebarVisibility}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden w-full">
{isSidebarVisible && (
<div className="w-16 shrink-0 border-r transition-all duration-200">
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
visible={isSidebarVisible}
favoriteAlbums={favoriteAlbums}
onRemoveFavoriteAlbum={removeFavoriteAlbum}
/>
</div>
)}
<div className="flex-1 overflow-y-auto min-w-0">
<div>{children}</div>
</div>
</div>
<Toaster />
</div>
{/* Single Shared Audio Player - shows on all layouts */}
<AudioPlayer />
</>
); );
}; };

View File

@@ -1,7 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Image from "next/image"; import Image from "next/image";
import { Github, Mail } from "lucide-react" import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import { useGlobalSearch } from "./GlobalSearchProvider";
import { import {
Menubar, Menubar,
MenubarCheckboxItem, MenubarCheckboxItem,
@@ -28,9 +30,35 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useIsMobile } from "@/hooks/use-mobile"
import Link from "next/link"
import {
Search,
Home,
List,
Radio,
Users,
Disc,
Music,
Heart,
Grid3X3,
Clock,
Settings,
Circle
} from "lucide-react";
interface MenuProps { interface MenuProps {
toggleSidebar: () => void; toggleSidebar: () => void;
@@ -43,9 +71,28 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isFullScreen, setIsFullScreen] = useState(false) const [isFullScreen, setIsFullScreen] = useState(false)
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isConnected } = useNavidrome(); const { isConnected } = useNavidrome();
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null); const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile();
const { openSpotlight } = useGlobalSearch();
// Navigation items for mobile menu
const navigationItems = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/library/albums', label: 'Albums', icon: Disc },
{ href: '/library/artists', label: 'Artists', icon: Users },
{ href: '/library/songs', label: 'Songs', icon: Circle },
{ href: '/library/playlists', label: 'Playlists', icon: Music },
{ href: '/favorites', label: 'Favorites', icon: Heart },
{ href: '/queue', label: 'Queue', icon: List },
{ href: '/radio', label: 'Radio', icon: Radio },
{ href: '/browse', label: 'Browse', icon: Grid3X3 },
{ href: '/history', label: 'History', icon: Clock },
{ href: '/settings', label: 'Settings', icon: Settings },
];
// For this demo, we'll show connection status instead of user auth // For this demo, we'll show connection status instead of user auth
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected"; const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
@@ -112,36 +159,40 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return ( return (
<> <>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<Menubar {/* Mobile Top Bar - Simplified since navigation is now at bottom */}
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0" {isMobile ? (
style={{ // hey bear!
minWidth: 0, // nothing
WebkitAppRegion: "drag" null
} as React.CSSProperties} ) : (
> /* Desktop Navigation */
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2"> <Menubar
<MenubarMenu> className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
<MenubarTrigger className="font-bold">mice</MenubarTrigger> style={{
<MenubarContent> minWidth: 0,
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem> WebkitAppRegion: "drag"
<MenubarSeparator /> } as React.CSSProperties}
<MenubarItem onClick={() => router.push('/settings')}> >
Preferences <MenubarShortcut>,</MenubarShortcut> <div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
</MenubarItem> <MenubarMenu>
<MenubarSeparator /> <MenubarTrigger className="font-bold">mice</MenubarTrigger>
<MenubarItem onClick={() => isClient && window.close()}> <MenubarContent>
Quit Music <MenubarShortcut>Q</MenubarShortcut> <MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
</MenubarItem> <MenubarSeparator />
</MenubarContent> <MenubarItem onClick={() => router.push('/settings')}>
</MenubarMenu> Preferences <MenubarShortcut>,</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => isClient && window.close()}>
Quit Music <MenubarShortcut>Q</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="relative">File</MenubarTrigger> <MenubarTrigger className="relative">File</MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarSub> <MenubarItem onClick={() => router.push('/library/playlists')}>
<MenubarSubTrigger>New</MenubarSubTrigger> View Playlists
<MenubarSubContent className="w-[230px]">
<MenubarItem>
Playlist <MenubarShortcut>N</MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem disabled> <MenubarItem disabled>
Playlist from Selection <MenubarShortcut>N</MenubarShortcut> Playlist from Selection <MenubarShortcut>N</MenubarShortcut>
@@ -151,8 +202,6 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</MenubarItem> </MenubarItem>
<MenubarItem>Playlist Folder</MenubarItem> <MenubarItem>Playlist Folder</MenubarItem>
<MenubarItem disabled>Genius Playlist</MenubarItem> <MenubarItem disabled>Genius Playlist</MenubarItem>
</MenubarSubContent>
</MenubarSub>
<MenubarItem> <MenubarItem>
Open Stream URL <MenubarShortcut>U</MenubarShortcut> Open Stream URL <MenubarShortcut>U</MenubarShortcut>
</MenubarItem> </MenubarItem>
@@ -279,6 +328,24 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</MenubarMenu> </MenubarMenu>
</div> </div>
</Menubar> </Menubar>
)}
{/* User Profile and Search - Desktop only */}
{!isMobile && (
<div className="ml-auto flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={openSpotlight}
className="flex items-center space-x-2"
title="Search (/ or ⌘K)"
>
<Search className="w-4 h-4" />
<span className="hidden lg:inline">Search</span>
</Button>
<UserProfile variant="desktop" />
</div>
)}
</div> </div>
@@ -314,7 +381,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
) : navidromeUrl ? ( ) : navidromeUrl ? (
navidromeUrl navidromeUrl
) : ( ) : (
<span className="italic text-gray-400">Not set</span> <span className="italic text-gray-400">Auto-configured</span>
)} )}
</span> </span>
</div> </div>

View File

@@ -109,7 +109,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
> >
{album.coverArt && api ? ( {album.coverArt && api ? (
<Image <Image
src={api.getCoverArtUrl(album.coverArt, 32)} src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name} alt={album.name}
width={16} width={16}
height={16} height={16}
@@ -165,7 +165,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
> >
{album.coverArt && api ? ( {album.coverArt && api ? (
<Image <Image
src={api.getCoverArtUrl(album.coverArt, 32)} src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name} alt={album.name}
width={16} width={16}
height={16} height={16}

View File

@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext'; import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useTheme } from '@/app/components/ThemeProvider'; import { useTheme } from '@/app/components/ThemeProvider';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa'; import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
export function LoginForm({ export function LoginForm({
className, className,
@@ -45,20 +45,7 @@ export function LoginForm({
return true; return true;
}); });
// New settings // New settings - removed sidebar and standalone lastfm options
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sidebar-collapsed') === 'true';
}
return false;
});
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
}
return false;
});
// Check if Navidrome is configured via environment variables // Check if Navidrome is configured via environment variables
const hasEnvConfig = React.useMemo(() => { const hasEnvConfig = React.useMemo(() => {
@@ -187,8 +174,6 @@ export function LoginForm({
const handleFinishSetup = () => { const handleFinishSetup = () => {
// Save all settings // Save all settings
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString()); localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
// Mark onboarding as complete // Mark onboarding as complete
localStorage.setItem('onboarding-completed', '1.1.0'); localStorage.setItem('onboarding-completed', '1.1.0');
@@ -252,7 +237,7 @@ export function LoginForm({
if (step === 'settings') { if (step === 'settings') {
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Card> <Card className='py-5'>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaPalette className="w-5 h-5" /> <FaPalette className="w-5 h-5" />
@@ -286,29 +271,6 @@ export function LoginForm({
</Select> </Select>
</div> </div>
{/* Sidebar Settings */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaBars className="w-4 h-4" />
Sidebar Layout
</Label>
<Select
value={sidebarCollapsed ? "collapsed" : "expanded"}
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
You can always toggle this later using the button in the sidebar
</p>
</div>
{/* Last.fm Scrobbling */} {/* Last.fm Scrobbling */}
<div className="grid gap-3"> <div className="grid gap-3">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
@@ -334,31 +296,6 @@ export function LoginForm({
</p> </p>
</div> </div>
{/* Standalone Last.fm */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaLastfm className="w-4 h-4" />
Standalone Last.fm (Advanced)
</Label>
<Select
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{standaloneLastfmEnabled
? "Direct Last.fm API integration (configure in Settings later)"
: "Use only Navidrome's Last.fm integration"}
</p>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button onClick={handleFinishSetup} className="w-full"> <Button onClick={handleFinishSetup} className="w-full">
<FaCheck className="w-4 h-4 mr-2" /> <FaCheck className="w-4 h-4 mr-2" />
@@ -383,7 +320,7 @@ export function LoginForm({
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Card> <Card className="py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" /> <FaServer className="w-5 h-5" />

View File

@@ -58,7 +58,7 @@ const FavoritesPage = () => {
artistId: song.artistId, artistId: song.artistId,
url: api?.getStreamUrl(song.id) || '', url: api?.getStreamUrl(song.id) || '',
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined, coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred starred: !!song.starred
}); });
}; };
@@ -78,7 +78,7 @@ const FavoritesPage = () => {
artistId: song.artistId, artistId: song.artistId,
url: api.getStreamUrl(song.id), url: api.getStreamUrl(song.id),
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred starred: !!song.starred
})); }));
@@ -201,7 +201,7 @@ const FavoritesPage = () => {
<div className="w-12 h-12 relative shrink-0"> <div className="w-12 h-12 relative shrink-0">
{song.coverArt && api ? ( {song.coverArt && api ? (
<Image <Image
src={api.getCoverArtUrl(song.coverArt)} src={api.getCoverArtUrl(song.coverArt, 1200)}
alt={song.album} alt={song.album}
fill fill
className="rounded object-cover" className="rounded object-cover"

View File

@@ -88,6 +88,18 @@
body { body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Hide scrollbars on mobile */
@media (max-width: 768px) {
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
*::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
}
} }
@layer utilities { @layer utilities {
@@ -816,34 +828,170 @@
---break--- ---break---
*/ */
/* /* Mobile-specific optimizations */
@media (max-width: 767px) {
will delete after the new theme replaces the old one /* Improve touch targets for mobile */
since the new theme already has the sidebar colors defined button {
min-height: 44px;
:root { min-width: 44px;
--sidebar: hsl(0 0% 98%); }
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%); /* Better touch feedback */
--sidebar-primary-foreground: hsl(0 0% 98%); button:active {
--sidebar-accent: hsl(240 4.8% 95.9%); transform: scale(0.95);
--sidebar-accent-foreground: hsl(240 5.9% 10%); transition: transform 0.1s ease;
--sidebar-border: hsl(220 13% 91%); }
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Ensure proper viewport behavior */
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* Smooth scrolling for mobile */
.overflow-y-auto {
-webkit-overflow-scrolling: touch;
}
/* Mobile audio player specific */
.mobile-audio-player {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
/* Prevent horizontal scroll */
body {
overflow-x: hidden;
}
} }
.dark { /* Safe area support for mobile devices */
--sidebar: hsl(240 5.9% 10%); .pb-safe {
--sidebar-foreground: hsl(240 4.8% 95.9%); padding-bottom: env(safe-area-inset-bottom, 0.5rem);
--sidebar-primary: hsl(224.3 76.3% 48%); }
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%); .mobile-safe-bottom {
--sidebar-accent-foreground: hsl(240 4.8% 95.9%); margin-bottom: env(safe-area-inset-bottom, 0);
--sidebar-border: hsl(240 3.7% 15.9%); }
--sidebar-ring: hsl(217.2 91.2% 59.8%);
} */ /* Touch-optimized navigation */
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Bottom navigation z-index fix */
.bottom-nav {
z-index: 45;
}
/* Audio player above bottom nav */
.mobile-audio-above-nav {
z-index: 50;
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
}
/* Mobile Audio Player Styles */
.mobile-audio-player {
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.mobile-audio-player button {
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Prevent iOS zoom on input focus */
@media screen and (max-width: 767px) {
input[type="range"] {
font-size: 16px;
}
/* Improve button touch targets */
.mobile-audio-player button {
min-height: 44px;
min-width: 44px;
}
}
/* Better focus states for accessibility */
button:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
/* Improved animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out;
}
/* Safe area insets for mobile devices */
@supports (padding: max(0px)) {
.mobile-safe-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.mobile-safe-top {
padding-top: max(0.5rem, env(safe-area-inset-top));
}
}
/* Progress bar improvements for mobile */
@media (max-width: 767px) {
.progress-mobile {
height: 3px;
cursor: pointer;
-webkit-appearance: none;
touch-action: manipulation;
}
.progress-mobile::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
margin-top: -6px;
}
}
/* /*
---break--- ---break---
*/ */
/* Mobile Bottom Navigation Styles */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
}
.mobile-safe-bottom {
margin-bottom: env(safe-area-inset-bottom, 0);
}
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.mobile-audio-above-nav {
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
}

View File

@@ -26,6 +26,36 @@ export const metadata = {
'max-snippet': -1, 'max-snippet': -1,
}, },
}, },
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black-translucent',
'format-detection': 'telephone=no',
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: '48x48' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon-precomposed.png', sizes: '180x180', type: 'image/png' },
],
},
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}; };
const geistSans = localFont({ const geistSans = localFont({
@@ -47,6 +77,7 @@ export default function Layout({ children }: LayoutProps) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<link rel="manifest" href="/manifest.json" />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `

244
app/library/page.tsx Normal file
View File

@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Music, Users, Disc, ListMusic, Heart, Play } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getNavidromeAPI } from '@/lib/navidrome';
import NavidromeAPI from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
interface Album {
id: string;
name: string;
artist: string;
artistId?: string;
coverArt?: string;
year?: number;
songCount: number;
}
interface LibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
}
export default function LibraryPage() {
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [stats, setStats] = useState<LibraryStats>({ albums: 0, artists: 0, songs: 0, playlists: 0 });
const [loading, setLoading] = useState(true);
const [api, setApi] = useState<NavidromeAPI | null>(null);
const { playAlbum } = useAudioPlayer();
const isMobile = useIsMobile();
useEffect(() => {
const loadLibraryData = async () => {
try {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.error('Navidrome API not available');
return;
}
setApi(navidromeApi);
// Load recent albums
const albumsData = await navidromeApi.getAlbums('newest', 4, 0);
setRecentAlbums(albumsData || []);
// Load library stats
const [allAlbums, allArtists, allPlaylists] = await Promise.all([
navidromeApi.getAlbums('alphabeticalByName', 1, 0), // Just to get count
navidromeApi.getArtists(),
navidromeApi.getPlaylists()
]);
setStats({
albums: allAlbums?.length || 0,
artists: allArtists?.length || 0,
songs: 0, // We don't have a direct method for this
playlists: allPlaylists?.length || 0
});
} catch (error) {
console.error('Failed to load library data:', error);
} finally {
setLoading(false);
}
};
loadLibraryData();
}, []);
const handlePlayAlbum = async (album: Album) => {
try {
await playAlbum(album.id);
} catch (error) {
console.error('Failed to play album:', error);
}
};
const libraryLinks = [
{
href: '/library/albums',
label: 'Albums',
icon: Disc,
description: 'Browse all albums',
count: stats.albums
},
{
href: '/library/artists',
label: 'Artists',
icon: Users,
description: 'Discover artists',
count: stats.artists
},
{
href: '/library/songs',
label: 'Songs',
icon: Music,
description: 'All your music',
count: stats.songs
},
{
href: '/library/playlists',
label: 'Playlists',
icon: ListMusic,
description: 'Your playlists',
count: stats.playlists
},
{
href: '/favorites',
label: 'Favorites',
icon: Heart,
description: 'Starred music',
count: 0
}
];
if (loading) {
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Loading skeleton for library links */}
<div>
<h2 className="text-lg font-semibold mb-3">Browse</h2>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg h-16"></div>
</div>
))}
</div>
</div>
{/* Loading skeleton for recent albums */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg aspect-square mb-2"></div>
<div className="bg-muted h-4 rounded mb-1"></div>
<div className="bg-muted h-3 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Library Navigation - Always at top */}
<div>
{/* <h2 className="text-lg font-semibold mb-3">Browse</h2> */}
<div className="flex flex-col gap-2">
{libraryLinks.map((link) => {
const Icon = link.icon;
return (
<Link key={link.href} href={link.href}>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="p-2">
<div className="flex items-center space-x-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium">{link.label}</h3>
<p className="text-sm text-muted-foreground">{link.description}</p>
</div>
{link.count > 0 && (
<div className="text-sm text-muted-foreground">
{link.count}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
{/* Recently Added Albums - At bottom on mobile, after Browse on desktop */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{recentAlbums.map((album) => (
<Card key={album.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
<CardContent className="p-3">
<Link href={`/album/${album.id}`}>
<div className="relative aspect-square mb-2">
<Image
src={album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 300) : '/default-user.jpg'}
alt={album.name}
width={600}
height={600}
className="object-cover rounded-lg"
sizes="(max-width: 768px) 50vw, 200px"
/>
{!isMobile && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePlayAlbum(album);
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"
>
<Play className="w-8 h-8 text-white fill-white" />
</button>
)}
</div>
<h3 className="font-medium text-sm truncate hover:underline">{album.name}</h3>
<Link
href={`/artist/${album.artistId || album.artist}`}
className="text-xs text-muted-foreground truncate hover:underline block"
onClick={(e) => e.stopPropagation()}
>
{album.artist}
</Link>
{/* {album.year && (
<p className="text-xs text-muted-foreground">{album.year}</p>
)} */}
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -53,7 +53,7 @@ const PlaylistsPage: React.FC = () => {
<ScrollArea> <ScrollArea>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => {
const playlistCoverUrl = playlist.coverArt && api const playlistCoverUrl = playlist.coverArt && api
? api.getCoverArtUrl(playlist.coverArt, 200) ? api.getCoverArtUrl(playlist.coverArt, 600)
: '/default-user.jpg'; : '/default-user.jpg';
return ( return (

View File

@@ -10,13 +10,15 @@ import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Play, Plus, User, Disc } from 'lucide-react'; import { Search, Play, Plus, User, Disc, ChevronLeft, ChevronRight } from 'lucide-react';
import Loading from '@/app/components/loading'; import Loading from '@/app/components/loading';
import { getNavidromeAPI } from '@/lib/navidrome'; import { getNavidromeAPI } from '@/lib/navidrome';
type SortOption = 'title' | 'artist' | 'album' | 'year' | 'duration' | 'track'; type SortOption = 'title' | 'artist' | 'album' | 'year' | 'duration' | 'track';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const ITEMS_PER_PAGE = 50;
export default function SongsPage() { export default function SongsPage() {
const { getAllSongs } = useNavidrome(); const { getAllSongs } = useNavidrome();
const { playTrack, addToQueue, currentTrack } = useAudioPlayer(); const { playTrack, addToQueue, currentTrack } = useAudioPlayer();
@@ -26,6 +28,7 @@ export default function SongsPage() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('title'); const [sortBy, setSortBy] = useState<SortOption>('title');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [currentPage, setCurrentPage] = useState(1);
const api = getNavidromeAPI(); const api = getNavidromeAPI();
useEffect(() => { useEffect(() => {
@@ -100,8 +103,9 @@ export default function SongsPage() {
}); });
setFilteredSongs(filtered); setFilteredSongs(filtered);
setCurrentPage(1); // Reset to first page when filters change
}, [songs, searchQuery, sortBy, sortDirection]); }, [songs, searchQuery, sortBy, sortDirection]);
const handlePlaySong = (song: Song) => { const handlePlayClick = (song: Song) => {
if (!api) { if (!api) {
console.error('Navidrome API not available'); console.error('Navidrome API not available');
return; return;
@@ -114,7 +118,7 @@ export default function SongsPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -135,7 +139,7 @@ export default function SongsPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -154,6 +158,24 @@ export default function SongsPage() {
return currentTrack?.id === song.id; return currentTrack?.id === song.id;
}; };
// Pagination calculations
const totalPages = Math.ceil(filteredSongs.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedSongs = filteredSongs.slice(startIndex, endIndex);
const goToNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const goToPreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
if (loading) { if (loading) {
return <Loading />; return <Loading />;
} }
@@ -165,7 +187,8 @@ export default function SongsPage() {
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Songs</h1> <h1 className="text-3xl font-semibold tracking-tight">Songs</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{filteredSongs.length} of {songs.length} songs Showing {startIndex + 1}-{Math.min(endIndex, filteredSongs.length)} of {filteredSongs.length} songs
{searchQuery && ` (filtered from ${songs.length} total)`}
</p> </p>
</div> </div>
@@ -216,13 +239,13 @@ export default function SongsPage() {
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{filteredSongs.map((song, index) => ( {paginatedSongs.map((song, index) => (
<div <div
key={song.id} key={song.id}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${ className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : '' isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
}`} }`}
onClick={() => handlePlaySong(song)} onClick={() => handlePlayClick(song)}
> >
{/* Track Number / Play Indicator */} {/* Track Number / Play Indicator */}
<div className="w-8 text-center text-sm text-muted-foreground mr-3"> <div className="w-8 text-center text-sm text-muted-foreground mr-3">
@@ -232,7 +255,7 @@ export default function SongsPage() {
</div> </div>
) : ( ) : (
<> <>
<span className="group-hover:hidden">{index + 1}</span> <span className="group-hover:hidden">{startIndex + index + 1}</span>
<Play className="w-4 h-4 mx-auto hidden group-hover:block" /> <Play className="w-4 h-4 mx-auto hidden group-hover:block" />
</> </>
)} )}
@@ -240,7 +263,7 @@ export default function SongsPage() {
{/* Album Art */} {/* Album Art */}
<div className="w-12 h-12 mr-4 shrink-0"> <Image <div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'} src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album} alt={song.album}
width={48} width={48}
height={48} height={48}
@@ -298,6 +321,35 @@ export default function SongsPage() {
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{/* Pagination Controls */}
{filteredSongs.length > ITEMS_PER_PAGE && (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={goToPreviousPage}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,115 +0,0 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Mice',
short_name: 'Mice',
description: 'a very awesome navidrome client',
start_url: '/',
categories: ["music", "entertainment"],
display_override: ['window-controls-overlay'],
display: 'standalone',
background_color: '#0f0f0f',
theme_color: '#0f0f0f',
icons: [
{
src: '/favicon.ico',
type: 'image/x-icon',
sizes: '48x48'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: '/icon-512.png',
type: 'image/png',
sizes: '512x512'
},
{
src: '/icon-192-maskable.png',
type: 'image/png',
sizes: '192x192',
purpose: 'maskable'
},
{
src: './icon-512-maskable.png',
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
}
],
screenshots: [
{
src: '/home-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Home Preview',
form_factor: 'wide'
},
{
src: '/browse-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Browse Preview',
form_factor: 'wide'
},
{
src: '/album-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Album Preview',
form_factor: 'wide'
},
{
src: '/fullscreen-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Fullscreen Preview',
form_factor: 'wide'
}
],
shortcuts: [
{
name: 'Resume Song',
short_name: 'Resume',
description: 'Resume the last played song',
url: '/?action=resume',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Play Recent Albums',
short_name: 'Recent',
description: 'Play from recently added albums',
url: '/?action=recent',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Shuffle Favorites',
short_name: 'Shuffle',
description: 'Shuffle songs from favorite artists',
url: '/?action=shuffle-favorites',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
]
}
}

View File

@@ -6,57 +6,83 @@ import { Tabs, TabsContent } from '../components/ui/tabs';
import { AlbumArtwork } from './components/album-artwork'; import { AlbumArtwork } from './components/album-artwork';
import { useNavidrome } from './components/NavidromeContext'; import { useNavidrome } from './components/NavidromeContext';
import { useEffect, useState, Suspense } from 'react'; import { useEffect, useState, Suspense } from 'react';
import { Album } from '@/lib/navidrome'; import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext'; import { useNavidromeConfig } from './components/NavidromeConfigContext';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext'; import { useAudioPlayer } from './components/AudioPlayerContext';
import { SongRecommendations } from './components/SongRecommendations'; import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
type TimeOfDay = 'morning' | 'afternoon' | 'evening'; type TimeOfDay = 'morning' | 'afternoon' | 'evening';
function MusicPageContent() { function MusicPageContent() {
const { albums, isLoading, api, isConnected } = useNavidrome(); const { api } = useNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer(); const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]); const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]); const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]); const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(true);
const [favoritesLoading, setFavoritesLoading] = useState(true); const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false); const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
// Load albums
useEffect(() => { useEffect(() => {
if (albums.length > 0) { let mounted = true;
// Split albums into recent and newest for display const load = async () => {
const recent = albums.slice(0, Math.ceil(albums.length / 2)); if (!api) return;
const newest = albums.slice(Math.ceil(albums.length / 2)); setAlbumsLoading(true);
setRecentAlbums(recent);
setNewestAlbums(newest);
}
}, [albums]);
useEffect(() => {
const loadFavoriteAlbums = async () => {
if (!api || !isConnected) return;
setFavoritesLoading(true);
try { try {
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage const list = await api.getAlbums('newest', 500);
setFavoriteAlbums(starredAlbums); if (!mounted) return;
} catch (error) { setAllAlbums(list || []);
console.error('Failed to load favorite albums:', error); // Split albums into two sections
const recent = list.slice(0, Math.ceil(list.length / 2));
const newest = list.slice(Math.ceil(list.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
} catch (e) {
console.error('Failed to load albums:', e);
if (mounted) {
setAllAlbums([]);
setRecentAlbums([]);
setNewestAlbums([]);
}
} finally { } finally {
setFavoritesLoading(false); if (mounted) setAlbumsLoading(false);
} }
}; };
load();
return () => { mounted = false; };
}, [api]);
useEffect(() => {
let mounted = true;
const loadFavoriteAlbums = async () => {
if (!api) return;
setFavoritesLoading(true);
try {
const starred = await api.getAlbums('starred', 20);
if (mounted) setFavoriteAlbums(starred || []);
} catch (error) {
console.error('Failed to load favorite albums:', error);
if (mounted) setFavoriteAlbums([]);
} finally {
if (mounted) setFavoritesLoading(false);
}
};
loadFavoriteAlbums(); loadFavoriteAlbums();
}, [api, isConnected]); return () => { mounted = false; };
}, [api]);
// Handle PWA shortcuts // Handle PWA shortcuts
useEffect(() => { useEffect(() => {
const action = searchParams.get('action'); const action = searchParams.get('action');
if (!action || shortcutProcessed || !api || !isConnected) return; if (!action || shortcutProcessed) return;
const handleShortcuts = async () => { const handleShortcuts = async () => {
try { try {
@@ -88,18 +114,20 @@ function MusicPageContent() {
await playAlbum(shuffledAlbums[0].id); await playAlbum(shuffledAlbums[0].id);
// Add remaining albums to queue // Add remaining albums to queue
for (let i = 1; i < shuffledAlbums.length; i++) { const navidromeApi = getNavidromeAPI();
try { if (navidromeApi) {
const albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id); for (let i = 1; i < shuffledAlbums.length; i++) {
albumSongs.forEach(song => { try {
addToQueue({ const songs = await navidromeApi.getAlbumSongs(shuffledAlbums[i].id);
id: song.id, songs.forEach((song: Song) => {
name: song.title, addToQueue({
url: api.getStreamUrl(song.id), id: song.id,
artist: song.artist || 'Unknown Artist', name: song.title,
artistId: song.artistId || '', url: navidromeApi.getStreamUrl(song.id),
album: song.album || 'Unknown Album', artist: song.artist || 'Unknown Artist',
albumId: song.parent, artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0, duration: song.duration || 0,
coverArt: song.coverArt, coverArt: song.coverArt,
starred: !!song.starred starred: !!song.starred
@@ -110,6 +138,7 @@ function MusicPageContent() {
} }
} }
} }
}
break; break;
case 'shuffle-favorites': case 'shuffle-favorites':
@@ -126,18 +155,20 @@ function MusicPageContent() {
await playAlbum(shuffledFavorites[0].id); await playAlbum(shuffledFavorites[0].id);
// Add remaining albums to queue // Add remaining albums to queue
for (let i = 1; i < shuffledFavorites.length; i++) { const navidromeApiFav = getNavidromeAPI();
try { if (navidromeApiFav) {
const albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id); for (let i = 1; i < shuffledFavorites.length; i++) {
albumSongs.forEach(song => { try {
addToQueue({ const songs = await navidromeApiFav.getAlbumSongs(shuffledFavorites[i].id);
id: song.id, songs.forEach((song: Song) => {
name: song.title, addToQueue({
url: api.getStreamUrl(song.id), id: song.id,
artist: song.artist || 'Unknown Artist', name: song.title,
artistId: song.artistId || '', url: navidromeApiFav.getStreamUrl(song.id),
album: song.album || 'Unknown Album', artist: song.artist || 'Unknown Artist',
albumId: song.parent, artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0, duration: song.duration || 0,
coverArt: song.coverArt, coverArt: song.coverArt,
starred: !!song.starred starred: !!song.starred
@@ -148,6 +179,7 @@ function MusicPageContent() {
} }
} }
} }
}
break; break;
} }
setShortcutProcessed(true); setShortcutProcessed(true);
@@ -159,7 +191,7 @@ function MusicPageContent() {
// Delay to ensure data is loaded // Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000); const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]); }, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
// Try to get user name from navidrome context, fallback to 'user' // Try to get user name from navidrome context, fallback to 'user'
let userName = ''; let userName = '';
@@ -194,7 +226,7 @@ function MusicPageContent() {
<div className="relative"> <div className="relative">
<ScrollArea> <ScrollArea>
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{isLoading ? ( {albumsLoading ? (
// Loading skeletons // Loading skeletons
Array.from({ length: 10 }).map((_, i) => ( Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3"> <div key={i} className="w-[220px] shrink-0 space-y-3">
@@ -281,7 +313,7 @@ function MusicPageContent() {
<div className="relative"> <div className="relative">
<ScrollArea> <ScrollArea>
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{isLoading ? ( {albumsLoading ? (
// Loading skeletons // Loading skeletons
Array.from({ length: 10 }).map((_, i) => ( Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3"> <div key={i} className="w-[220px] shrink-0 space-y-3">

View File

@@ -57,7 +57,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -209,7 +209,7 @@ export default function PlaylistPage() {
{/* Album Art */} {/* Album Art */}
<div className="w-12 h-12 mr-4 shrink-0"> <Image <div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'} src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album} alt={song.album}
width={48} width={48}
height={48} height={48}

View File

@@ -3,14 +3,161 @@
import React from 'react'; import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react'; import { Play, X, Disc, Trash2, SkipForward, GripVertical } from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import {
CSS,
} from '@dnd-kit/utilities';
interface SortableQueueItemProps {
track: Track;
index: number;
onPlay: () => void;
onRemove: () => void;
formatDuration: (seconds: number) => string;
}
function SortableQueueItem({ track, index, onPlay, onRemove, formatDuration }: SortableQueueItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${track.id}-${index}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 transition-colors ${
isDragging ? 'bg-accent' : ''
}`}
>
{/* Drag Handle */}
<div
className="mr-3 opacity-60 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing p-1 -m-1 hover:bg-accent rounded"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
{/* Clickable content area for play */}
<div
className="flex items-center flex-1 cursor-pointer"
onClick={onPlay}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
);
}
const QueuePage: React.FC = () => { const QueuePage: React.FC = () => {
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer(); const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue, reorderQueue } = useAudioPlayer();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px of movement before starting drag
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = queue.findIndex((track, index) => `${track.id}-${index}` === active.id);
const newIndex = queue.findIndex((track, index) => `${track.id}-${index}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderQueue(oldIndex, newIndex);
}
}
};
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
@@ -107,67 +254,29 @@ const QueuePage: React.FC = () => {
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <DndContext
{queue.map((track, index) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={`${track.id}-${index}`} onDragEnd={handleDragEnd}
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors" >
onClick={() => skipToTrackInQueue(index)} <SortableContext
> items={queue.map((track, index) => `${track.id}-${index}`)}
{/* Album Art with Play Indicator */} strategy={verticalListSortingStrategy}
<div className="w-12 h-12 mr-4 shrink-0 relative"> >
<Image <div className="space-y-1">
src={track.coverArt || '/default-user.jpg'} {queue.map((track, index) => (
alt={track.album} <SortableQueueItem
width={48} key={`${track.id}-${index}`}
height={48} track={track}
className="w-full h-full object-cover rounded-md" index={index}
onPlay={() => skipToTrackInQueue(index)}
onRemove={() => removeTrackFromQueue(index)}
formatDuration={formatDuration}
/> />
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> ))}
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeTrackFromQueue(index);
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
))} </SortableContext>
</div> </DndContext>
)} )}
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext'; import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome'; import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { TrackContextMenu, AlbumContextMenu, ArtistContextMenu } from '@/app/components/ContextMenus';
import { Search, Play, Plus } from 'lucide-react'; import { Search, Play, Plus } from 'lucide-react';
export default function SearchPage() { export default function SearchPage() {
@@ -34,7 +35,12 @@ export default function SearchPage() {
try { try {
setIsSearching(true); setIsSearching(true);
const results = await search2(query); const results = await search2(query);
setSearchResults(results); // Limit results to 5 of each type
setSearchResults({
artists: results.artists.slice(0, 5),
albums: results.albums.slice(0, 5),
songs: results.songs.slice(0, 5)
});
} catch (error) { } catch (error) {
console.error('Search failed:', error); console.error('Search failed:', error);
setSearchResults({ artists: [], albums: [], songs: [] }); setSearchResults({ artists: [], albums: [], songs: [] });
@@ -51,6 +57,31 @@ export default function SearchPage() {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]); }, [searchQuery]);
// Focus search input when component mounts (for keyboard shortcut navigation)
useEffect(() => {
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, []);
const createTrackFromSong = (song: Song) => {
if (!api) return null;
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
};
};
const handlePlaySong = (song: Song) => { const handlePlaySong = (song: Song) => {
if (!api) { if (!api) {
console.error('Navidrome API not available'); console.error('Navidrome API not available');
@@ -136,25 +167,29 @@ export default function SearchPage() {
)} )}
{/* Artists */} {/* Artists */}
{/* {searchResults.artists.length > 0 && ( {searchResults.artists.length > 0 && (
<div> <div>
<h2 className="text-2xl font-bold mb-4">Artists</h2> <h2 className="text-2xl font-bold mb-4">Artists</h2>
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{searchResults.artists.map((artist) => ( {searchResults.artists.map((artist) => (
<ArtistContextMenu
key={artist.id}
artistId={artist.id}
artistName={artist.name}
>
<ArtistIcon <ArtistIcon
key={artist.id}
artist={artist} artist={artist}
className="shrink-0 overflow-hidden" className="shrink-0 overflow-hidden"
size={190} size={190}
/> />
</ArtistContextMenu>
))} ))}
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
</ScrollArea> </ScrollArea>
</div> </div>
)} */} )}
{/* broken for now */}
{/* Albums */} {/* Albums */}
{searchResults.albums.length > 0 && ( {searchResults.albums.length > 0 && (
@@ -163,14 +198,19 @@ export default function SearchPage() {
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{searchResults.albums.map((album) => ( {searchResults.albums.map((album) => (
<AlbumArtwork <AlbumContextMenu
key={album.id} key={album.id}
album={album} albumId={album.id}
className="shrink-0 w-48" albumName={album.name}
aspectRatio="square" >
width={192} <AlbumArtwork
height={192} album={album}
/> className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
</AlbumContextMenu>
))} ))}
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
@@ -183,54 +223,62 @@ export default function SearchPage() {
<div> <div>
<h2 className="text-2xl font-bold mb-4">Songs</h2> <h2 className="text-2xl font-bold mb-4">Songs</h2>
<div className="space-y-2"> <div className="space-y-2">
{searchResults.songs.slice(0, 10).map((song, index) => ( {searchResults.songs.slice(0, 10).map((song, index) => {
<div key={song.id} className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors"> const track = createTrackFromSong(song);
<div className="w-8 text-center text-sm text-muted-foreground"> if (!track) return null;
<span className="group-hover:hidden">{index + 1}</span>
<Button return (
variant="ghost" <TrackContextMenu key={song.id} track={track}>
size="sm" <div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
onClick={() => handlePlaySong(song)} <div className="w-8 text-center text-sm text-muted-foreground">
className="hidden group-hover:flex h-8 w-8 p-0" <span className="group-hover:hidden">{index + 1}</span>
> <Button
<Play className="w-4 h-4" /> variant="ghost"
</Button> size="sm"
</div> onClick={() => handlePlaySong(song)}
className="hidden group-hover:flex h-8 w-8 p-0"
{/* Song Cover */} >
<div className="shrink-0"> <Image <Play className="w-4 h-4" />
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'} </Button>
alt={song.album} </div>
width={48}
height={48} {/* Song Cover */}
className="w-12 h-12 rounded-md object-cover" <div className="shrink-0">
/> <Image
</div> src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album}
{/* Song Info */} width={48}
<div className="flex-1 min-w-0"> height={48}
<p className="font-medium truncate">{song.title}</p> className="w-12 h-12 rounded-md object-cover"
<p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p> />
</div> </div>
{/* Duration */} {/* Song Info */}
<div className="text-sm text-muted-foreground"> <div className="flex-1 min-w-0">
{formatDuration(song.duration)} <p className="font-medium truncate">{song.title}</p>
</div> <p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p>
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"> {/* Duration */}
<Button <div className="text-sm text-muted-foreground">
variant="ghost" {formatDuration(song.duration)}
size="sm" </div>
onClick={() => handleAddToQueue(song)}
className="h-8 w-8 p-0" {/* Actions */}
> <div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Plus className="w-4 h-4" /> <Button
</Button> variant="ghost"
</div> size="sm"
</div> onClick={() => handleAddToQueue(song)}
))} className="h-8 w-8 p-0"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</TrackContextMenu>
);
})}
{searchResults.songs.length > 10 && ( {searchResults.songs.length > 10 && (
<div className="text-center pt-4"> <div className="text-center pt-4">

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTheme } from '@/app/components/ThemeProvider'; import { useTheme } from '@/app/components/ThemeProvider';
@@ -13,9 +14,10 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts'; import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
import { SidebarCustomization } from '@/app/components/SidebarCustomization'; import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement'; import { SettingsManagement } from '@/app/components/SettingsManagement';
import { CacheManagement } from '@/app/components/CacheManagement'; import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa'; import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react'; import { Settings, ExternalLink, Tag } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
const SettingsPage = () => { const SettingsPage = () => {
const { theme, setTheme, mode, setMode } = useTheme(); const { theme, setTheme, mode, setMode } = useTheme();
@@ -23,6 +25,7 @@ const SettingsPage = () => {
const { toast } = useToast(); const { toast } = useToast();
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm(); const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
const { shortcutType, updateShortcutType } = useSidebarShortcuts(); const { shortcutType, updateShortcutType } = useSidebarShortcuts();
const audioPlayer = useAudioPlayer();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
serverUrl: '', serverUrl: '',
@@ -58,6 +61,7 @@ const SettingsPage = () => {
// Sidebar settings // Sidebar settings
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarVisible, setSidebarVisible] = useState(true); const [sidebarVisible, setSidebarVisible] = useState(true);
const [notifyNowPlaying, setNotifyNowPlaying] = useState(false);
// Initialize client-side state after hydration // Initialize client-side state after hydration
useEffect(() => { useEffect(() => {
@@ -93,6 +97,12 @@ const SettingsPage = () => {
setSidebarVisible(true); // Default to visible setSidebarVisible(true); // Default to visible
} }
// Notifications preference
const savedNotify = localStorage.getItem('playback-notifications-enabled');
if (savedNotify !== null) {
setNotifyNowPlaying(savedNotify === 'true');
}
// Load Last.fm credentials // Load Last.fm credentials
const storedCredentials = localStorage.getItem('lastfm-credentials'); const storedCredentials = localStorage.getItem('lastfm-credentials');
if (storedCredentials) { if (storedCredentials) {
@@ -263,6 +273,43 @@ const SettingsPage = () => {
} }
}; };
const handleNotifyToggle = async (enabled: boolean) => {
setNotifyNowPlaying(enabled);
if (isClient) {
localStorage.setItem('playback-notifications-enabled', enabled.toString());
}
if (enabled && typeof window !== 'undefined' && 'Notification' in window) {
try {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
} catch {}
}
toast({
title: enabled ? 'Notifications Enabled' : 'Notifications Disabled',
description: enabled ? 'You will be notified when a new song starts.' : 'Now playing notifications are off.',
});
};
const handleTestNotification = () => {
if (typeof window === 'undefined') return;
if (!('Notification' in window)) {
toast({ title: 'Not supported', description: 'Browser does not support notifications.', variant: 'destructive' });
return;
}
if (Notification.permission === 'denied') {
toast({ title: 'Permission denied', description: 'Enable notifications in your browser settings.', variant: 'destructive' });
return;
}
const title = 'mice Test Notification';
const body = 'This is how a now playing notification will look.';
try {
new Notification(title, { body, icon: '/icon-192.png', badge: '/icon-192.png' });
} catch {
toast({ title: 'Test Notification', description: body });
}
};
const handleLastFmAuth = () => { const handleLastFmAuth = () => {
if (!lastFmCredentials.apiKey) { if (!lastFmCredentials.apiKey) {
toast({ toast({
@@ -353,7 +400,7 @@ const SettingsPage = () => {
style={{ columnFill: 'balance' }}> style={{ columnFill: 'balance' }}>
{!hasEnvConfig && ( {!hasEnvConfig && (
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" /> <FaServer className="w-5 h-5" />
@@ -442,7 +489,7 @@ const SettingsPage = () => {
)} )}
{hasEnvConfig && ( {hasEnvConfig && (
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" /> <FaServer className="w-5 h-5" />
@@ -469,7 +516,30 @@ const SettingsPage = () => {
</Card> </Card>
)} )}
<Card className="mb-6 break-inside-avoid"> {/* Notifications */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Notifications
</CardTitle>
<CardDescription>Control now playing notifications</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Now playing notifications</p>
<p className="text-sm text-muted-foreground">Show a notification when a new song starts</p>
</div>
<Switch checked={notifyNowPlaying} onCheckedChange={handleNotifyToggle} />
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleTestNotification}>Test notification</Button>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" /> <FaLastfm className="w-5 h-5" />
@@ -547,7 +617,7 @@ const SettingsPage = () => {
</CardContent> </CardContent>
</Card> */} </Card> */}
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
@@ -602,7 +672,7 @@ const SettingsPage = () => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="mb-6 break-inside-avoid"> {/* <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" /> <FaLastfm className="w-5 h-5" />
@@ -695,7 +765,7 @@ const SettingsPage = () => {
</> </>
)} )}
</CardContent> </CardContent>
</Card> </Card> */}
{/* Sidebar Customization */} {/* Sidebar Customization */}
<div className="break-inside-avoid mb-6"> <div className="break-inside-avoid mb-6">
@@ -707,12 +777,12 @@ const SettingsPage = () => {
<SettingsManagement /> <SettingsManagement />
</div> </div>
{/* Cache Management */} {/* Auto-Tagging Settings */}
<div className="break-inside-avoid mb-6"> <div className="break-inside-avoid mb-6">
<CacheManagement /> <AutoTaggingSettings />
</div> </div>
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle>Appearance</CardTitle> <CardTitle>Appearance</CardTitle>
<CardDescription> <CardDescription>
@@ -761,7 +831,88 @@ const SettingsPage = () => {
</Card> </Card>
{/* Theme Preview */} {/* Theme Preview */}
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaCog className="w-5 h-5" />
Audio Settings
</CardTitle>
<CardDescription>
Configure playback and audio effects
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label htmlFor="crossfade-duration">Crossfade Duration</Label>
<Select
value={String(audioPlayer.audioSettings.crossfadeDuration)}
onValueChange={(value) => audioPlayer.updateAudioSettings({ crossfadeDuration: Number(value) })}
>
<SelectTrigger id="crossfade-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Off</SelectItem>
<SelectItem value="2">2 seconds</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="4">4 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
</SelectContent>
</Select>
</div>
{/* Equalizer Preset */}
<div className="space-y-2">
<Label htmlFor="equalizer-preset">Equalizer Preset</Label>
<Select
value={audioPlayer.equalizerPreset}
onValueChange={audioPlayer.setEqualizerPreset}
>
<SelectTrigger id="equalizer-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bassBoost">Bass Boost</SelectItem>
<SelectItem value="trebleBoost">Treble Boost</SelectItem>
<SelectItem value="vocalBoost">Vocal Boost</SelectItem>
</SelectContent>
</Select>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<div>
<Label>ReplayGain</Label>
<p className="text-sm text-muted-foreground">Normalize volume across tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.replayGainEnabled}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ replayGainEnabled: checked })}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<div>
<Label>Gapless Playback</Label>
<p className="text-sm text-muted-foreground">Seamless transitions between tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.gaplessPlayback}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ gaplessPlayback: checked })}
/>
</div> <div className="text-sm text-muted-foreground space-y-2">
<p><strong>Crossfade:</strong> Smooth fade between tracks (2-5 seconds)</p>
<p><strong>Equalizer:</strong> Preset frequency adjustments for different music styles</p>
<p><strong>ReplayGain:</strong> Consistent volume across all tracks in your library</p>
<p><strong>Gapless:</strong> Perfect for live albums and continuous DJ mixes</p>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle>Preview</CardTitle> <CardTitle>Preview</CardTitle>
<CardDescription> <CardDescription>
@@ -789,6 +940,47 @@ const SettingsPage = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Debug Section - Development Only */}
{process.env.NODE_ENV === 'development' && (
<Card className="mb-6 break-inside-avoid py-5 border-orange-200 bg-orange-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-700">
<Settings className="w-5 h-5" />
Debug Tools
</CardTitle>
<CardDescription className="text-orange-600">
Development-only debugging utilities
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => {
// Save Navidrome config before clearing
const navidromeConfig = localStorage.getItem('navidrome-config');
// Clear all localStorage
localStorage.clear();
// Restore Navidrome config
if (navidromeConfig) {
localStorage.setItem('navidrome-config', navidromeConfig);
}
// Reload page to reset state
window.location.reload();
}}
variant="outline"
className="w-full bg-orange-100 border-orange-300 text-orange-700 hover:bg-orange-200"
>
Clear All Data (Keep Navidrome Config)
</Button>
<p className="text-xs text-orange-600 mt-2">
This will clear all localStorage data except your Navidrome server configuration, then reload the page.
</p>
</CardContent>
</Card>
)}
</div> </div>
</div> </div>
)} )}

46
cliff.toml Normal file
View File

@@ -0,0 +1,46 @@
# git-cliff configuration for changelog generation
# https://git-cliff.org
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}{{ commit.scope }}: {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
footer = ""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore|^ci", group = "Miscellaneous" },
]
protect_breaking_commits = false
filter_commits = false
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-0 shadow-sm",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,56 @@
'use client';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface InfiniteScrollProps {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
loadingText?: string;
endMessage?: string;
className?: string;
}
export function InfiniteScroll({
onLoadMore,
hasMore,
isLoading,
loadingText = 'Loading more items...',
endMessage = 'No more items to load',
className
}: InfiniteScrollProps) {
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '100px 0px',
});
useEffect(() => {
if (inView && hasMore && !isLoading) {
onLoadMore();
}
}, [inView, hasMore, isLoading, onLoadMore]);
return (
<div
ref={ref}
className={cn(
'py-4 flex flex-col items-center justify-center w-full',
className
)}
>
{isLoading && (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm text-muted-foreground">{loadingText}</p>
</div>
)}
{!hasMore && !isLoading && (
<p className="text-sm text-muted-foreground">{endMessage}</p>
)}
</div>
);
}

View File

@@ -2,16 +2,16 @@
import * as React from "react" import * as React from "react"
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels" import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) { }: React.ComponentProps<typeof PanelGroup>) {
return ( return (
<ResizablePrimitive.PanelGroup <PanelGroup
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
@@ -24,19 +24,19 @@ function ResizablePanelGroup({
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <Panel data-slot="resizable-panel" {...props} />
} }
function ResizableHandle({ function ResizableHandle({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof PanelResizeHandle> & {
withHandle?: boolean withHandle?: boolean
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <PanelResizeHandle
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
@@ -49,7 +49,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" /> <GripVerticalIcon className="size-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </PanelResizeHandle>
) )
} }

View File

@@ -3,18 +3,14 @@ version: '3.8'
services: services:
mice: mice:
container_name: mice-public container_name: mice-public
image: sillyangel/mice:latest image: sillyangel/mice:dev-latest
ports: ports:
- "40625:40625" - "40625:40625"
environment: environment:
# Navidrome Server Configuration # Navidrome Server Configuration
- NAVIDROME_URL=https://navi.sillyangel.dev # - NAVIDROME_URL=http://navidrome:4533
- NAVIDROME_USERNAME=kryptonite # - NAVIDROME_USERNAME=user
- NAVIDROME_PASSWORD=kryptonite # - NAVIDROME_PASSWORD=password
# PostHog Analytics
- POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
- POSTHOG_HOST=https://us.i.posthog.com
# Application Port # Application Port
- PORT=40625 - PORT=40625
@@ -27,3 +23,24 @@ services:
start_period: 40s start_period: 40s
restart: unless-stopped restart: unless-stopped
navidrome:
container_name: navidrome
image: deluan/navidrome:latest
ports:
- "4533:4533"
environment:
- ND_SCANINTERVAL=1m
- ND_LOGLEVEL=info
- ND_SESSIONTIMEOUT=24h
- ND_PORT=4533
# - ND_BASEURL=/navidrome
# - ND_MUSICFOLDER=/music
volumes:
- navidrome_data:/data
- navidrome_music:/music
restart: unless-stopped
volumes:
navidrome_data:
navidrome_music:

View File

@@ -14,8 +14,6 @@ services:
- NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 - NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
- NEXT_PUBLIC_NAVIDROME_USERNAME=admin - NEXT_PUBLIC_NAVIDROME_USERNAME=admin
- NEXT_PUBLIC_NAVIDROME_PASSWORD=admin - NEXT_PUBLIC_NAVIDROME_PASSWORD=admin
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
- PORT=${PORT:-3000} - PORT=${PORT:-3000}
# Mount source code for development (optional) # Mount source code for development (optional)

View File

@@ -2,9 +2,10 @@ version: '3.8'
services: services:
mice: mice:
image: sillyangel/mice:latest container_name: mice-public
image: sillyangel/mice:dev-latest
ports: ports:
- "${HOST_PORT:-3000}:${PORT:-3000}" - "40625:40625"
environment: environment:
# Navidrome Server Configuration # Navidrome Server Configuration
# These will be injected at runtime using the entrypoint script # These will be injected at runtime using the entrypoint script
@@ -12,19 +13,32 @@ services:
- NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-} - NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-}
- NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-} - NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-}
# PostHog Analytics (optional)
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
# Application Port # Application Port
- PORT=${PORT:-3000} - PORT=40625
# Optional: Add a health check
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${PORT:-3000}"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:40625"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
restart: unless-stopped restart: unless-stopped
navidrome:
container_name: navidrome
image: deluan/navidrome:latest
ports:
- "4533:4533"
environment:
- ND_SCANINTERVAL=1m
- ND_LOGLEVEL=info
- ND_SESSIONTIMEOUT=24h
- ND_PORT=4533
# - ND_BASEURL=/navidrome
# - ND_MUSICFOLDER=/music
volumes:
- navidrome_data:/data
- navidrome_music:/music
restart: unless-stopped

View File

@@ -65,8 +65,6 @@ When running with Docker, use these variable names (without the `NEXT_PUBLIC_` p
- `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set) - `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set)
- `PORT`: Port for the application to listen on (default: `3000`) - `PORT`: Port for the application to listen on (default: `3000`)
- `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`) - `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`)
- `POSTHOG_KEY`: PostHog analytics key (optional)
- `POSTHOG_HOST`: PostHog analytics host (optional)
### Development Environment Variables ### Development Environment Variables
@@ -75,8 +73,6 @@ For local development (non-Docker), use these variable names:
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server - `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username - `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password - `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
**Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables. **Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables.

121
docs/KEYBOARD_SHORTCUTS.md Normal file
View File

@@ -0,0 +1,121 @@
# Keyboard Shortcuts & Queue Management Features
This document outlines the new keyboard shortcuts, queue management, and context menu features added to the music player.
## Keyboard Shortcuts
The following keyboard shortcuts work globally throughout the application:
### Playback Controls
- **Space** - Play/Pause current track
- **→ (Right Arrow)** - Skip to next track
- **← (Left Arrow)** - Skip to previous track
### Volume Controls
- **↑ (Up Arrow)** - Increase volume by 10%
- **↓ (Down Arrow)** - Decrease volume by 10%
- **M** - Toggle mute/unmute
### Navigation
- **/** - Quick search (navigates to search page and focuses input)
### Notes
- Keyboard shortcuts are disabled when typing in input fields
- When in fullscreen player mode, shortcuts are handled by the fullscreen player
- Volume changes are saved to localStorage
## Queue Management
### Drag and Drop Queue Reordering
- **Drag Handle**: Hover over queue items to reveal the grip handle (⋮⋮)
- **Reorder**: Click and drag the handle to reorder tracks in the queue
- **Visual Feedback**: Dragged items become semi-transparent during drag
- **Keyboard Support**: Use Tab to focus items, then Space + Arrow keys to reorder
### Queue Features
- Real-time visual feedback during drag operations
- Maintains playback order after reordering
- Works with both mouse and keyboard navigation
- Accessible drag and drop implementation
## Context Menus (Right-Click)
Right-click on tracks, albums, and artists to access quick actions:
### Track Context Menu
- **Play Now** - Immediately play the selected track
- **Play Next** - Add track to the beginning of the queue
- **Add to Queue** - Add track to the end of the queue
- **Add/Remove from Favorites** - Toggle favorite status
- **Go to Album** - Navigate to the track's album
- **Go to Artist** - Navigate to the track's artist
- **Track Info** - View detailed track information
- **Share** - Share the track
### Album Context Menu
- **Play Album** - Play the entire album from the beginning
- **Add Album to Queue** - Add all album tracks to queue
- **Play Album Next** - Add album tracks to beginning of queue
- **Add to Favorites** - Add album to favorites
- **Go to Artist** - Navigate to the album's artist
- **Album Info** - View detailed album information
- **Share Album** - Share the album
### Artist Context Menu
- **Play All Songs** - Play all songs by the artist
- **Add All to Queue** - Add all artist songs to queue
- **Play All Next** - Add all artist songs to beginning of queue
- **Add to Favorites** - Add artist to favorites
- **Artist Info** - View detailed artist information
- **Share Artist** - Share the artist
## Where to Find These Features
### Keyboard Shortcuts
- Available globally throughout the application
- Work in main player, fullscreen player, and all pages
- Search shortcut (/) works from any page
### Queue Management
- **Queue Page**: `/queue` - Full drag and drop interface
- **Mini Player**: Shows current track and basic controls
- **Fullscreen Player**: Queue management button available
### Context Menus
- **Search Results**: Right-click on any track, album, or artist
- **Album Pages**: Right-click on individual tracks
- **Artist Pages**: Right-click on tracks and albums
- **Queue Page**: Right-click on queued tracks
- **Library Browse**: Right-click on any item
## Technical Implementation
### Components Used
- `useKeyboardShortcuts` hook for global keyboard shortcuts
- `@dnd-kit` for drag and drop functionality
- `@radix-ui/react-context-menu` for context menus
- Custom context menu components for different content types
### Accessibility
- Full keyboard navigation support
- Screen reader compatible
- Focus management
- ARIA labels and descriptions
- High contrast support
## Tips for Users
1. **Keyboard Shortcuts**: Most shortcuts work anywhere in the app, just start typing
2. **Queue Reordering**: Hover over queue items to see the drag handle
3. **Context Menus**: Right-click almost anything to see available actions
4. **Quick Search**: Press `/` from anywhere to jump to search
5. **Volume Control**: Use arrow keys for precise volume adjustment
## Future Enhancements
Potential future additions:
- Custom keyboard shortcut configuration
- More queue management options (clear queue, save as playlist)
- Additional context menu actions (edit metadata, download)
- Gesture support for mobile devices
- Queue templates and smart playlists

168
docs/OFFLINE_DOWNLOADS.md Normal file
View File

@@ -0,0 +1,168 @@
# Offline Downloads Feature
This document describes the offline downloads functionality implemented in the Mice Navidrome client.
## Overview
The offline downloads feature allows users to download music for offline listening using modern web technologies including Service Workers and Cache API, with localStorage fallback for browsers without Service Worker support.
## Components
### 1. Service Worker (`/public/sw.js`)
- Handles audio, image, and API caching
- Manages download operations in the background
- Provides offline audio playback capabilities
- Implements cache-first strategy for downloaded content
### 2. Download Manager Hook (`/hooks/use-offline-downloads.ts`)
- Provides React interface for download operations
- Manages download progress and status
- Handles Service Worker communication
- Provides localStorage fallback for metadata
### 3. Cache Management Component (`/app/components/CacheManagement.tsx`)
- Enhanced to show offline download statistics
- Displays download progress during operations
- Lists downloaded content with removal options
- Shows Service Worker support status
### 4. Offline Indicator Component (`/app/components/OfflineIndicator.tsx`)
- Shows download status for albums and songs
- Provides download/remove buttons
- Displays visual indicators on album artwork
## Features
### Download Capabilities
- **Album Downloads**: Download entire albums with all tracks and artwork
- **Individual Song Downloads**: Download single tracks
- **Progress Tracking**: Real-time download progress with track-by-track updates
- **Error Handling**: Graceful handling of failed downloads with retry options
### Visual Indicators
- **Album Artwork**: Small download icon in top-right corner of downloaded albums
- **Album Pages**: Download buttons and status indicators
- **Song Lists**: Individual download indicators for tracks
- **Library View**: Visual badges showing offline availability
### Offline Storage
- **Service Worker Cache**: True offline storage for audio files and images
- **localStorage Fallback**: Metadata-only storage for limited browser support
- **Progressive Enhancement**: Works in all browsers with varying capabilities
### Cache Management
- **Storage Statistics**: Shows total offline storage usage
- **Content Management**: List and remove downloaded content
- **Cache Cleanup**: Clear expired and unnecessary cache data
- **Progress Monitoring**: Real-time download progress display
## Usage
### Downloading Content
#### From Album Page
1. Navigate to any album page
2. Click the "Download" button (desktop) or small download button (mobile)
3. Monitor progress in the cache management section
4. Downloaded albums show indicators on artwork and in lists
#### From Settings Page
1. Go to Settings → Cache & Offline Downloads
2. View current download statistics
3. Monitor active downloads
4. Manage downloaded content
### Managing Downloads
#### Viewing Downloaded Content
- Settings page shows list of all downloaded albums and songs
- Album artwork displays download indicators
- Individual songs show download status
#### Removing Downloads
- Use the "X" button next to items in the cache management list
- Use "Remove Download" button on album pages
- Clear all cache to remove everything
## Technical Implementation
### Service Worker Features
- **Audio Caching**: Streams are cached using the Subsonic API
- **Image Caching**: Album artwork and avatars cached separately
- **API Caching**: Metadata cached with network-first strategy
- **Background Downloads**: Downloads continue even when page is closed
### Browser Compatibility
- **Full Support**: Modern browsers with Service Worker support
- **Limited Support**: Older browsers get metadata-only caching
- **Progressive Enhancement**: Features gracefully degrade
### Storage Strategy
- **Audio Cache**: Large files stored in Service Worker cache
- **Image Cache**: Artwork cached separately for optimization
- **Metadata Cache**: Song/album information in localStorage
- **Size Management**: Automatic cleanup of old cached content
## Configuration
### Environment Variables
The offline downloads use existing Navidrome configuration:
- `NEXT_PUBLIC_NAVIDROME_URL`
- `NEXT_PUBLIC_NAVIDROME_USERNAME`
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`
### Cache Limits
- Default audio cache: No explicit limit (browser manages)
- Image cache: Optimized sizes based on display requirements
- Metadata: Stored in localStorage with cleanup
## Development Notes
### File Structure
```
hooks/
use-offline-downloads.ts # Main download hook
app/components/
CacheManagement.tsx # Enhanced cache UI
OfflineIndicator.tsx # Download status components
album-artwork.tsx # Updated with indicators
album/[id]/
page.tsx # Enhanced with download buttons
public/
sw.js # Service Worker implementation
```
### API Integration
- Uses existing Navidrome API endpoints
- Leverages Subsonic streaming URLs
- Integrates with current authentication system
- Compatible with existing cache infrastructure
## Future Enhancements
### Planned Features
- **Playlist Downloads**: Download entire playlists
- **Smart Sync**: Automatic download of favorites
- **Storage Limits**: User-configurable storage limits
- **Download Scheduling**: Queue downloads for later
- **Offline Mode Detection**: Automatic offline behavior
### Performance Optimizations
- **Compression**: Audio compression options
- **Quality Selection**: Choose download quality
- **Selective Sync**: Download only specific tracks
- **Background Sync**: Download during idle time
## Troubleshooting
### Common Issues
- **Service Worker not registering**: Check browser console
- **Downloads failing**: Verify Navidrome server connection
- **Storage full**: Clear cache or check browser storage limits
- **Slow downloads**: Check network connection and server performance
### Debug Information
- Browser Developer Tools → Application → Service Workers
- Cache storage inspection in DevTools
- Console logs for download progress and errors
- Network tab for failed requests

135
docs/SPOTLIGHT_SEARCH.md Normal file
View File

@@ -0,0 +1,135 @@
# Spotlight Search Feature
## Overview
The Spotlight Search feature provides a macOS Spotlight-style search interface for your music library, enhanced with Last.fm metadata for rich music information.
## Features
### 🔍 **Instant Search**
- **Global Search**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux) from anywhere in the app
- **Real-time Results**: Search as you type with 300ms debouncing
- **Multiple Types**: Search across tracks, albums, and artists simultaneously
### ⌨️ **Keyboard Navigation**
- `↑`/`↓` arrows to navigate results
- `Enter` to select and play/view
- `Tab` to show detailed information
- `Esc` to close (or close details panel)
### 🎵 **Quick Actions**
- **Play Now**: Click on any result to play immediately
- **Play Next**: Add track to the beginning of queue
- **Add to Queue**: Add track to the end of queue
- **Show Details**: Get rich information from Last.fm
### 🌍 **Last.fm Integration**
When viewing details, you'll see:
- **Artist Biography**: Rich biographical information
- **Statistics**: Play counts and listener numbers
- **Tags**: Genre and style tags
- **Similar Artists**: Discover new music based on your selections
- **Album Art**: High-quality images
## Usage
### Opening Search
- **Keyboard**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux)
- **Mouse**: Click the search button in the top menu bar (desktop)
- **Mobile**: Tap the search icon in the bottom navigation
### Search Tips
- Type partial song names, artist names, or album titles
- Results appear in real-time as you type
- Use keyboard navigation for fastest access
- Press Tab to see detailed Last.fm information
### Quick Actions
- **Tracks**: Play, Play Next, Add to Queue
- **Albums**: View album page, Add entire album to queue
- **Artists**: View artist page, Play all songs
## Last.fm Data
The search integrates with Last.fm to provide:
### Artist Information
- **Bio**: Artist biography and background
- **Stats**: Total plays and listeners globally
- **Similar**: Artists with similar style
- **Tags**: Genre classification and style tags
### Enhanced Discovery
- Click on similar artists to search for them
- Explore tags to discover new genres
- View play statistics to understand popularity
## Keyboard Shortcuts Summary
| Shortcut | Action |
|----------|--------|
| `Cmd+K` / `Ctrl+K` | Open Spotlight Search |
| `↑` / `↓` | Navigate results |
| `Enter` | Select result |
| `Tab` | Show details |
| `Esc` | Close search/details |
| `Space` | Play/Pause (when not in search) |
| `←` / `→` | Previous/Next track |
| `↑` / `↓` | Volume up/down (when not in search) |
| `M` | Toggle mute |
## Implementation Details
### Architecture
- **Global Context**: `GlobalSearchProvider` manages search state
- **Component**: `SpotlightSearch` handles UI and interactions
- **Hooks**: `useKeyboardShortcuts` for global hotkeys
- **Integration**: Uses existing Navidrome search API + Last.fm API
### Performance
- **Debounced Search**: 300ms delay prevents excessive API calls
- **Keyboard Optimized**: All interactions available via keyboard
- **Lazy Loading**: Last.fm data loaded only when details are viewed
- **Caching**: Search results cached during session
### Accessibility
- **Keyboard Navigation**: Full keyboard support
- **Screen Reader**: Proper ARIA labels and descriptions
- **Focus Management**: Automatic focus on search input
- **Visual Feedback**: Clear hover and selection states
## Future Enhancements
### Planned Features
- **Search History**: Remember recent searches
- **Smart Suggestions**: AI-powered search suggestions
- **Scoped Search**: Filter by type (tracks only, albums only, etc.)
- **Advanced Filters**: Date ranges, genres, etc.
- **Playlist Integration**: Search within specific playlists
### Last.fm Enhancements
- **Track Information**: Individual track details from Last.fm
- **Album Reviews**: User reviews and ratings
- **Concert Information**: Upcoming shows and tour dates
- **Scrobbling Integration**: Enhanced scrobbling with search data
## Troubleshooting
### Search Not Working
1. Check Navidrome connection in settings
2. Verify network connectivity
3. Try refreshing the page
### Last.fm Data Missing
1. Last.fm API may be unavailable
2. Artist/album may not exist in Last.fm database
3. Network connectivity issues
### Keyboard Shortcuts Not Working
1. Ensure you're not in an input field
2. Check if fullscreen mode is interfering
3. Try clicking outside any input fields first
The Spotlight Search feature transforms how you discover and interact with your music library, making it faster and more intuitive than ever before!

63
docs/rewrite-commits.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Script to rewrite commits to conventional commit format
# WARNING: This rewrites git history. Only use on branches that haven't been shared or after coordinating with team.
# This script uses git filter-branch to rewrite commit messages
# Run from repository root: bash docs/rewrite-commits.sh
echo "⚠️ WARNING: This will rewrite git history!"
echo "This should only be done on the offline-support branch before merging to main."
echo ""
read -p "Are you sure you want to continue? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted."
exit 1
fi
# Backup current branch
git branch backup-$(date +%Y%m%d-%H%M%S)
# Set up commit message mapping
export FILTER_BRANCH_SQUELCH_WARNING=1
git filter-branch -f --msg-filter '
msg=$(cat)
# Skip if already has conventional commit prefix
if echo "$msg" | grep -qE "^(feat|fix|chore|docs|style|refactor|perf|test|build|ci):"; then
echo "$msg"
# Convert specific commit messages
elif echo "$msg" | grep -q "Use git commit SHA for versioning"; then
echo "fix: use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code"
elif echo "$msg" | grep -q "Fix menubar, add lazy loading"; then
echo "feat: fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists"
elif echo "$msg" | grep -q "Add pagination to library/songs"; then
echo "feat: add pagination to library/songs and remove listening streaks"
elif echo "$msg" | grep -q "Organize documentation"; then
echo "chore: organize documentation - move markdown files to docs/ folder"
elif echo "$msg" | grep -q "Simplify service worker"; then
echo "refactor: simplify service worker by removing offline download functionality"
elif echo "$msg" | grep -q "Remove all offline download"; then
echo "refactor: remove all offline download and caching functionality"
elif echo "$msg" | grep -q "Update pnpm-lock.yaml"; then
echo "chore: update pnpm-lock.yaml to match new overrides configuration"
elif echo "$msg" | grep -q "Remove PostHog analytics"; then
echo "chore: remove PostHog analytics and update dependencies to latest minor versions"
elif echo "$msg" | grep -q "Merge pull request"; then
echo "chore: $(echo "$msg" | sed "s/^Merge/merge/")"
# Default: add chore: prefix to any other commit
else
first_char=$(echo "$msg" | cut -c1 | tr "[:upper:]" "[:lower:]")
rest=$(echo "$msg" | cut -c2-)
echo "chore: ${first_char}${rest}"
fi
' 2025.07.31..HEAD
echo ""
echo "✅ Commits have been rewritten!"
echo "⚠️ To update the remote branch, you'll need to force push:"
echo " git push origin offline-support --force-with-lease"
echo ""
echo "If something went wrong, restore from backup:"
echo " git reset --hard backup-TIMESTAMP"

View File

@@ -15,6 +15,7 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
done done
echo "✅ Environment variable replacement complete" echo "✅ Environment variable replacement complete"
echo "🚀 Starting Next.js application..."
# Execute the container's main process (CMD in Dockerfile) # Execute the command passed as arguments
exec "$@" exec "$@"

603
hooks/use-auto-tagging.ts Normal file
View File

@@ -0,0 +1,603 @@
import { useState, useCallback } from 'react';
import MusicBrainzClient, {
MusicBrainzRelease,
MusicBrainzReleaseDetails,
MusicBrainzRecording,
MusicBrainzRecordingDetails
} from '@/lib/musicbrainz-api';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
import { Album, Song, Artist } from '@/lib/navidrome';
// Define interfaces for the enhanced metadata
// Define interfaces for the enhanced metadata
export interface EnhancedTrackMetadata {
id: string; // Navidrome track ID
title: string; // Track title
artist: string; // Artist name
album: string; // Album name
mbTrackId?: string; // MusicBrainz recording ID
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
trackNumber?: number; // Track number
discNumber?: number; // Disc number
duration?: number; // Duration in seconds
artistCountry?: string; // Artist country
artistType?: string; // Artist type (group, person, etc.)
releaseType?: string; // Release type (album, EP, single, etc.)
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status of the track metadata
confidence: number; // Match confidence (0-100)
}
export interface EnhancedAlbumMetadata {
id: string; // Navidrome album ID
name: string; // Album name
artist: string; // Album artist name
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
country?: string; // Release country
releaseType?: string; // Release type (album, EP, single, etc.)
barcode?: string; // Barcode
label?: string; // Record label
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status
confidence: number; // Match confidence (0-100)
tracks: EnhancedTrackMetadata[]; // Tracks in the album
coverArtUrl?: string; // Cover art URL from MusicBrainz
}
// Type for the Auto-Tagging operation mode
export type AutoTaggingMode = 'track' | 'album' | 'artist';
export function useAutoTagging() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [enhancedTracks, setEnhancedTracks] = useState<EnhancedTrackMetadata[]>([]);
const [enhancedAlbums, setEnhancedAlbums] = useState<EnhancedAlbumMetadata[]>([]);
const { toast } = useToast();
const api = getNavidromeAPI();
/**
* Find enhanced metadata for a single track from MusicBrainz
*/
const enhanceTrack = useCallback(async (track: Song): Promise<EnhancedTrackMetadata> => {
try {
// Start with basic metadata
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track in MusicBrainz
const recording = await MusicBrainzClient.findBestMatchingRecording(
track.title,
track.artist,
track.duration * 1000 // Convert to milliseconds
);
if (!recording) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Get detailed recording information
const recordingDetails = await MusicBrainzClient.getRecording(recording.id);
if (!recordingDetails) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Calculate match confidence
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(recording.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.artist),
MusicBrainzClient.normalizeString(recording['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedTrack.confidence = Math.round((titleSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update track with MusicBrainz metadata
enhancedTrack.mbTrackId = recording.id;
enhancedTrack.mbArtistId = recording['artist-credit'][0]?.artist.id;
// Extract additional metadata from recordingDetails
if (recordingDetails.releases && recordingDetails.releases.length > 0) {
enhancedTrack.mbReleaseId = recordingDetails.releases[0].id;
}
if (recordingDetails['first-release-date']) {
enhancedTrack.year = recordingDetails['first-release-date'].split('-')[0];
}
if (recordingDetails.genres) {
enhancedTrack.genres = recordingDetails.genres.map(genre => genre.name);
}
if (recordingDetails.tags) {
enhancedTrack.tags = recordingDetails.tags.map(tag => tag.name);
}
enhancedTrack.status = 'matched';
return enhancedTrack;
} catch (error) {
console.error('Failed to enhance track:', error);
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'failed',
confidence: 0
};
}
}, []);
/**
* Find enhanced metadata for an album and its tracks from MusicBrainz
*/
const enhanceAlbum = useCallback(async (album: Album, tracks: Song[]): Promise<EnhancedAlbumMetadata> => {
try {
// Start with basic metadata
const enhancedAlbum: EnhancedAlbumMetadata = {
id: album.id,
name: album.name,
artist: album.artist,
status: 'pending',
confidence: 0,
tracks: []
};
// Try to find the album in MusicBrainz
const release = await MusicBrainzClient.findBestMatchingRelease(
album.name,
album.artist,
tracks.length
);
if (!release) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Get detailed release information
const releaseDetails = await MusicBrainzClient.getRelease(release.id);
if (!releaseDetails) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Calculate match confidence
const albumSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.name),
MusicBrainzClient.normalizeString(release.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.artist),
MusicBrainzClient.normalizeString(release['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedAlbum.confidence = Math.round((albumSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update album with MusicBrainz metadata
enhancedAlbum.mbReleaseId = release.id;
enhancedAlbum.mbArtistId = release['artist-credit'][0]?.artist.id;
if (release.date) {
enhancedAlbum.year = release.date.split('-')[0];
}
if (release.country) {
enhancedAlbum.country = release.country;
}
// We need to access release-group via a type assertion since it's not defined in MusicBrainzRelease interface
// But it exists in the MusicBrainzReleaseDetails which we're working with
const releaseWithGroup = release as unknown as { 'release-group'?: { id: string; 'primary-type'?: string } };
if (releaseWithGroup['release-group'] && releaseWithGroup['release-group']['primary-type']) {
enhancedAlbum.releaseType = releaseWithGroup['release-group']['primary-type'];
}
if (releaseDetails.barcode) {
enhancedAlbum.barcode = releaseDetails.barcode;
}
// Get cover art URL
if (releaseDetails['cover-art-archive'] && releaseDetails['cover-art-archive'].front) {
enhancedAlbum.coverArtUrl = MusicBrainzClient.getCoverArtUrl(release.id);
}
// Match tracks with MusicBrainz tracks
const enhancedTracks: EnhancedTrackMetadata[] = [];
// First, organize MB tracks by disc and track number
// Define a type for the MusicBrainz track
interface MusicBrainzTrack {
position: number;
number: string;
title: string;
length?: number;
recording: {
id: string;
title: string;
length?: number;
};
}
const mbTracks: Record<number, Record<number, MusicBrainzTrack>> = {};
if (releaseDetails.media) {
for (const medium of releaseDetails.media) {
const discNumber = medium.position;
mbTracks[discNumber] = {};
for (const track of medium.tracks) {
mbTracks[discNumber][track.position] = track;
}
}
}
// Try to match each track
for (const track of tracks) {
// Basic track info
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track by position if available
if (track.discNumber && track.track && mbTracks[track.discNumber] && mbTracks[track.discNumber][track.track]) {
const mbTrack = mbTracks[track.discNumber][track.track];
enhancedTrack.mbTrackId = mbTrack.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.trackNumber = track.track;
enhancedTrack.discNumber = track.discNumber;
// Calculate title similarity
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
enhancedTrack.confidence = Math.round(titleSimilarity * 100);
enhancedTrack.status = 'matched';
}
// If we can't match by position, try to match by title
else {
// Find in any medium and any position
let bestMatch: MusicBrainzTrack | null = null;
let bestSimilarity = 0;
for (const discNumber of Object.keys(mbTracks)) {
for (const trackNumber of Object.keys(mbTracks[Number(discNumber)])) {
const mbTrack = mbTracks[Number(discNumber)][Number(trackNumber)];
const similarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
if (similarity > bestSimilarity && similarity > 0.6) { // 60% similarity threshold
bestMatch = mbTrack;
bestSimilarity = similarity;
}
}
}
if (bestMatch) {
enhancedTrack.mbTrackId = bestMatch.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.confidence = Math.round(bestSimilarity * 100);
enhancedTrack.status = 'matched';
} else {
enhancedTrack.status = 'failed';
}
}
enhancedTracks.push(enhancedTrack);
}
// Update album with tracks
enhancedAlbum.tracks = enhancedTracks;
enhancedAlbum.status = 'matched';
return enhancedAlbum;
} catch (error) {
console.error('Failed to enhance album:', error);
return {
id: album.id,
name: album.name,
artist: album.artist,
status: 'failed',
confidence: 0,
tracks: []
};
}
}, []);
/**
* Start the auto-tagging process for a track, album, or artist
*/
const startAutoTagging = useCallback(async (
mode: AutoTaggingMode,
itemId: string,
confidenceThreshold: number = 70
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
setEnhancedTracks([]);
setEnhancedAlbums([]);
try {
// Process different modes
if (mode === 'track') {
// In the absence of a direct method to get a song by ID,
// we'll find it by searching for it in its album
const searchResults = await api.search(itemId, 0, 0, 10);
const track = searchResults.songs.find(song => song.id === itemId);
if (!track) {
throw new Error('Track not found');
}
setProgress(10);
// Enhance track metadata
const enhancedTrack = await enhanceTrack(track);
setEnhancedTracks([enhancedTrack]);
setProgress(100);
toast({
title: "Track Analysis Complete",
description: enhancedTrack.status === 'matched'
? `Found metadata for "${track.title}" with ${enhancedTrack.confidence}% confidence`
: `Couldn't find metadata for "${track.title}"`,
});
}
else if (mode === 'album') {
// Get album and its tracks from Navidrome
const { album, songs } = await api.getAlbum(itemId);
if (!album) {
throw new Error('Album not found');
}
setProgress(10);
// Enhance album metadata
const enhancedAlbum = await enhanceAlbum(album, songs);
setEnhancedAlbums([enhancedAlbum]);
setProgress(100);
toast({
title: "Album Analysis Complete",
description: enhancedAlbum.status === 'matched'
? `Found metadata for "${album.name}" with ${enhancedAlbum.confidence}% confidence`
: `Couldn't find metadata for "${album.name}"`,
});
}
else if (mode === 'artist') {
// Get artist and their albums from Navidrome
try {
const { artist, albums } = await api.getArtist(itemId);
if (!artist) {
throw new Error('Artist not found');
}
setProgress(5);
const enhancedAlbumsData: EnhancedAlbumMetadata[] = [];
let processedAlbums = 0;
// Process each album
for (const album of albums) {
try {
const { songs } = await api.getAlbum(album.id);
const enhancedAlbum = await enhanceAlbum(album, songs);
enhancedAlbumsData.push(enhancedAlbum);
} catch (albumError) {
console.error('Error processing album:', albumError);
// Continue with the next album
}
processedAlbums++;
setProgress(5 + Math.round((processedAlbums / albums.length) * 95));
}
setEnhancedAlbums(enhancedAlbumsData);
setProgress(100);
const matchedAlbums = enhancedAlbumsData.filter(album =>
album.status === 'matched' && album.confidence >= confidenceThreshold
).length;
toast({
title: "Artist Analysis Complete",
description: `Found metadata for ${matchedAlbums} of ${albums.length} albums by "${artist.name}"`,
});
} catch (artistError) {
console.error('Error fetching artist:', artistError);
toast({
title: "Artist Not Found",
description: "Could not find the artist in your library",
variant: "destructive",
});
setProgress(100);
}
}
} catch (error) {
console.error('Auto-tagging error:', error);
toast({
title: "Auto-Tagging Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, enhanceTrack, enhanceAlbum, toast]);
/**
* Apply enhanced metadata to tracks in Navidrome
*/
const applyEnhancedMetadata = useCallback(async (
tracks: EnhancedTrackMetadata[],
albums?: EnhancedAlbumMetadata[]
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
try {
let processedItems = 0;
const totalItems = tracks.length + (albums?.length || 0);
// Apply album metadata first
if (albums && albums.length > 0) {
for (const album of albums) {
if (album.status === 'matched') {
// To be implemented: Update album metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update album:', album);
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
}
// Apply track metadata
for (const track of tracks) {
if (track.status === 'matched') {
// To be implemented: Update track metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update track:', track);
// Alternatively, suggest implementing this feature using a separate
// script that interacts with music files directly
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
toast({
title: "Metadata Applied",
description: `Updated metadata for ${tracks.filter(t => t.status === 'matched').length} tracks`,
});
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Metadata Update Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, toast]);
return {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
};
}
/**
* Calculate similarity between two strings (0-1)
* Uses Levenshtein distance
*/
function calculateStringSimilarity(str1: string, str2: string): number {
// If either string is empty, return 0
if (!str1.length || !str2.length) {
return 0;
}
// If strings are identical, return 1
if (str1 === str2) {
return 1;
}
// Calculate Levenshtein distance
const distance = levenshteinDistance(str1, str2);
// Calculate similarity score
const maxLength = Math.max(str1.length, str2.length);
const similarity = 1 - distance / maxLength;
return similarity;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = [];
// Initialize matrix with row and column indices
for (let i = 0; i <= str1.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str2.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= str1.length; i++) {
for (let j = 1; j <= str2.length; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + cost // Substitution
);
}
}
return matrix[str1.length][str2.length];
}

View File

@@ -60,7 +60,7 @@ export function useFavoriteAlbums() {
id: album.id, id: album.id,
name: album.name, name: album.name,
artist: album.artist, artist: album.artist,
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 300) : undefined
}; };
addFavoriteAlbum(favoriteAlbum); addFavoriteAlbum(favoriteAlbum);
} }

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
interface KeyboardShortcutsOptions {
onPlayPause?: () => void;
onNextTrack?: () => void;
onPreviousTrack?: () => void;
onVolumeUp?: () => void;
onVolumeDown?: () => void;
onToggleMute?: () => void;
onSpotlightSearch?: () => void;
disabled?: boolean;
}
export function useKeyboardShortcuts({
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
disabled = false
}: KeyboardShortcutsOptions = {}) {
const router = useRouter();
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Don't trigger shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('[data-cmdk-input]'); // Command palette input
if (disabled || isInputField) return;
// Prevent default behavior for our shortcuts
const preventDefault = () => {
event.preventDefault();
event.stopPropagation();
};
switch (event.key) {
case ' ': // Space - Play/Pause
if (onPlayPause) {
preventDefault();
onPlayPause();
}
break;
case 'ArrowRight': // Right Arrow - Next Track
if (onNextTrack) {
preventDefault();
onNextTrack();
}
break;
case 'ArrowLeft': // Left Arrow - Previous Track
if (onPreviousTrack) {
preventDefault();
onPreviousTrack();
}
break;
case 'ArrowUp': // Up Arrow - Volume Up
if (onVolumeUp) {
preventDefault();
onVolumeUp();
}
break;
case 'ArrowDown': // Down Arrow - Volume Down
if (onVolumeDown) {
preventDefault();
onVolumeDown();
}
break;
case 'm': // M - Toggle Mute
case 'M':
if (onToggleMute) {
preventDefault();
onToggleMute();
}
break;
case 'k': // Cmd+K or Ctrl+K - Spotlight Search
case 'K':
if ((event.metaKey || event.ctrlKey) && onSpotlightSearch) {
preventDefault();
onSpotlightSearch();
}
break;
default:
break;
}
}, [
disabled,
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
router
]);
useEffect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return {
// Return any utility functions if needed
isShortcutActive: !disabled
};
}

View File

@@ -1,110 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface LibraryCacheItem<T> {
data: T;
timestamp: number;
expiresAt: number;
}
export function useLibraryCache<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 30 * 60 * 1000 // 30 minutes default
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const getCacheKey = (key: string) => `library-cache-${key}`;
const getFromCache = (key: string): T | null => {
if (typeof window === 'undefined') return null;
try {
const cached = localStorage.getItem(getCacheKey(key));
if (!cached) return null;
const item: LibraryCacheItem<T> = JSON.parse(cached);
// Check if expired
if (Date.now() > item.expiresAt) {
localStorage.removeItem(getCacheKey(key));
return null;
}
return item.data;
} catch (error) {
console.warn('Failed to get cached data:', error);
localStorage.removeItem(getCacheKey(key));
return null;
}
};
const setToCache = (key: string, data: T, ttl: number) => {
if (typeof window === 'undefined') return;
const item: LibraryCacheItem<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
try {
localStorage.setItem(getCacheKey(key), JSON.stringify(item));
} catch (error) {
console.warn('Failed to cache data:', error);
}
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
setError(null);
// Check cache first
const cached = getFromCache(key);
if (cached) {
setData(cached);
setLoading(false);
return;
}
// Fetch fresh data
try {
const result = await fetcher();
setData(result);
setToCache(key, result, ttl);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
loadData();
}, [key, ttl]);
const refresh = async () => {
setLoading(true);
setError(null);
try {
const result = await fetcher();
setData(result);
setToCache(key, result, ttl);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
const clearCache = () => {
if (typeof window === 'undefined') return;
localStorage.removeItem(getCacheKey(key));
};
return { data, loading, error, refresh, clearCache };
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
const INITIAL_BATCH_SIZE = 24; // Initial number of albums to load
const BATCH_SIZE = 24; // Number of albums to load in each batch
const SCROLL_THRESHOLD = 200; // Pixels from bottom before loading more
export type AlbumSortOption = 'alphabeticalByName' | 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByArtist' | 'starred' | 'highest';
export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabeticalByName') {
const [albums, setAlbums] = useState<Album[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentOffset, setCurrentOffset] = useState(0);
const { api } = useNavidrome();
const [error, setError] = useState<string | null>(null);
// Load initial batch
useEffect(() => {
loadInitialBatch();
}, [sortBy]);
// Cleanup when sort changes
useEffect(() => {
return () => {
setAlbums([]);
setCurrentOffset(0);
setHasMore(true);
};
}, [sortBy]);
// Load initial batch of albums
const loadInitialBatch = useCallback(async () => {
if (!api) return;
setIsLoading(true);
setError(null);
try {
const albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0);
setAlbums(albumData);
setCurrentOffset(albumData.length);
// Assume there are more unless we got fewer than we asked for
setHasMore(albumData.length >= INITIAL_BATCH_SIZE);
} catch (err) {
console.error('Failed to load initial albums batch:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading albums');
setAlbums([]);
setHasMore(false);
} finally {
setIsLoading(false);
}
}, [api, sortBy]);
// Load more albums when scrolling
const loadMoreAlbums = useCallback(async () => {
if (isLoading || !hasMore || !api) return;
setIsLoading(true);
try {
const newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
// If we get fewer albums than we asked for, we've reached the end
setHasMore(newAlbums.length >= BATCH_SIZE);
} catch (err) {
console.error('Failed to load more albums:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading more albums');
setHasMore(false);
} finally {
setIsLoading(false);
}
}, [api, currentOffset, isLoading, hasMore, sortBy]);
// Manual refresh (useful for pull-to-refresh functionality)
const refreshAlbums = useCallback(() => {
setAlbums([]);
setCurrentOffset(0);
setHasMore(true);
loadInitialBatch();
}, [loadInitialBatch]);
// Setup scroll listener
useEffect(() => {
const handleScroll = () => {
// Don't trigger if already loading
if (isLoading || !hasMore) return;
// Check if we're near the bottom
const scrollHeight = document.documentElement.scrollHeight;
const currentScroll = window.innerHeight + document.documentElement.scrollTop;
if (scrollHeight - currentScroll <= SCROLL_THRESHOLD) {
loadMoreAlbums();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [isLoading, hasMore, loadMoreAlbums]);
return {
albums,
isLoading,
hasMore,
loadMoreAlbums,
refreshAlbums,
error,
resetAndLoad: refreshAlbums // Alias for consistency
};
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useRef } from 'react';
interface UseResponsiveImageSizeOptions {
/** Minimum size threshold */
minSize?: number;
/** Maximum size threshold */
maxSize?: number;
/** Multiplier for high DPI displays */
dpiMultiplier?: number;
/** Available size tiers from Navidrome */
availableSizes?: number[];
}
/**
* Hook to calculate optimal image size based on container dimensions
*/
export function useResponsiveImageSize(options: UseResponsiveImageSizeOptions = {}) {
const {
minSize = 60,
maxSize = 1200,
dpiMultiplier = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1,
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
} = options;
const containerRef = useRef<HTMLElement>(null);
const [imageSize, setImageSize] = useState<number>(300); // Default fallback
useEffect(() => {
const calculateOptimalSize = () => {
if (!containerRef.current) return;
const element = containerRef.current;
const rect = element.getBoundingClientRect();
// Use the larger dimension (width or height) as base
const displaySize = Math.max(rect.width, rect.height);
// Account for device pixel ratio for crisp images on high DPI displays
const targetSize = Math.round(displaySize * dpiMultiplier);
// Clamp to min/max bounds
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
// Find the next larger available size to ensure quality
const optimalSize = availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
setImageSize(optimalSize);
};
// Calculate initial size
calculateOptimalSize();
// Recalculate on resize
const resizeObserver = new ResizeObserver(calculateOptimalSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [minSize, maxSize, dpiMultiplier, availableSizes]);
return {
containerRef,
imageSize,
/** Get size for a specific display dimension */
getSizeForDimension: (dimension: number) => {
const targetSize = Math.round(dimension * dpiMultiplier);
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
}
};
}
/**
* Simple function to get optimal image size for known dimensions
*/
export function getOptimalImageSize(
displayWidth: number,
displayHeight: number,
options: Omit<UseResponsiveImageSizeOptions, 'availableSizes'> & { availableSizes?: number[] } = {}
): number {
const {
minSize = 60,
maxSize = 1200,
dpiMultiplier = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
} = options;
const displaySize = Math.max(displayWidth, displayHeight);
const targetSize = Math.round(displaySize * dpiMultiplier);
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
}

169
lib/audio-effects.ts Normal file
View File

@@ -0,0 +1,169 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export interface AudioEffectPreset {
name: string;
gains: number[]; // Gains for different frequency bands
frequencies: number[]; // Center frequencies for each band
}
export const presets: { [key: string]: AudioEffectPreset } = {
normal: {
name: "Normal",
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
bassBoost: {
name: "Bass Boost",
gains: [7, 5, 3, 2, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
trebleBoost: {
name: "Treble Boost",
gains: [0, 0, 0, 0, 0, 0, 2, 3, 5, 7],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
vocalBoost: {
name: "Vocal Boost",
gains: [0, 0, 0, 2, 4, 4, 2, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
}
};
export class AudioEffects {
private context: AudioContext;
private source: MediaElementAudioSourceNode | null = null;
private destination: AudioDestinationNode;
private filters: BiquadFilterNode[] = [];
private gainNode: GainNode;
private crossfadeGainNode: GainNode;
private analyser: AnalyserNode;
private replayGainNode: GainNode;
private currentPreset: string = 'normal';
constructor(audioElement: HTMLAudioElement) {
// Properly type the AudioContext initialization
this.context = new (window.AudioContext || window.webkitAudioContext || AudioContext)();
this.destination = this.context.destination;
this.gainNode = this.context.createGain();
this.crossfadeGainNode = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.replayGainNode = this.context.createGain();
// Initialize ReplayGain node
this.replayGainNode.gain.value = 1.0;
// Create the audio processing chain
this.setupAudioChain(audioElement);
// Initialize EQ filters
this.setupEqualizer();
}
private setupAudioChain(audioElement: HTMLAudioElement) {
// Disconnect any existing source
if (this.source) {
this.source.disconnect();
}
// Create new source from audio element
this.source = this.context.createMediaElementSource(audioElement);
// Connect the audio processing chain
this.source
.connect(this.replayGainNode)
.connect(this.gainNode)
.connect(this.crossfadeGainNode);
// Connect filters in series
let lastNode: AudioNode = this.crossfadeGainNode;
this.filters.forEach(filter => {
lastNode.connect(filter);
lastNode = filter;
});
// Connect to analyser and destination
lastNode.connect(this.analyser);
this.analyser.connect(this.destination);
}
private setupEqualizer() {
// Create 10-band EQ
presets.normal.frequencies.forEach((freq, index) => {
const filter = this.context.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1.0;
filter.gain.value = 0;
this.filters.push(filter);
});
}
public setPreset(presetName: string) {
if (presets[presetName]) {
this.currentPreset = presetName;
presets[presetName].gains.forEach((gain, index) => {
if (this.filters[index]) {
this.filters[index].gain.setValueAtTime(gain, this.context.currentTime);
}
});
}
}
public getCurrentPreset(): string {
return this.currentPreset;
}
public setVolume(volume: number) {
if (this.gainNode) {
this.gainNode.gain.setValueAtTime(volume, this.context.currentTime);
}
}
public setCrossfadeTime(seconds: number) {
if (this.crossfadeGainNode) {
const now = this.context.currentTime;
this.crossfadeGainNode.gain.setValueAtTime(1, now);
this.crossfadeGainNode.gain.linearRampToValueAtTime(0, now + seconds);
}
}
public startCrossfade() {
if (this.crossfadeGainNode) {
this.crossfadeGainNode.gain.value = 1;
}
}
public setReplayGain(gain: number) {
if (this.replayGainNode) {
// Clamp gain between -12dB and +12dB for safety
const clampedGain = Math.max(-12, Math.min(12, gain));
const gainValue = Math.pow(10, clampedGain / 20); // Convert dB to linear gain
this.replayGainNode.gain.setValueAtTime(gainValue, this.context.currentTime);
}
}
public getAnalyserNode(): AnalyserNode {
return this.analyser;
}
public async resume() {
if (this.context.state === 'suspended') {
await this.context.resume();
}
}
public disconnect() {
if (this.source) {
this.source.disconnect();
}
this.filters.forEach(filter => filter.disconnect());
this.gainNode.disconnect();
this.crossfadeGainNode.disconnect();
this.analyser.disconnect();
this.replayGainNode.disconnect();
}
}

View File

@@ -1,258 +0,0 @@
'use client';
// Types for caching (simplified versions to avoid circular imports)
interface Album {
id: string;
name: string;
artist: string;
artistId: string;
coverArt?: string;
songCount: number;
duration: number;
playCount?: number;
created: string;
starred?: string;
year?: number;
genre?: string;
}
interface Artist {
id: string;
name: string;
albumCount: number;
starred?: string;
coverArt?: string;
}
interface Song {
id: string;
parent: string;
isDir: boolean;
title: string;
artist?: string;
artistId?: string;
album?: string;
albumId?: string;
year?: number;
genre?: string;
coverArt?: string;
size?: number;
contentType?: string;
suffix?: string;
starred?: string;
duration?: number;
bitRate?: number;
path?: string;
playCount?: number;
created: string;
}
export interface CacheItem<T> {
data: T;
timestamp: number;
expiresAt: number;
}
export interface CacheConfig {
defaultTTL: number; // Time to live in milliseconds
maxSize: number; // Maximum number of items in cache
}
class Cache<T> {
private cache = new Map<string, CacheItem<T>>();
private config: CacheConfig;
constructor(config: CacheConfig = { defaultTTL: 24 * 60 * 60 * 1000, maxSize: 1000 }) {
this.config = config;
}
set(key: string, data: T, ttl?: number): void {
const now = Date.now();
const expiresAt = now + (ttl || this.config.defaultTTL);
// Remove expired items before adding new one
this.cleanup();
// If cache is at max size, remove oldest item
if (this.cache.size >= this.config.maxSize) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
data,
timestamp: now,
expiresAt
});
}
get(key: string): T | null {
const item = this.cache.get(key);
if (!item) return null;
// Check if item has expired
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return null;
}
return item.data;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
size(): number {
this.cleanup();
return this.cache.size;
}
keys(): string[] {
this.cleanup();
return Array.from(this.cache.keys());
}
private cleanup(): void {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now > item.expiresAt) {
this.cache.delete(key);
}
}
}
// Get cache statistics
getStats() {
this.cleanup();
const items = Array.from(this.cache.values());
const totalSize = items.length;
const oldestItem = items.reduce((oldest, item) =>
!oldest || item.timestamp < oldest.timestamp ? item : oldest, null as CacheItem<T> | null);
const newestItem = items.reduce((newest, item) =>
!newest || item.timestamp > newest.timestamp ? item : newest, null as CacheItem<T> | null);
return {
size: totalSize,
maxSize: this.config.maxSize,
oldestTimestamp: oldestItem?.timestamp,
newestTimestamp: newestItem?.timestamp,
defaultTTL: this.config.defaultTTL
};
}
}
// Specific cache instances
export const albumCache = new Cache<Album[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 500 }); // 24 hours
export const artistCache = new Cache<Artist[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 200 }); // 24 hours
export const songCache = new Cache<Song[]>({ defaultTTL: 12 * 60 * 60 * 1000, maxSize: 1000 }); // 12 hours
export const imageCache = new Cache<string>({ defaultTTL: 7 * 24 * 60 * 60 * 1000, maxSize: 1000 }); // 7 days for image URLs
// Cache management utilities
export const CacheManager = {
clearAll() {
albumCache.clear();
artistCache.clear();
songCache.clear();
imageCache.clear();
// Also clear localStorage cache data
if (typeof window !== 'undefined') {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
localStorage.removeItem(key);
}
});
}
},
getStats() {
return {
albums: albumCache.getStats(),
artists: artistCache.getStats(),
songs: songCache.getStats(),
images: imageCache.getStats()
};
},
getCacheSizeBytes() {
if (typeof window === 'undefined') return 0;
let size = 0;
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
size += localStorage.getItem(key)?.length || 0;
}
});
return size;
}
};
// Persistent cache for localStorage
export const PersistentCache = {
set<T>(key: string, data: T, ttl: number = 24 * 60 * 60 * 1000): void {
if (typeof window === 'undefined') return;
const item: CacheItem<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
try {
localStorage.setItem(`cache-${key}`, JSON.stringify(item));
} catch (error) {
console.warn('Failed to store in localStorage cache:', error);
}
},
get<T>(key: string): T | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(`cache-${key}`);
if (!stored) return null;
const item: CacheItem<T> = JSON.parse(stored);
// Check if expired
if (Date.now() > item.expiresAt) {
localStorage.removeItem(`cache-${key}`);
return null;
}
return item.data;
} catch (error) {
console.warn('Failed to read from localStorage cache:', error);
return null;
}
},
delete(key: string): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(`cache-${key}`);
},
clear(): void {
if (typeof window === 'undefined') return;
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-')) {
localStorage.removeItem(key);
}
});
}
};

38
lib/gravatar.ts Normal file
View File

@@ -0,0 +1,38 @@
import crypto from 'crypto';
/**
* Generate a Gravatar URL from an email address
* @param email - The email address
* @param size - The size of the image (default: 80)
* @param defaultImage - Default image type if no Gravatar found (default: 'identicon')
* @returns The Gravatar URL
*/
export function getGravatarUrl(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
// Normalize email: trim whitespace and convert to lowercase
const normalizedEmail = email.trim().toLowerCase();
// i love md5 hash (no i dont)
// Generate MD5 hash of the email
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');
// Construct the Gravatar URL
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultImage}`;
}
/**
* Generate a Gravatar URL with retina support (2x size)
* @param email - The email address
* @param size - The base size of the image
* @param defaultImage - Default image type if no Gravatar found
* @returns The Gravatar URL at 2x resolution
*/
export function getGravatarUrlRetina(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
return getGravatarUrl(email, size * 2, defaultImage);
}

207
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* Utility functions for calculating optimal image sizes for different contexts
*/
export interface ImageSizeContext {
/** The display width in CSS pixels */
displayWidth: number;
/** The display height in CSS pixels */
displayHeight: number;
/** Device pixel ratio for high-DPI displays */
devicePixelRatio?: number;
/** Additional scaling factor (e.g., for hover effects) */
scaleFactor?: number;
}
/**
* Calculate the optimal image size for the given context
* Takes into account device pixel ratio and potential scaling effects
*/
export function calculateOptimalImageSize(context: ImageSizeContext): number {
const { displayWidth, displayHeight, devicePixelRatio = 1, scaleFactor = 1.1 } = context;
// Use the larger dimension to ensure we cover the entire display area
const baseDimension = Math.max(displayWidth, displayHeight);
// Account for device pixel ratio and potential scaling
const optimalSize = Math.ceil(baseDimension * devicePixelRatio * scaleFactor);
// Cap at reasonable maximum to avoid excessive bandwidth usage
return Math.min(optimalSize, 1200);
}
/**
* Get optimal image size for common component contexts
* All sizes are clean divisions of 1200 for optimal scaling
*/
export const ImageSizes = {
// Small thumbnails in lists - 1200/20 = 60, rounded to 64 for better display
THUMBNAIL: 60,
// Small album covers in compact views - 1200/10 = 120
SMALL_ALBUM: 120,
// Medium album covers in grid views - 1200/5 = 240
MEDIUM_ALBUM: 240,
// Large album covers in detail views - 1200/3 = 400
LARGE_ALBUM: 400,
// Extra large for full-screen displays - 1200/2 = 600
XLARGE_ALBUM: 600,
// Full resolution - 1200/1 = 1200
FULL_ALBUM: 1200,
// Artist images
ARTIST_SMALL: 120, // 1200/10
ARTIST_MEDIUM: 240, // 1200/5
ARTIST_LARGE: 400, // 1200/3
// Player images
PLAYER_MINI: 60, // 1200/20
PLAYER_COMPACT: 120, // 1200/10
PLAYER_FULL: 400, // 1200/3
} as const;
/**
* Get responsive image size based on container and viewport
*/
export function getResponsiveImageSize(
containerWidth: number,
viewportWidth: number = typeof window !== 'undefined' ? window?.innerWidth || 1920 : 1920,
devicePixelRatio: number = typeof window !== 'undefined' ? window?.devicePixelRatio || 1 : 1
): number {
let targetSize: number;
// Determine base size based on container and viewport
// All sizes are clean divisions of 1200
if (containerWidth <= 60) {
targetSize = ImageSizes.THUMBNAIL; // 60px
} else if (containerWidth <= 120) {
targetSize = ImageSizes.SMALL_ALBUM; // 120px
} else if (containerWidth <= 240 || viewportWidth <= 768) {
targetSize = ImageSizes.MEDIUM_ALBUM; // 240px
} else if (containerWidth <= 400 || viewportWidth <= 1024) {
targetSize = ImageSizes.LARGE_ALBUM; // 400px
} else if (containerWidth <= 600 || viewportWidth <= 1440) {
targetSize = ImageSizes.XLARGE_ALBUM; // 600px
} else {
targetSize = ImageSizes.FULL_ALBUM; // 1200px
}
// Apply device pixel ratio but ensure we stay within clean divisions of 1200
const scaledSize = Math.ceil(targetSize * devicePixelRatio);
// Round to nearest clean division of 1200
const divisions = [60, 120, 240, 400, 600, 1200];
return divisions.find(size => size >= scaledSize) || 1200;
}
/**
* Hook to get optimal image size for a container
* Returns clean divisions of 1200 for optimal scaling
*/
export function useOptimalImageSize(
width: number,
height: number = width,
scaleFactor: number = 1.1
): number {
if (typeof window === 'undefined') {
// SSR fallback - return appropriate size based on dimensions
return getResponsiveImageSize(width, 1920, 1);
}
const optimalSize = calculateOptimalImageSize({
displayWidth: width,
displayHeight: height,
devicePixelRatio: window.devicePixelRatio || 1,
scaleFactor,
});
// Round to nearest clean division of 1200
const divisions = [60, 120, 240, 400, 600, 1200];
return divisions.find(size => size >= optimalSize) || 1200;
}
/**
* Extract dominant color from an image
* @param imageUrl - URL of the image to analyze
* @returns Promise that resolves to CSS color string (rgb format)
*/
export async function extractDominantColor(imageUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve('rgb(25, 25, 25)'); // Fallback dark color
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Simple dominant color extraction
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let r = 0, g = 0, b = 0;
// Sample points across the image (for performance, not using all pixels)
const sampleSize = Math.max(1, Math.floor(data.length / 4000));
let sampleCount = 0;
for (let i = 0; i < data.length; i += 4 * sampleSize) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
sampleCount++;
}
r = Math.floor(r / sampleCount);
g = Math.floor(g / sampleCount);
b = Math.floor(b / sampleCount);
// Adjust brightness to ensure readability
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// For very light colors, darken them
if (brightness > 200) {
const darkFactor = 0.7;
r = Math.floor(r * darkFactor);
g = Math.floor(g * darkFactor);
b = Math.floor(b * darkFactor);
}
// For very dark colors, lighten them slightly
if (brightness < 50) {
const lightFactor = 1.3;
r = Math.min(255, Math.floor(r * lightFactor));
g = Math.min(255, Math.floor(g * lightFactor));
b = Math.min(255, Math.floor(b * lightFactor));
}
resolve(`rgb(${r}, ${g}, ${b})`);
} catch (error) {
console.error('Error extracting color:', error);
resolve('rgb(25, 25, 25)'); // Fallback dark color
}
};
img.onerror = () => {
resolve('rgb(25, 25, 25)'); // Fallback dark color
};
img.src = imageUrl;
} catch (error) {
console.error('Error loading image for color extraction:', error);
resolve('rgb(25, 25, 25)'); // Fallback dark color
}
});
}

655
lib/indexeddb.ts Normal file
View File

@@ -0,0 +1,655 @@
'use client';
export interface LibraryItem {
id: string;
lastModified: number;
synced: boolean;
}
export interface OfflineAlbum extends LibraryItem {
name: string;
artist: string;
artistId: string;
coverArt?: string;
songCount: number;
duration: number;
playCount?: number;
created: string;
starred?: string;
year?: number;
genre?: string;
}
export interface OfflineArtist extends LibraryItem {
name: string;
albumCount: number;
starred?: string;
coverArt?: string;
}
export interface OfflineSong extends LibraryItem {
parent: string;
isDir: boolean;
title: string;
album: string;
artist: string;
track?: number;
year?: number;
genre?: string;
coverArt?: string;
size: number;
contentType: string;
suffix: string;
duration: number;
bitRate?: number;
path: string;
playCount?: number;
discNumber?: number;
created: string;
albumId: string;
artistId: string;
type: string;
starred?: string;
}
export interface OfflinePlaylist extends LibraryItem {
name: string;
comment?: string;
owner: string;
public: boolean;
songCount: number;
duration: number;
created: string;
changed: string;
coverArt?: string;
songIds: string[];
}
export interface SyncMetadata<T = unknown> {
key: string;
value: T;
lastUpdated: number;
}
// Shape for queued operations' data payloads
export type SyncOperationData =
| { star: true } // star
| { star: false } // unstar
| { name: string; songIds?: string[] } // create_playlist
| { name?: string; comment?: string; songIds?: string[] } // update_playlist
| Record<string, never>; // delete_playlist, scrobble, or empty
export interface SyncOperation {
id: string;
type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble';
entityType: 'song' | 'album' | 'artist' | 'playlist';
entityId: string;
data: SyncOperationData;
timestamp: number;
retryCount: number;
}
export interface LibrarySyncStats {
albums: number;
artists: number;
songs: number;
playlists: number;
lastSync: Date | null;
pendingOperations: number;
storageSize: number;
syncInProgress: boolean;
}
class OfflineLibraryDB {
private dbName = 'stillnavidrome-offline';
private dbVersion = 2;
private db: IDBDatabase | null = null;
private isInitialized = false;
async initialize(): Promise<boolean> {
if (this.isInitialized && this.db) {
return true;
}
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
return false;
}
try {
this.db = await this.openDatabase();
this.isInitialized = true;
return true;
} catch (error) {
console.error('Failed to initialize offline library:', error);
return false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Albums store
if (!db.objectStoreNames.contains('albums')) {
const albumsStore = db.createObjectStore('albums', { keyPath: 'id' });
albumsStore.createIndex('artist', 'artist', { unique: false });
albumsStore.createIndex('artistId', 'artistId', { unique: false });
albumsStore.createIndex('starred', 'starred', { unique: false });
albumsStore.createIndex('synced', 'synced', { unique: false });
albumsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Artists store
if (!db.objectStoreNames.contains('artists')) {
const artistsStore = db.createObjectStore('artists', { keyPath: 'id' });
artistsStore.createIndex('name', 'name', { unique: false });
artistsStore.createIndex('starred', 'starred', { unique: false });
artistsStore.createIndex('synced', 'synced', { unique: false });
artistsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Songs store
if (!db.objectStoreNames.contains('songs')) {
const songsStore = db.createObjectStore('songs', { keyPath: 'id' });
songsStore.createIndex('albumId', 'albumId', { unique: false });
songsStore.createIndex('artistId', 'artistId', { unique: false });
songsStore.createIndex('starred', 'starred', { unique: false });
songsStore.createIndex('synced', 'synced', { unique: false });
songsStore.createIndex('lastModified', 'lastModified', { unique: false });
songsStore.createIndex('title', 'title', { unique: false });
}
// Playlists store
if (!db.objectStoreNames.contains('playlists')) {
const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' });
playlistsStore.createIndex('name', 'name', { unique: false });
playlistsStore.createIndex('owner', 'owner', { unique: false });
playlistsStore.createIndex('synced', 'synced', { unique: false });
playlistsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Sync operations queue
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
syncStore.createIndex('type', 'type', { unique: false });
syncStore.createIndex('entityType', 'entityType', { unique: false });
}
// Metadata store for sync info and settings
if (!db.objectStoreNames.contains('metadata')) {
const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' });
}
};
});
}
// Metadata operations
async setMetadata<T>(key: string, value: T): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
const metadata: SyncMetadata<T> = {
key,
value,
lastUpdated: Date.now()
};
return new Promise((resolve, reject) => {
const request = store.put(metadata);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getMetadata<T = unknown>(key: string): Promise<T | null> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['metadata'], 'readonly');
const store = transaction.objectStore('metadata');
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result as SyncMetadata<T> | undefined;
resolve(result ? (result.value as T) : null);
};
request.onerror = () => reject(request.error);
});
}
// Album operations
async storeAlbums(albums: OfflineAlbum[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readwrite');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
let completed = 0;
const total = albums.length;
if (total === 0) {
resolve();
return;
}
albums.forEach(album => {
const albumWithMeta = {
...album,
lastModified: Date.now(),
synced: true
};
const request = store.put(albumWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getAlbums(starred?: boolean): Promise<OfflineAlbum[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
const request = starred
? store.index('starred').getAll(IDBKeyRange.only('starred'))
: store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAlbum(id: string): Promise<OfflineAlbum | null> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
// Artist operations
async storeArtists(artists: OfflineArtist[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['artists'], 'readwrite');
const store = transaction.objectStore('artists');
return new Promise((resolve, reject) => {
let completed = 0;
const total = artists.length;
if (total === 0) {
resolve();
return;
}
artists.forEach(artist => {
const artistWithMeta = {
...artist,
lastModified: Date.now(),
synced: true
};
const request = store.put(artistWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getArtists(starred?: boolean): Promise<OfflineArtist[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['artists'], 'readonly');
const store = transaction.objectStore('artists');
return new Promise((resolve, reject) => {
const request = starred
? store.index('starred').getAll(IDBKeyRange.only('starred'))
: store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Song operations
async storeSongs(songs: OfflineSong[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['songs'], 'readwrite');
const store = transaction.objectStore('songs');
return new Promise((resolve, reject) => {
let completed = 0;
const total = songs.length;
if (total === 0) {
resolve();
return;
}
songs.forEach(song => {
const songWithMeta = {
...song,
lastModified: Date.now(),
synced: true
};
const request = store.put(songWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getSongs(albumId?: string, starred?: boolean): Promise<OfflineSong[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['songs'], 'readonly');
const store = transaction.objectStore('songs');
return new Promise((resolve, reject) => {
let request: IDBRequest<OfflineSong[]>;
if (albumId) {
request = store.index('albumId').getAll(IDBKeyRange.only(albumId));
} else if (starred) {
request = store.index('starred').getAll(IDBKeyRange.only('starred'));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result as OfflineSong[]);
request.onerror = () => reject(request.error);
});
}
// Playlist operations
async storePlaylists(playlists: OfflinePlaylist[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
return new Promise((resolve, reject) => {
let completed = 0;
const total = playlists.length;
if (total === 0) {
resolve();
return;
}
playlists.forEach(playlist => {
const playlistWithMeta = {
...playlist,
lastModified: Date.now(),
synced: true
};
const request = store.put(playlistWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getPlaylists(): Promise<OfflinePlaylist[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['playlists'], 'readonly');
const store = transaction.objectStore('playlists');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Sync operations
async addSyncOperation(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
const syncOp: SyncOperation = {
...operation,
id: crypto.randomUUID(),
timestamp: Date.now(),
retryCount: 0
};
return new Promise((resolve, reject) => {
const request = store.add(syncOp);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getSyncOperations(): Promise<SyncOperation[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async removeSyncOperation(id: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Statistics and management
async getStats(): Promise<LibrarySyncStats> {
if (!this.db) throw new Error('Database not initialized');
const [albums, artists, songs, playlists, syncOps, lastSyncNum] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists(),
this.getSyncOperations(),
this.getMetadata<number>('lastSync')
]);
// Estimate storage size
const storageSize = await this.estimateStorageSize();
return {
albums: albums.length,
artists: artists.length,
songs: songs.length,
playlists: playlists.length,
lastSync: typeof lastSyncNum === 'number' ? new Date(lastSyncNum) : null,
pendingOperations: syncOps.length,
storageSize,
syncInProgress: (await this.getMetadata<boolean>('syncInProgress')) ?? false
};
}
async estimateStorageSize(): Promise<number> {
if (!this.db) return 0;
try {
const estimate = await navigator.storage.estimate();
return estimate.usage || 0;
} catch {
// Fallback estimation if storage API not available
const [albums, artists, songs, playlists] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists()
]);
// Rough estimation: average 2KB per item
return (albums.length + artists.length + songs.length + playlists.length) * 2048;
}
}
async clearAllData(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums', 'artists', 'songs', 'playlists', 'syncQueue', 'metadata'], 'readwrite');
const stores = [
transaction.objectStore('albums'),
transaction.objectStore('artists'),
transaction.objectStore('songs'),
transaction.objectStore('playlists'),
transaction.objectStore('syncQueue'),
transaction.objectStore('metadata')
];
return new Promise((resolve, reject) => {
let completed = 0;
const total = stores.length;
stores.forEach(store => {
const request = store.clear();
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
// Star/unstar operations (offline-first)
async starItem(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const storeName = `${type}s`;
const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite');
const store = transaction.objectStore(storeName);
const syncStore = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
// Update the item locally first
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
item.starred = 'starred';
item.lastModified = Date.now();
item.synced = false;
const putRequest = store.put(item);
putRequest.onsuccess = () => {
// Add to sync queue
const syncOp: SyncOperation = {
id: crypto.randomUUID(),
type: 'star',
entityType: type,
entityId: id,
data: { star: true },
timestamp: Date.now(),
retryCount: 0
};
const syncRequest = syncStore.add(syncOp);
syncRequest.onsuccess = () => resolve();
syncRequest.onerror = () => reject(syncRequest.error);
};
putRequest.onerror = () => reject(putRequest.error);
} else {
reject(new Error(`${type} not found`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
async unstarItem(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const storeName = `${type}s`;
const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite');
const store = transaction.objectStore(storeName);
const syncStore = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
delete item.starred;
item.lastModified = Date.now();
item.synced = false;
const putRequest = store.put(item);
putRequest.onsuccess = () => {
const syncOp: SyncOperation = {
id: crypto.randomUUID(),
type: 'unstar',
entityType: type,
entityId: id,
data: { star: false },
timestamp: Date.now(),
retryCount: 0
};
const syncRequest = syncStore.add(syncOp);
syncRequest.onsuccess = () => resolve();
syncRequest.onerror = () => reject(syncRequest.error);
};
putRequest.onerror = () => reject(putRequest.error);
} else {
reject(new Error(`${type} not found`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
}
// Singleton instance
export const offlineLibraryDB = new OfflineLibraryDB();

347
lib/musicbrainz-api.ts Normal file
View File

@@ -0,0 +1,347 @@
/**
* MusicBrainz API client for the auto-tagging feature
*
* This module provides functions to search and fetch metadata from MusicBrainz,
* which is an open music encyclopedia that collects music metadata.
*/
// Define the User-Agent string as per MusicBrainz API guidelines
// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#User-Agent
const USER_AGENT = 'mice/1.0.0 (https://github.com/sillyangel/mice)';
// Base URL for MusicBrainz API
const API_BASE_URL = 'https://musicbrainz.org/ws/2';
// Add a delay between requests to comply with MusicBrainz rate limiting
const RATE_LIMIT_DELAY = 1100; // Slightly more than 1 second to be safe
// Queue for API requests to ensure proper rate limiting
const requestQueue: (() => Promise<unknown>)[] = [];
let isProcessingQueue = false;
/**
* Process the request queue with proper rate limiting
*/
async function processQueue() {
if (isProcessingQueue || requestQueue.length === 0) return;
isProcessingQueue = true;
while (requestQueue.length > 0) {
const request = requestQueue.shift();
if (request) {
try {
await request();
} catch (error) {
console.error('MusicBrainz API request failed:', error);
}
// Wait before processing the next request
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_DELAY));
}
}
isProcessingQueue = false;
}
/**
* Make a rate-limited request to the MusicBrainz API
*/
async function makeRequest<T>(endpoint: string, params: Record<string, string> = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
const requestFn = async () => {
try {
const url = new URL(`${API_BASE_URL}${endpoint}`);
// Add format parameter
url.searchParams.append('fmt', 'json');
// Add other parameters
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url.toString(), {
headers: {
'User-Agent': USER_AGENT
}
});
if (!response.ok) {
throw new Error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
resolve(data as T);
} catch (error) {
reject(error);
}
};
// Add request to queue
requestQueue.push(requestFn);
processQueue();
});
}
/**
* Search for releases (albums) in MusicBrainz
*/
export async function searchReleases(query: string, limit: number = 10): Promise<MusicBrainzRelease[]> {
try {
interface ReleaseSearchResult {
releases: MusicBrainzRelease[];
}
const data = await makeRequest<ReleaseSearchResult>('/release', {
query,
limit: limit.toString()
});
return data.releases || [];
} catch (error) {
console.error('Failed to search releases:', error);
return [];
}
}
/**
* Search for recordings (tracks) in MusicBrainz
*/
export async function searchRecordings(query: string, limit: number = 10): Promise<MusicBrainzRecording[]> {
try {
interface RecordingSearchResult {
recordings: MusicBrainzRecording[];
}
const data = await makeRequest<RecordingSearchResult>('/recording', {
query,
limit: limit.toString()
});
return data.recordings || [];
} catch (error) {
console.error('Failed to search recordings:', error);
return [];
}
}
/**
* Get detailed information about a release by its MBID
*/
export async function getRelease(mbid: string): Promise<MusicBrainzReleaseDetails | null> {
try {
// Request with recording-level relationships to get track-level data
const data = await makeRequest<MusicBrainzReleaseDetails>(`/release/${mbid}`, {
inc: 'recordings+artists+labels+artist-credits'
});
return data;
} catch (error) {
console.error(`Failed to get release ${mbid}:`, error);
return null;
}
}
/**
* Get detailed information about a recording by its MBID
*/
export async function getRecording(mbid: string): Promise<MusicBrainzRecordingDetails | null> {
try {
const data = await makeRequest<MusicBrainzRecordingDetails>(`/recording/${mbid}`, {
inc: 'artists+releases+artist-credits'
});
return data;
} catch (error) {
console.error(`Failed to get recording ${mbid}:`, error);
return null;
}
}
/**
* Find the best matching release for the given album information
* This uses fuzzy matching to find the most likely match
*/
export async function findBestMatchingRelease(
albumName: string,
artistName: string,
trackCount?: number
): Promise<MusicBrainzRelease | null> {
try {
// Build a search query with both album and artist
const query = `release:"${albumName}" AND artist:"${artistName}"`;
const releases = await searchReleases(query, 5);
if (!releases || releases.length === 0) {
return null;
}
// If track count is provided, prioritize releases with the same track count
if (trackCount !== undefined) {
const exactTrackCountMatch = releases.find(release =>
release['track-count'] === trackCount
);
if (exactTrackCountMatch) {
return exactTrackCountMatch;
}
}
// Just return the first result as it's likely the best match
return releases[0];
} catch (error) {
console.error('Failed to find matching release:', error);
return null;
}
}
/**
* Find the best matching recording for the given track information
*/
export async function findBestMatchingRecording(
trackName: string,
artistName: string,
duration?: number // in milliseconds
): Promise<MusicBrainzRecording | null> {
try {
// Build a search query with both track and artist
const query = `recording:"${trackName}" AND artist:"${artistName}"`;
const recordings = await searchRecordings(query, 5);
if (!recordings || recordings.length === 0) {
return null;
}
// If duration is provided, try to find a close match
if (duration !== undefined) {
// Convert to milliseconds if not already (MusicBrainz uses milliseconds)
const durationMs = duration < 1000 ? duration * 1000 : duration;
// Find recording with the closest duration (within 5 seconds)
const durationMatches = recordings.filter(recording => {
if (!recording.length) return false;
return Math.abs(recording.length - durationMs) < 5000; // 5 second tolerance
});
if (durationMatches.length > 0) {
return durationMatches[0];
}
}
// Just return the first result as it's likely the best match
return recordings[0];
} catch (error) {
console.error('Failed to find matching recording:', error);
return null;
}
}
// Type definitions for MusicBrainz API responses
export interface MusicBrainzRelease {
id: string; // MBID
title: string;
'artist-credit': Array<{
artist: {
id: string;
name: string;
};
name: string;
}>;
date?: string;
country?: string;
'track-count': number;
status?: string;
disambiguation?: string;
}
export interface MusicBrainzReleaseDetails extends MusicBrainzRelease {
media: Array<{
position: number;
format?: string;
tracks: Array<{
position: number;
number: string;
title: string;
length?: number;
recording: {
id: string;
title: string;
length?: number;
};
}>;
}>;
'cover-art-archive'?: {
artwork: boolean;
count: number;
front: boolean;
back: boolean;
};
barcode?: string;
'release-group'?: {
id: string;
'primary-type'?: string;
};
}
export interface MusicBrainzRecording {
id: string; // MBID
title: string;
length?: number; // in milliseconds
'artist-credit': Array<{
artist: {
id: string;
name: string;
};
name: string;
}>;
releases?: Array<{
id: string;
title: string;
}>;
isrcs?: string[];
}
export interface MusicBrainzRecordingDetails extends MusicBrainzRecording {
disambiguation?: string;
'first-release-date'?: string;
genres?: Array<{
id: string;
name: string;
}>;
tags?: Array<{
count: number;
name: string;
}>;
}
// Cover art functions
// MusicBrainz has a separate API for cover art: Cover Art Archive
export function getCoverArtUrl(releaseId: string, size: 'small' | 'large' | '500' | 'full' = 'large'): string {
return `https://coverartarchive.org/release/${releaseId}/front-${size}`;
}
// Utility function to normalize strings for comparison
export function normalizeString(input: string): string {
return input
.toLowerCase()
.replace(/[^\w\s]/g, '') // Remove special characters
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
.trim();
}
// Export the MusicBrainz client as a singleton
const MusicBrainzClient = {
searchReleases,
searchRecordings,
getRelease,
getRecording,
findBestMatchingRelease,
findBestMatchingRecording,
getCoverArtUrl,
normalizeString
};
export default MusicBrainzClient;

View File

@@ -1,5 +1,4 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache';
export interface NavidromeConfig { export interface NavidromeConfig {
serverUrl: string; serverUrl: string;
@@ -68,6 +67,7 @@ export interface Song {
artistId: string; artistId: string;
type: string; type: string;
starred?: string; starred?: string;
replayGain?: number;
} }
export interface Playlist { export interface Playlist {
@@ -110,6 +110,26 @@ export interface ArtistInfo {
similarArtist?: Artist[]; similarArtist?: Artist[];
} }
export interface User {
username: string;
email?: string;
scrobblingEnabled: boolean;
maxBitRate?: number;
adminRole: boolean;
settingsRole: boolean;
downloadRole: boolean;
uploadRole: boolean;
playlistRole: boolean;
coverArtRole: boolean;
commentRole: boolean;
podcastRole: boolean;
streamRole: boolean;
jukeboxRole: boolean;
shareRole: boolean;
videoConversionRole: boolean;
avatarLastChanged?: string;
}
class NavidromeAPI { class NavidromeAPI {
private config: NavidromeConfig; private config: NavidromeConfig;
private clientName = 'miceclient'; private clientName = 'miceclient';
@@ -171,6 +191,12 @@ class NavidromeAPI {
} }
} }
async getUserInfo(): Promise<User> {
const response = await this.makeRequest('getUser', { username: this.config.username });
const userData = response.user as User;
return userData;
}
async getArtists(): Promise<Artist[]> { async getArtists(): Promise<Artist[]> {
const response = await this.makeRequest('getArtists'); const response = await this.makeRequest('getArtists');
const artists: Artist[] = []; const artists: Artist[] = [];
@@ -188,12 +214,21 @@ class NavidromeAPI {
} }
async getArtist(artistId: string): Promise<{ artist: Artist; albums: Album[] }> { async getArtist(artistId: string): Promise<{ artist: Artist; albums: Album[] }> {
const response = await this.makeRequest('getArtist', { id: artistId }); try {
const artistData = response.artist as Artist & { album?: Album[] }; const response = await this.makeRequest('getArtist', { id: artistId });
return { // Check if artist data exists
artist: artistData, if (!response.artist) {
albums: artistData.album || [] throw new Error('Artist not found in response');
}; }
const artistData = response.artist as Artist & { album?: Album[] };
return {
artist: artistData,
albums: artistData.album || []
};
} catch (error) {
console.error('Navidrome API request failed:', error);
throw new Error('Artist not found');
}
} }
async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise<Album[]> { async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise<Album[]> {
@@ -304,6 +339,23 @@ class NavidromeAPI {
return `${this.config.serverUrl}/rest/stream?${params.toString()}`; return `${this.config.serverUrl}/rest/stream?${params.toString()}`;
} }
// Direct download URL (original file). Useful for offline caching where the browser can handle transcoding.
getDownloadUrl(songId: string): string {
const salt = this.generateSalt();
const token = this.generateToken(this.config.password, salt);
const params = new URLSearchParams({
u: this.config.username,
t: token,
s: salt,
v: this.version,
c: this.clientName,
id: songId
});
return `${this.config.serverUrl}/rest/download?${params.toString()}`;
}
getCoverArtUrl(coverArtId: string, size?: number): string { getCoverArtUrl(coverArtId: string, size?: number): string {
const salt = this.generateSalt(); const salt = this.generateSalt();
const token = this.generateToken(this.config.password, salt); const token = this.generateToken(this.config.password, salt);

782
lib/offline-library.ts Normal file
View File

@@ -0,0 +1,782 @@
'use client';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
export interface NavidromeAPIInterface {
ping(): Promise<boolean>;
getAlbums(type?: string, size?: number): Promise<Album[]>;
getArtists(): Promise<Artist[]>;
getPlaylists(): Promise<Playlist[]>;
getAlbum(id: string): Promise<{ album: Album; songs: Song[] }>;
star(id: string, type: string): Promise<void>;
unstar(id: string, type: string): Promise<void>;
createPlaylist(name: string, songIds?: string[]): Promise<Playlist>;
updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void>;
deletePlaylist(id: string): Promise<void>;
scrobble(songId: string): Promise<void>;
}
export interface OfflineDatabase {
albums: Album[];
artists: Artist[];
songs: Song[];
playlists: Playlist[];
favorites: {
albums: string[];
artists: string[];
songs: string[];
};
syncQueue: SyncOperation[];
lastSync: number;
}
export interface SyncOperationData {
// For star/unstar operations
star?: boolean;
// For playlist operations
name?: string;
comment?: string;
songIds?: string[];
// For scrobble operations
timestamp?: number;
}
export interface SyncOperation {
id: string;
type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble';
data: SyncOperationData;
timestamp: number;
retryCount: number;
entityType: 'song' | 'album' | 'artist' | 'playlist';
entityId: string;
}
export interface OfflineLibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
lastSync: Date | null;
pendingOperations: number;
storageSize: number;
}
class OfflineLibraryManager {
private dbName = 'mice-offline-library';
private dbVersion = 1;
private db: IDBDatabase | null = null;
async initialize(): Promise<boolean> {
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
return false;
}
try {
this.db = await this.openDatabase();
return true;
} catch (error) {
console.error('Failed to initialize offline library:', error);
return false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores
if (!db.objectStoreNames.contains('albums')) {
const albumsStore = db.createObjectStore('albums', { keyPath: 'id' });
albumsStore.createIndex('artist', 'artist', { unique: false });
albumsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('artists')) {
const artistsStore = db.createObjectStore('artists', { keyPath: 'id' });
artistsStore.createIndex('name', 'name', { unique: false });
artistsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('songs')) {
const songsStore = db.createObjectStore('songs', { keyPath: 'id' });
songsStore.createIndex('albumId', 'albumId', { unique: false });
songsStore.createIndex('artistId', 'artistId', { unique: false });
songsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('playlists')) {
const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' });
playlistsStore.createIndex('name', 'name', { unique: false });
}
if (!db.objectStoreNames.contains('playlistSongs')) {
const playlistSongsStore = db.createObjectStore('playlistSongs', { keyPath: ['playlistId', 'songId'] });
playlistSongsStore.createIndex('playlistId', 'playlistId', { unique: false });
}
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
syncStore.createIndex('type', 'type', { unique: false });
}
if (!db.objectStoreNames.contains('metadata')) {
const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' });
}
};
});
}
// Library sync methods
async syncFromServer(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
try {
console.log('Starting full library sync...');
// Test connection
const isConnected = await navidromeAPI.ping();
if (!isConnected) {
throw new Error('No connection to Navidrome server');
}
// Sync albums
const albums = await navidromeAPI.getAlbums('alphabeticalByName', 5000);
await this.storeAlbums(albums);
// Sync artists
const artists = await navidromeAPI.getArtists();
await this.storeArtists(artists);
// Sync playlists
const playlists = await navidromeAPI.getPlaylists();
await this.storePlaylists(playlists);
// Sync songs for recently added albums (to avoid overwhelming the db)
const recentAlbums = albums.slice(0, 100);
for (const album of recentAlbums) {
try {
const { songs } = await navidromeAPI.getAlbum(album.id);
await this.storeSongs(songs);
} catch (error) {
console.warn(`Failed to sync songs for album ${album.id}:`, error);
}
}
// Update last sync timestamp
await this.setMetadata('lastSync', Date.now());
console.log('Library sync completed successfully');
} catch (error) {
console.error('Failed to sync library:', error);
throw error;
}
}
async syncPendingOperations(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
const operations = await this.getAllSyncOperations();
for (const operation of operations) {
try {
await this.executeOperation(operation, navidromeAPI);
await this.removeSyncOperation(operation.id);
} catch (error) {
console.error(`Failed to sync operation ${operation.id}:`, error);
// Increment retry count
operation.retryCount++;
if (operation.retryCount < 3) {
await this.updateSyncOperation(operation);
} else {
// Remove after 3 failed attempts
await this.removeSyncOperation(operation.id);
}
}
}
}
private async executeOperation(operation: SyncOperation, api: NavidromeAPIInterface): Promise<void> {
switch (operation.type) {
case 'star':
await api.star(operation.entityId, operation.entityType);
break;
case 'unstar':
await api.unstar(operation.entityId, operation.entityType);
break;
case 'create_playlist':
if (operation.data.name) {
await api.createPlaylist(operation.data.name, operation.data.songIds);
}
break;
case 'update_playlist':
await api.updatePlaylist(operation.entityId, operation.data.name, operation.data.comment, operation.data.songIds);
break;
case 'delete_playlist':
await api.deletePlaylist(operation.entityId);
break;
case 'scrobble':
await api.scrobble(operation.entityId);
break;
}
}
// Data storage methods
async storeAlbums(albums: Album[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['albums'], 'readwrite');
const store = transaction.objectStore('albums');
for (const album of albums) {
store.put(album);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeArtists(artists: Artist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['artists'], 'readwrite');
const store = transaction.objectStore('artists');
for (const artist of artists) {
store.put(artist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeSongs(songs: Song[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['songs'], 'readwrite');
const store = transaction.objectStore('songs');
for (const song of songs) {
store.put(song);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storePlaylists(playlists: Playlist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
for (const playlist of playlists) {
store.put(playlist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Data retrieval methods
async getAlbums(starred?: boolean): Promise<Album[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getArtists(starred?: boolean): Promise<Artist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['artists'], 'readonly');
const store = transaction.objectStore('artists');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getSongs(albumId?: string, artistId?: string, starred?: boolean): Promise<Song[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['songs'], 'readonly');
const store = transaction.objectStore('songs');
let request: IDBRequest;
if (albumId) {
const index = store.index('albumId');
request = index.getAll(albumId);
} else if (artistId) {
const index = store.index('artistId');
request = index.getAll(artistId);
} else if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getPlaylists(): Promise<Playlist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['playlists'], 'readonly');
const store = transaction.objectStore('playlists');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getAlbum(id: string): Promise<{ album: Album; songs: Song[] } | null> {
if (!this.db) return null;
try {
const [album, songs] = await Promise.all([
this.getAlbumById(id),
this.getSongs(id)
]);
if (!album) return null;
return { album, songs };
} catch (error) {
console.error('Failed to get album:', error);
return null;
}
}
private async getAlbumById(id: string): Promise<Album | null> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
// Sync queue methods
async addSyncOperation(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
if (!this.db) return;
const fullOperation: SyncOperation = {
...operation,
id: `${operation.type}_${operation.entityId}_${Date.now()}`,
timestamp: Date.now(),
retryCount: 0
};
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(fullOperation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async getAllSyncOperations(): Promise<SyncOperation[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
private async updateSyncOperation(operation: SyncOperation): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(operation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async removeSyncOperation(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.delete(id);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Metadata methods
async setMetadata(key: string, value: unknown): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
store.put({ key, value });
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getMetadata(key: string): Promise<unknown> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['metadata'], 'readonly');
const store = transaction.objectStore('metadata');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
}
// Search methods
async searchOffline(query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> {
if (!this.db) return { artists: [], albums: [], songs: [] };
try {
const [allAlbums, allArtists, allSongs] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs()
]);
const searchLower = query.toLowerCase();
const albums = allAlbums.filter(album =>
album.name.toLowerCase().includes(searchLower) ||
album.artist.toLowerCase().includes(searchLower)
);
const artists = allArtists.filter(artist =>
artist.name.toLowerCase().includes(searchLower)
);
const songs = allSongs.filter(song =>
song.title.toLowerCase().includes(searchLower) ||
(song.artist && song.artist.toLowerCase().includes(searchLower)) ||
(song.album && song.album.toLowerCase().includes(searchLower))
);
return { artists, albums, songs };
} catch (error) {
console.error('Offline search failed:', error);
return { artists: [], albums: [], songs: [] };
}
}
// Offline favorites management
async starOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'star',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
item.starred = new Date().toISOString();
store.put(item);
}
};
}
async unstarOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'unstar',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
delete item.starred;
store.put(item);
}
};
}
// Playlist management
async createPlaylistOffline(name: string, songIds?: string[]): Promise<Playlist> {
if (!this.db) throw new Error('Database not initialized');
const playlistId = `offline_${Date.now()}`;
const playlist: Playlist = {
id: playlistId,
name,
comment: '',
owner: 'offline',
public: false,
songCount: songIds?.length || 0,
duration: 0,
created: new Date().toISOString(),
changed: new Date().toISOString()
};
// Store playlist
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
store.put(playlist);
// Add to sync queue
await this.addSyncOperation({
type: 'create_playlist',
entityType: 'playlist',
entityId: playlistId,
data: { name, songIds }
});
return playlist;
}
// Update playlist
async updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Get existing playlist
const playlist = await new Promise<Playlist>((resolve, reject) => {
const request = playlistStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!playlist) {
throw new Error('Playlist not found');
}
// Update playlist metadata
const updatedPlaylist = {
...playlist,
...(name && { name }),
...(comment && { comment }),
updatedAt: new Date().toISOString()
};
playlistStore.put(updatedPlaylist);
// Update song associations if provided
if (songIds) {
// Remove existing songs
const existingSongs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const index = playlistSongsStore.index('playlistId');
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of existingSongs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
// Add new songs
for (const songId of songIds) {
playlistSongsStore.put({
playlistId: id,
songId: songId
});
}
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Delete playlist
async deletePlaylist(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Delete playlist
playlistStore.delete(id);
// Delete associated songs
const index = playlistSongsStore.index('playlistId');
const songs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of songs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Statistics
async getLibraryStats(): Promise<OfflineLibraryStats> {
if (!this.db) {
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
try {
const [albums, artists, songs, playlists, syncOps, lastSync] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists(),
this.getAllSyncOperations(),
this.getMetadata('lastSync')
]);
// Estimate storage size (rough calculation)
const storageSize = this.estimateStorageSize(albums, artists, songs, playlists);
return {
albums: albums.length,
artists: artists.length,
songs: songs.length,
playlists: playlists.length,
lastSync: lastSync && typeof lastSync === 'number' ? new Date(lastSync) : null,
pendingOperations: syncOps.length,
storageSize
};
} catch (error) {
console.error('Failed to get library stats:', error);
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
}
private estimateStorageSize(albums: Album[], artists: Artist[], songs: Song[], playlists: Playlist[]): number {
// Rough estimation: each item is approximately 1KB in JSON
return (albums.length + artists.length + songs.length + playlists.length) * 1024;
}
// Clear all data
async clearAllData(): Promise<void> {
if (!this.db) return;
const storeNames = ['albums', 'artists', 'songs', 'playlists', 'playlistSongs', 'syncQueue', 'metadata'];
for (const storeName of storeNames) {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
await new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
}
export const offlineLibraryManager = new OfflineLibraryManager();

View File

@@ -1,11 +0,0 @@
import { PostHog } from "posthog-node"
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
// capture_pageview: 'history_change',
flushAt: 1,
flushInterval: 0,
})
return posthogClient
}

View File

@@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function constrain(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatBytes(bytes: number, decimals: number = 2): string {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
qualities: [50, 75, 100],
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -11,6 +12,8 @@ const nextConfig = {
hostname: "**", hostname: "**",
} }
], ],
minimumCacheTTL: 60,
// unoptimized: true,
}, },
async headers() { async headers() {
return [ return [
@@ -44,30 +47,12 @@ const nextConfig = {
}, },
{ {
key: 'Content-Security-Policy', key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'", value: "default-src 'self' *; connect-src 'self' *; script-src 'self'",
}, },
], ],
}, },
]; ];
}, },
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://us-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://us.i.posthog.com/:path*',
},
{
source: '/ingest/decide',
destination: 'https://us.i.posthog.com/decide',
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,9 +1,10 @@
{ {
"name": "mice-reworked", "name": "mice-reworked",
"version": "2025.07.10", "version": "2026.01.24",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local", "predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
"prebuild": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
"dev": "next dev --turbopack -p 40625", "dev": "next dev --turbopack -p 40625",
"build": "next build", "build": "next build",
"start": "next start -p 40625", "start": "next start -p 40625",
@@ -13,77 +14,90 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.15", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.8",
"@types/react-beautiful-dnd": "^13.1.8", "@types/react-beautiful-dnd": "^13.1.8",
"axios": "^1.8.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"colorthief": "^2.6.0", "colorthief": "^2.6.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.29.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.563.0",
"next": "15.3.4", "next": "16.1.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"posthog-js": "^1.255.0", "react": "19.2.3",
"posthog-node": "^5.1.1", "react-day-picker": "^9.13.0",
"react": "19.1.0", "react-dom": "19.2.3",
"react-day-picker": "^9.7.0", "react-hook-form": "^7.71.1",
"react-dom": "19.1.0", "react-icons": "^5.5.0",
"react-hook-form": "^7.53.2", "react-intersection-observer": "^10.0.2",
"react-icons": "^5.3.0", "react-resizable-panels": "^4.5.1",
"react-resizable-panels": "^3.0.3", "recharts": "^3.7.0",
"recharts": "^3.0.2", "sonner": "^2.0.7",
"sonner": "^2.0.5", "tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.25.70" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.0.10", "@types/node": "^25.0.10",
"@types/react": "19.1.8", "@types/react": "19.2.9",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.2.3",
"chalk": "^5.3.0", "chalk": "^5.6.2",
"eslint": "^9.30", "eslint": "^9.39.2",
"eslint-config-next": "15.3.5", "eslint-config-next": "16.1.4",
"postcss": "^8", "postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "source-map-support": "^0.5.21",
"typescript": "^5" "tailwindcss": "^4.1.18",
"typescript": "5.9.3"
}, },
"packageManager": "pnpm@10.12.4", "packageManager": "pnpm@10.13.1",
"overrides": { "overrides": {
"@types/react": "19.1.8", "@types/react": "19.2.9",
"@types/react-dom": "19.1.6" "@types/react-dom": "19.2.3",
"typescript": "5.9.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
},
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
} }
} }

4194
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- unrs-resolver

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 339 KiB

129
public/manifest.json Normal file
View File

@@ -0,0 +1,129 @@
{
"name": "Mice",
"short_name": "Mice",
"description": "a very awesome navidrome client",
"start_url": "/",
"categories": ["music", "entertainment"],
"display_override": ["window-controls-overlay"],
"display": "standalone",
"background_color": "#0f0f0f",
"theme_color": "#0f0f0f",
"icons": [
{
"src": "/favicon.ico",
"type": "image/x-icon",
"sizes": "48x48"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-192-maskable.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
{
"src": "/icon-512-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "152x152",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "120x120",
"purpose": "any"
}
],
"screenshots": [
{
"src": "/home-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Home Preview",
"form_factor": "wide"
},
{
"src": "/browse-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Browse Preview",
"form_factor": "wide"
},
{
"src": "/album-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Album Preview",
"form_factor": "wide"
},
{
"src": "/fullscreen-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Fullscreen Preview",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "Resume Song",
"short_name": "Resume",
"description": "Resume the last played song",
"url": "/?action=resume",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Play Recent Albums",
"short_name": "Recent",
"description": "Play from recently added albums",
"url": "/?action=recent",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Shuffle Favorites",
"short_name": "Shuffle",
"description": "Shuffle songs from favorite artists",
"url": "/?action=shuffle-favorites",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
]
}

123
public/sw.js Normal file
View File

@@ -0,0 +1,123 @@
/*
Service Worker for Mice (Navidrome client)
- App shell caching for faster loading
- Static asset caching
*/
/* global self, caches */
const VERSION = 'v3';
const APP_SHELL_CACHE = `mice-app-shell-${VERSION}`;
const IMAGE_CACHE = `mice-images-${VERSION}`;
// Core assets to precache (safe, static public files)
const APP_SHELL = [
'/',
'/favicon.ico',
'/manifest.json',
'/icon-192.png',
'/icon-192-maskable.png',
'/icon-512.png',
'/icon-512-maskable.png',
'/apple-touch-icon.png',
'/apple-touch-icon-precomposed.png',
];
// Install: pre-cache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(APP_SHELL_CACHE);
await cache.addAll(APP_SHELL.map((u) => new Request(u, { cache: 'reload' })));
// Force activate new SW immediately
await self.skipWaiting();
})()
);
});
// Activate: clean old caches and claim clients
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => ![APP_SHELL_CACHE, IMAGE_CACHE].includes(k))
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
// Fetch strategy
self.addEventListener('fetch', (event) => {
const req = event.request;
// Navigation requests: network-first, fallback to cache
if (req.mode === 'navigate') {
event.respondWith(
(async () => {
try {
const fresh = await fetch(req);
const cache = await caches.open(APP_SHELL_CACHE);
cache.put(req, fresh.clone()).catch(() => {});
return fresh;
} catch {
const cache = await caches.open(APP_SHELL_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
// final fallback to index
return (await cache.match('/')) || Response.error();
}
})()
);
return;
}
// Images: cache-first for better performance
if (req.destination === 'image') {
event.respondWith(
(async () => {
const cache = await caches.open(IMAGE_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
cache.put(req, res.clone()).catch(() => {});
return res;
} catch {
return cached || Response.error();
}
})()
);
return;
}
// Scripts, styles, fonts, and Next.js assets: cache-first for faster loading
if (
req.destination === 'script' ||
req.destination === 'style' ||
req.destination === 'font' ||
req.url.includes('/_next/')
) {
event.respondWith(
(async () => {
const cache = await caches.open(APP_SHELL_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
cache.put(req, res.clone()).catch(() => {});
return res;
} catch {
return cached || Response.error();
}
})()
);
return;
}
// Default: network-only (no caching for API calls, audio streams, etc.)
event.respondWith(fetch(req));
});

View File

@@ -14,7 +14,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -32,7 +32,8 @@
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts",
".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"