From 4e0b187a1f62e5daf97b6987d67ca234373130c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:32:37 +0000 Subject: [PATCH 01/27] 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] --- package.json | 2 +- pnpm-lock.yaml | 104 ++++++++++++++++++++++++------------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 4eb76ee..f6d40f3 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "chalk": "^5.3.0", "eslint": "^9.31", "eslint": "^9.32", - "eslint-config-next": "15.4.4", + "eslint-config-next": "15.4.5", "postcss": "^8", "tailwindcss": "^4.1.11", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c17fd2b..689957e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,8 +206,8 @@ importers: specifier: ^9.32 version: 9.32.0(jiti@2.4.2) eslint-config-next: - specifier: 15.4.4 - version: 15.4.4(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + specifier: 15.4.5 + version: 15.4.5(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) postcss: specifier: ^8 version: 8.5.6 @@ -216,7 +216,7 @@ importers: version: 4.1.11 typescript: specifier: ^5 - version: 5.8.3 + version: 5.9.2 packages: @@ -593,8 +593,8 @@ packages: '@next/env@15.4.4': resolution: {integrity: sha512-SJKOOkULKENyHSYXE5+KiFU6itcIb6wSBjgM92meK0HVKpo94dNOLZVdLLuS7/BxImROkGoPsjR4EnuDucqiiA==} - '@next/eslint-plugin-next@15.4.4': - resolution: {integrity: sha512-1FDsyN//ai3Jd97SEd7scw5h1yLdzDACGOPRofr2GD3sEFsBylEEoL0MHSerd4n2dq9Zm/mFMqi4+NRMOreOKA==} + '@next/eslint-plugin-next@15.4.5': + resolution: {integrity: sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw==} '@next/swc-darwin-arm64@15.4.4': resolution: {integrity: sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==} @@ -2123,8 +2123,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@15.4.4: - resolution: {integrity: sha512-sK/lWLUVF5om18O5w76Jt3F8uzu/LP5mVa6TprCMWkjWHUmByq80iHGHcdH7k1dLiJlj+DRIWf98d5piwRsSuA==} + eslint-config-next@15.4.5: + resolution: {integrity: sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -3255,8 +3255,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true @@ -3673,7 +3673,7 @@ snapshots: '@next/env@15.4.4': {} - '@next/eslint-plugin-next@15.4.4': + '@next/eslint-plugin-next@15.4.5': dependencies: fast-glob: 3.3.1 @@ -4655,41 +4655,41 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.38.0 eslint: 9.32.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.38.0 debug: 4.4.1 eslint: 9.32.0(jiti@2.4.2) - typescript: 5.8.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.38.0(typescript@5.8.3)': + '@typescript-eslint/project-service@8.38.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.2) '@typescript-eslint/types': 8.38.0 debug: 4.4.1 - typescript: 5.8.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -4698,28 +4698,28 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': + '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.9.2)': dependencies: - typescript: 5.8.3 + typescript: 5.9.2 - '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) debug: 4.4.1 eslint: 9.32.0(jiti@2.4.2) - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.38.0': {} - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.38.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) + '@typescript-eslint/project-service': 8.38.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.2) '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 debug: 4.4.1 @@ -4727,19 +4727,19 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2) eslint: 9.32.0(jiti@2.4.2) - typescript: 5.8.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -5255,21 +5255,21 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@15.4.4(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3): + eslint-config-next@15.4.5(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2): dependencies: - '@next/eslint-plugin-next': 15.4.4 + '@next/eslint-plugin-next': 15.4.5 '@rushstack/eslint-patch': 1.12.0 - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) eslint: 9.32.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.4.2)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.4.2)) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.2 transitivePeerDependencies: - eslint-import-resolver-webpack - eslint-plugin-import-x @@ -5294,22 +5294,22 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) eslint: 9.32.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5320,7 +5320,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -5332,7 +5332,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.4.2))(typescript@5.9.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6495,9 +6495,9 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - ts-api-utils@2.1.0(typescript@5.8.3): + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: - typescript: 5.8.3 + typescript: 5.9.2 tsconfig-paths@3.15.0: dependencies: @@ -6545,7 +6545,7 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript@5.8.3: {} + typescript@5.9.2: {} unbox-primitive@1.1.0: dependencies: From af5e24b80eb50d23d5fa15b77af26816356324c4 Mon Sep 17 00:00:00 2001 From: angel Date: Wed, 6 Aug 2025 02:15:29 +0000 Subject: [PATCH 02/27] style: update README formatting and improve content clarity --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8479671..073694c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ -

- Mice Logo - Mice | Navidrome Client +

+ Mice Logo

- -# +

Mice | Navidrome Client

> Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template. - + -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 @@ -20,7 +18,8 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n - **Search** - Find music across your entire library - **Audio Player** with queue management - **Scrobbling** - Track your listening history - +- **Playlist Management** - Create and manage playlists +- **Caching** - Cache/Offline save your server ### Preview ![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true) @@ -117,7 +116,7 @@ docker run -p 3000:3000 \ 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 From f6a6ee5d2e18a89ea17e5b83562cfa97d29862b3 Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 7 Aug 2025 22:07:53 +0000 Subject: [PATCH 03/27] 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. --- OFFLINE_DOWNLOADS.md | 168 +++++ app/album/[id]/page.tsx | 87 ++- app/components/CacheManagement.tsx | 447 +++++++++-- app/components/OfflineIndicator.tsx | 226 ++++++ app/components/OfflineManagement.tsx | 395 ++++++++++ app/components/OfflineNavidromeContext.tsx | 367 +++++++++ app/components/OfflineNavidromeProvider.tsx | 281 +++++++ app/components/OfflineStatusIndicator.tsx | 65 ++ app/components/PostHogProvider.tsx | 46 +- app/components/RootLayoutClient.tsx | 27 +- app/components/UserProfile.tsx | 11 +- app/components/album-artwork.tsx | 13 +- app/layout.tsx | 14 +- app/manifest.ts | 134 ---- app/settings/page.tsx | 6 + hooks/use-offline-audio-player.ts | 279 +++++++ hooks/use-offline-downloads.ts | 452 +++++++++++ hooks/use-offline-library.ts | 535 +++++++++++++ lib/gravatar.ts | 2 +- lib/offline-library.ts | 782 ++++++++++++++++++++ next.config.mjs | 2 +- public/manifest.json | 129 ++++ public/sw.js | 0 23 files changed, 4239 insertions(+), 229 deletions(-) create mode 100644 OFFLINE_DOWNLOADS.md create mode 100644 app/components/OfflineIndicator.tsx create mode 100644 app/components/OfflineManagement.tsx create mode 100644 app/components/OfflineNavidromeContext.tsx create mode 100644 app/components/OfflineNavidromeProvider.tsx create mode 100644 app/components/OfflineStatusIndicator.tsx delete mode 100644 app/manifest.ts create mode 100644 hooks/use-offline-audio-player.ts create mode 100644 hooks/use-offline-downloads.ts create mode 100644 hooks/use-offline-library.ts create mode 100644 lib/offline-library.ts create mode 100644 public/manifest.json create mode 100644 public/sw.js diff --git a/OFFLINE_DOWNLOADS.md b/OFFLINE_DOWNLOADS.md new file mode 100644 index 0000000..c1af720 --- /dev/null +++ b/OFFLINE_DOWNLOADS.md @@ -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 diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index bb734cb..3bb03ec 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -13,6 +13,9 @@ import { Separator } from '@/components/ui/separator'; import { getNavidromeAPI } from '@/lib/navidrome'; import { useFavoriteAlbums } from '@/hooks/use-favorite-albums'; import { useIsMobile } from '@/hooks/use-mobile'; +import { OfflineIndicator, DownloadButton } from '@/app/components/OfflineIndicator'; +import { useOfflineDownloads } from '@/hooks/use-offline-downloads'; +import { useToast } from '@/hooks/use-toast'; export default function AlbumPage() { const { id } = useParams(); @@ -26,6 +29,8 @@ export default function AlbumPage() { const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums(); const isMobile = useIsMobile(); const api = getNavidromeAPI(); + const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads(); + const { toast } = useToast(); useEffect(() => { const fetchAlbum = async () => { @@ -121,6 +126,31 @@ export default function AlbumPage() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; + const handleDownloadAlbum = async () => { + if (!album || !tracklist.length) return; + + try { + toast({ + title: "Download Started", + description: `Starting download of "${album.name}" by ${album.artist}`, + }); + + await downloadAlbum(album, tracklist); + + toast({ + title: "Download Complete", + description: `"${album.name}" has been downloaded for offline listening`, + }); + } catch (error) { + console.error('Failed to download album:', error); + toast({ + title: "Download Failed", + description: `Failed to download "${album.name}". Please try again.`, + variant: "destructive" + }); + } + }; + // Dynamic cover art URLs based on image size const getMobileCoverArtUrl = () => { return album.coverArt && api @@ -162,6 +192,15 @@ export default function AlbumPage() {

{album.genre} โ€ข {album.year}

{album.songCount} songs, {formatDuration(album.duration)}

+ + {/* Offline indicator for mobile */} + {/* Right side - Controls */} @@ -173,6 +212,18 @@ export default function AlbumPage() { > + + {/* Download button for mobile */} + {isOfflineSupported && ( + + )} @@ -196,12 +247,36 @@ export default function AlbumPage() {

{album.artist}

- + + {/* Controls row */} +
+ + + {/* Download button for desktop */} + {isOfflineSupported && ( + + )} +
+ + {/* Album info */}

{album.genre} โ€ข {album.year}

{album.songCount} songs, {formatDuration(album.duration)}

+ + {/* Offline indicator for desktop */} +
@@ -237,6 +312,12 @@ export default function AlbumPage() { }`}> {song.title}

+ {/* Song offline indicator */} +
diff --git a/app/components/CacheManagement.tsx b/app/components/CacheManagement.tsx index 72dd8af..d4d7da6 100644 --- a/app/components/CacheManagement.tsx +++ b/app/components/CacheManagement.tsx @@ -1,16 +1,28 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } 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 { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; import { Database, Trash2, RefreshCw, - HardDrive + HardDrive, + Download, + Wifi, + WifiOff, + X, + Music, + Globe, + Settings } from 'lucide-react'; import { CacheManager } from '@/lib/cache'; +import { useOfflineDownloads, OfflineItem } from '@/hooks/use-offline-downloads'; +import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext'; export function CacheManagement() { const [cacheStats, setCacheStats] = useState({ @@ -20,6 +32,24 @@ export function CacheManagement() { }); const [isClearing, setIsClearing] = useState(false); const [lastCleared, setLastCleared] = useState(null); + const [offlineItems, setOfflineItems] = useState([]); + const [offlineMode, setOfflineMode] = useState(false); + const [autoDownloadQueue, setAutoDownloadQueue] = useState(false); + const [isDownloadingQueue, setIsDownloadingQueue] = useState(false); + + const { + isSupported: isOfflineSupported, + isInitialized: isOfflineInitialized, + downloadProgress, + offlineStats, + downloadQueue, + enableOfflineMode, + deleteOfflineContent, + getOfflineItems, + clearDownloadProgress + } = useOfflineDownloads(); + + const { queue } = useAudioPlayer(); const loadCacheStats = () => { if (typeof window === 'undefined') return; @@ -65,15 +95,34 @@ export function CacheManagement() { }); }; + const loadOfflineItems = useCallback(() => { + if (isOfflineInitialized) { + const items = getOfflineItems(); + setOfflineItems(items); + } + }, [isOfflineInitialized, getOfflineItems]); + useEffect(() => { loadCacheStats(); + loadOfflineItems(); + + // Load offline mode settings + const storedOfflineMode = localStorage.getItem('offline-mode-enabled'); + const storedAutoDownload = localStorage.getItem('auto-download-queue'); + + if (storedOfflineMode) { + setOfflineMode(JSON.parse(storedOfflineMode)); + } + if (storedAutoDownload) { + setAutoDownloadQueue(JSON.parse(storedAutoDownload)); + } // Check if there's a last cleared timestamp const lastClearedTime = localStorage.getItem('cache-last-cleared'); if (lastClearedTime) { setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString()); } - }, []); + }, [loadOfflineItems]); const handleClearCache = async () => { setIsClearing(true); @@ -142,77 +191,365 @@ export function CacheManagement() { loadCacheStats(); }; + const handleDeleteOfflineItem = async (item: OfflineItem) => { + try { + await deleteOfflineContent(item.id, item.type); + loadOfflineItems(); + loadCacheStats(); + } catch (error) { + console.error('Failed to delete offline item:', error); + } + }; + + // Convert Track to Song format for offline downloads + const convertTrackToSong = (track: Track) => ({ + id: track.id, + parent: track.albumId || '', + isDir: false, + title: track.name, + album: track.album, + artist: track.artist, + size: 0, // Will be filled when downloaded + contentType: 'audio/mpeg', + suffix: 'mp3', + duration: track.duration, + path: '', + created: new Date().toISOString(), + albumId: track.albumId, + artistId: track.artistId, + type: 'music' + }); + + const handleOfflineModeToggle = async (enabled: boolean) => { + setOfflineMode(enabled); + localStorage.setItem('offline-mode-enabled', JSON.stringify(enabled)); + + if (enabled && isOfflineSupported) { + try { + const convertedQueue = queue.map(convertTrackToSong); + await enableOfflineMode({ + forceOffline: enabled, + autoDownloadQueue, + currentQueue: convertedQueue + }); + } catch (error) { + console.error('Failed to enable offline mode:', error); + } + } + }; + + const handleAutoDownloadToggle = async (enabled: boolean) => { + setAutoDownloadQueue(enabled); + localStorage.setItem('auto-download-queue', JSON.stringify(enabled)); + + if (enabled && isOfflineSupported) { + const convertedQueue = queue.map(convertTrackToSong); + await enableOfflineMode({ + forceOffline: offlineMode, + autoDownloadQueue: enabled, + currentQueue: convertedQueue + }); + } + }; + + const handleDownloadCurrentQueue = async () => { + if (!queue.length || !isOfflineSupported) return; + + setIsDownloadingQueue(true); + try { + const convertedQueue = queue.map(convertTrackToSong); + await downloadQueue(convertedQueue); + loadOfflineItems(); + } catch (error) { + console.error('Failed to download queue:', error); + } finally { + setIsDownloadingQueue(false); + } + }; + + 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]; + }; + return ( - Cache Management + Cache & Offline Downloads - Manage application cache to improve performance and free up storage + Manage application cache and offline content for better performance - - {/* Cache Statistics */} -
-
-

{cacheStats.total}

-

Total Items

+ + {/* Regular Cache Statistics */} +
+

Application Cache

+
+
+

{cacheStats.total}

+

Total Items

+
+
+

{cacheStats.expired}

+

Expired

+
+
+

{cacheStats.size}

+

Storage Used

+
-
-

{cacheStats.expired}

-

Expired

-
-
-

{cacheStats.size}

-

Storage Used

+ + {/* Cache Actions */} +
+
+ + + +
+ +
- {/* Cache Actions */} -
-
- + )} + + {queue.length === 0 && ( +

+ Add songs to queue to enable downloading +

+ )} +
+
+ )} + + {/* Download Progress */} + {downloadProgress.status !== 'idle' && ( +
+
+ + {downloadProgress.status === 'downloading' && 'Downloading...'} + {downloadProgress.status === 'starting' && 'Starting download...'} + {downloadProgress.status === 'complete' && 'Download complete!'} + {downloadProgress.status === 'error' && 'Download failed'} + + +
+ + {downloadProgress.total > 0 && ( +
+ +
+ + {downloadProgress.completed} / {downloadProgress.total} songs + {downloadProgress.failed > 0 && ` (${downloadProgress.failed} failed)`} + + {Math.round((downloadProgress.completed / downloadProgress.total) * 100)}% +
+
)} - {isClearing ? 'Clearing...' : 'Clear All Cache'} - - - -
- - + + {downloadProgress.currentSong && ( +

+ Current: {downloadProgress.currentSong} +

+ )} + + {downloadProgress.error && ( +

+ Error: {downloadProgress.error} +

+ )} +
+ )} + + {/* Offline Items List */} + {offlineItems.length > 0 && ( +
+ +
+ {offlineItems.map((item) => ( +
+
+ +
+

{item.name}

+

+ {item.artist} โ€ข {item.type} +

+
+
+ +
+ ))} +
+
+ )} + + {offlineItems.length === 0 && ( +
+ +

No offline content downloaded

+

Visit an album page to download content for offline listening

+
+ )}
{/* Cache Info */}

Cache includes albums, artists, songs, and image URLs to improve loading times.

+ {isOfflineSupported && ( +

Offline downloads use Service Workers for true offline audio playback.

+ )} + {!isOfflineSupported && ( +

Limited offline support - only metadata cached without Service Worker support.

+ )} {lastCleared && (

Last cleared: {lastCleared}

)} diff --git a/app/components/OfflineIndicator.tsx b/app/components/OfflineIndicator.tsx new file mode 100644 index 0000000..977f3a1 --- /dev/null +++ b/app/components/OfflineIndicator.tsx @@ -0,0 +1,226 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Download, Check, X, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useOfflineDownloads } from '@/hooks/use-offline-downloads'; + +interface OfflineIndicatorProps { + id: string; + type: 'album' | 'song'; + className?: string; + showLabel?: boolean; + size?: 'sm' | 'md' | 'lg'; +} + +export function OfflineIndicator({ + id, + type, + className, + showLabel = false, + size = 'md' +}: OfflineIndicatorProps) { + const [isOffline, setIsOffline] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const { checkOfflineStatus, isInitialized } = useOfflineDownloads(); + + useEffect(() => { + let mounted = true; + + const checkStatus = async () => { + if (!isInitialized) return; + + setIsChecking(true); + try { + const status = await checkOfflineStatus(id, type); + if (mounted) { + setIsOffline(status); + } + } catch (error) { + console.error('Failed to check offline status:', error); + if (mounted) { + setIsOffline(false); + } + } finally { + if (mounted) { + setIsChecking(false); + } + } + }; + + checkStatus(); + + return () => { + mounted = false; + }; + }, [id, type, isInitialized, checkOfflineStatus]); + + const iconSize = { + sm: 'h-3 w-3', + md: 'h-4 w-4', + lg: 'h-5 w-5' + }[size]; + + const textSize = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + }[size]; + + if (isChecking) { + return ( +
+ + {showLabel && Checking...} +
+ ); + } + + if (!isOffline) { + return null; // Don't show anything if not downloaded + } + + return ( +
+ + {showLabel && ( + + {type === 'album' ? 'Album Downloaded' : 'Downloaded'} + + )} +
+ ); +} + +interface DownloadButtonProps { + id: string; + type: 'album' | 'song'; + onDownload?: () => void; + className?: string; + size?: 'sm' | 'md' | 'lg'; + variant?: 'default' | 'outline' | 'ghost'; + children?: React.ReactNode; +} + +export function DownloadButton({ + id, + type, + onDownload, + className, + size = 'md', + variant = 'outline', + children +}: DownloadButtonProps) { + const [isOffline, setIsOffline] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const { + checkOfflineStatus, + deleteOfflineContent, + isInitialized, + downloadProgress + } = useOfflineDownloads(); + + const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting'; + + useEffect(() => { + let mounted = true; + + const checkStatus = async () => { + if (!isInitialized) return; + + setIsChecking(true); + try { + const status = await checkOfflineStatus(id, type); + if (mounted) { + setIsOffline(status); + } + } catch (error) { + console.error('Failed to check offline status:', error); + if (mounted) { + setIsOffline(false); + } + } finally { + if (mounted) { + setIsChecking(false); + } + } + }; + + checkStatus(); + + return () => { + mounted = false; + }; + }, [id, type, isInitialized, checkOfflineStatus]); + + const handleClick = async () => { + if (isOffline) { + // Remove from offline storage + try { + await deleteOfflineContent(id, type); + setIsOffline(false); + } catch (error) { + console.error('Failed to delete offline content:', error); + } + } else { + // Start download + if (onDownload) { + onDownload(); + } + } + }; + + const buttonSize = { + sm: 'sm', + md: 'default', + lg: 'lg' + }[size] as 'sm' | 'default' | 'lg'; + + const iconSize = { + sm: 'h-3 w-3', + md: 'h-4 w-4', + lg: 'h-5 w-5' + }[size]; + + if (isChecking) { + return ( + + ); + } + + return ( + + ); +} diff --git a/app/components/OfflineManagement.tsx b/app/components/OfflineManagement.tsx new file mode 100644 index 0000000..6105213 --- /dev/null +++ b/app/components/OfflineManagement.tsx @@ -0,0 +1,395 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { useToast } from '@/hooks/use-toast'; +import { useOfflineLibrary } from '@/hooks/use-offline-library'; +import { + Download, + Trash2, + RefreshCw, + Wifi, + WifiOff, + Database, + Clock, + AlertCircle, + CheckCircle, + Music, + User, + List, + HardDrive +} from 'lucide-react'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatDate(date: Date | null): string { + if (!date) return 'Never'; + return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(); +} + +export function OfflineManagement() { + const { toast } = useToast(); + const [isClearing, setIsClearing] = useState(false); + + const { + isInitialized, + isOnline, + isSyncing, + lastSync, + stats, + syncProgress, + syncLibraryFromServer, + syncPendingOperations, + clearOfflineData, + refreshStats + } = useOfflineLibrary(); + + // Refresh stats periodically + useEffect(() => { + const interval = setInterval(() => { + if (isInitialized && !isSyncing) { + refreshStats(); + } + }, 10000); // Every 10 seconds + + return () => clearInterval(interval); + }, [isInitialized, isSyncing, refreshStats]); + + const handleFullSync = async () => { + try { + await syncLibraryFromServer(); + toast({ + title: "Sync Complete", + description: "Your music library has been synced for offline use.", + }); + } catch (error) { + console.error('Full sync failed:', error); + toast({ + title: "Sync Failed", + description: "Failed to sync library. Check your connection and try again.", + variant: "destructive" + }); + } + }; + + const handlePendingSync = async () => { + try { + await syncPendingOperations(); + toast({ + title: "Pending Operations Synced", + description: "All pending changes have been synced to the server.", + }); + } catch (error) { + console.error('Pending sync failed:', error); + toast({ + title: "Sync Failed", + description: "Failed to sync pending operations. Will retry automatically when online.", + variant: "destructive" + }); + } + }; + + const handleClearData = async () => { + if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) { + return; + } + + setIsClearing(true); + try { + await clearOfflineData(); + toast({ + title: "Offline Data Cleared", + description: "All offline music data has been removed.", + }); + } catch (error) { + console.error('Clear data failed:', error); + toast({ + title: "Clear Failed", + description: "Failed to clear offline data. Please try again.", + variant: "destructive" + }); + } finally { + setIsClearing(false); + } + }; + + if (!isInitialized) { + return ( + + + + + Offline Library + + + Setting up offline library... + + + +
+
+ +

Initializing offline storage...

+
+
+
+
+ ); + } + + return ( +
+ {/* Connection Status */} + + + + {isOnline ? ( + + ) : ( + + )} + Connection Status + + + +
+
+ + {isOnline ? "Online" : "Offline"} + + + {isOnline ? "Connected to Navidrome server" : "Working offline"} + +
+ + {stats.pendingOperations > 0 && ( +
+ + + {stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''} + +
+ )} +
+
+
+ + {/* Sync Status */} + + + + + Library Sync + + + Keep your offline library up to date + + + + {isSyncing && syncProgress && ( +
+
+ {syncProgress.stage} + {syncProgress.current}% +
+ +
+ )} + +
+
+

Last Sync

+

+ + {formatDate(lastSync)} +

+
+ +
+ {stats.pendingOperations > 0 && isOnline && ( + + )} + + +
+
+
+
+ + {/* Library Statistics */} + + + + + Offline Library Stats + + + Your offline music collection + + + +
+
+
+ +
+
+

{stats.albums.toLocaleString()}

+

Albums

+
+
+ +
+
+ +
+
+

{stats.artists.toLocaleString()}

+

Artists

+
+
+ +
+
+ +
+
+

{stats.songs.toLocaleString()}

+

Songs

+
+
+ +
+
+ +
+
+

{stats.playlists.toLocaleString()}

+

Playlists

+
+
+
+ + + +
+
+ + Storage Used +
+ + {formatBytes(stats.storageSize)} + +
+
+
+ + {/* Offline Features */} + + + Offline Features + + What works when you're offline + + + +
+
+ +
+

Browse & Search

+

+ Browse your synced albums, artists, and search offline +

+
+
+ +
+ +
+

Favorites & Playlists

+

+ Star songs/albums and create playlists (syncs when online) +

+
+
+ +
+ +
+

Play Downloaded Music

+

+ Play songs you've downloaded for offline listening +

+
+
+ +
+ +
+

Auto-Sync

+

+ Changes sync automatically when you reconnect +

+
+
+
+
+
+ + {/* Danger Zone */} + + + Danger Zone + + Permanently delete all offline data + + + +
+
+

Clear All Offline Data

+

+ This will remove all synced library data and downloaded audio +

+
+ + +
+
+
+
+ ); +} diff --git a/app/components/OfflineNavidromeContext.tsx b/app/components/OfflineNavidromeContext.tsx new file mode 100644 index 0000000..5fc9d54 --- /dev/null +++ b/app/components/OfflineNavidromeContext.tsx @@ -0,0 +1,367 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; +import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { useOfflineLibrary } from '@/hooks/use-offline-library'; + +interface OfflineNavidromeContextType { + // Data (offline-first) + albums: Album[]; + artists: Artist[]; + playlists: Playlist[]; + + // Loading states + isLoading: boolean; + albumsLoading: boolean; + artistsLoading: boolean; + playlistsLoading: boolean; + + // Connection state + isOnline: boolean; + isOfflineReady: boolean; + + // Error states + error: string | null; + + // Offline sync status + isSyncing: boolean; + lastSync: Date | null; + pendingOperations: number; + + // Methods (offline-aware) + searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>; + getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>; + getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>; + getPlaylists: () => Promise; + refreshData: () => Promise; + + // Offline-capable operations + starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + createPlaylist: (name: string, songIds?: string[]) => Promise; + scrobble: (songId: string) => Promise; + + // Sync management + syncLibrary: () => Promise; + syncPendingOperations: () => Promise; + clearOfflineData: () => Promise; +} + +const OfflineNavidromeContext = createContext(undefined); + +interface OfflineNavidromeProviderProps { + children: ReactNode; +} + +export const OfflineNavidromeProvider: React.FC = ({ children }) => { + const [albums, setAlbums] = useState([]); + const [artists, setArtists] = useState([]); + const [playlists, setPlaylists] = useState([]); + + const [albumsLoading, setAlbumsLoading] = useState(false); + const [artistsLoading, setArtistsLoading] = useState(false); + const [playlistsLoading, setPlaylistsLoading] = useState(false); + + const [error, setError] = useState(null); + + // Use the original Navidrome context for online operations + const originalNavidrome = useNavidrome(); + + // Use offline library for offline operations + const { + isInitialized: isOfflineReady, + isOnline, + isSyncing, + lastSync, + stats, + syncLibraryFromServer, + syncPendingOperations: syncPendingOps, + getAlbums: getAlbumsOffline, + getArtists: getArtistsOffline, + getAlbum: getAlbumOffline, + getPlaylists: getPlaylistsOffline, + searchOffline, + starOffline, + unstarOffline, + createPlaylistOffline, + scrobbleOffline, + clearOfflineData: clearOfflineDataInternal, + refreshStats + } = useOfflineLibrary(); + + const isLoading = albumsLoading || artistsLoading || playlistsLoading; + const pendingOperations = stats.pendingOperations; + + // Load initial data (offline-first approach) + const loadAlbums = useCallback(async () => { + setAlbumsLoading(true); + setError(null); + + try { + const albumData = await getAlbumsOffline(); + setAlbums(albumData); + } catch (err) { + console.error('Failed to load albums:', err); + setError('Failed to load albums'); + } finally { + setAlbumsLoading(false); + } + }, [getAlbumsOffline]); + + const loadArtists = useCallback(async () => { + setArtistsLoading(true); + setError(null); + + try { + const artistData = await getArtistsOffline(); + setArtists(artistData); + } catch (err) { + console.error('Failed to load artists:', err); + setError('Failed to load artists'); + } finally { + setArtistsLoading(false); + } + }, [getArtistsOffline]); + + const loadPlaylists = useCallback(async () => { + setPlaylistsLoading(true); + setError(null); + + try { + const playlistData = await getPlaylistsOffline(); + setPlaylists(playlistData); + } catch (err) { + console.error('Failed to load playlists:', err); + setError('Failed to load playlists'); + } finally { + setPlaylistsLoading(false); + } + }, [getPlaylistsOffline]); + + const refreshData = useCallback(async () => { + await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]); + await refreshStats(); + }, [loadAlbums, loadArtists, loadPlaylists, refreshStats]); + + // Initialize data when offline library is ready + useEffect(() => { + if (isOfflineReady) { + refreshData(); + } + }, [isOfflineReady, refreshData]); + + // Auto-sync when coming back online + useEffect(() => { + if (isOnline && isOfflineReady && pendingOperations > 0) { + console.log('Back online with pending operations, starting sync...'); + syncPendingOps(); + } + }, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]); + + // Offline-first methods + const searchMusic = useCallback(async (query: string) => { + setError(null); + try { + return await searchOffline(query); + } catch (err) { + console.error('Search failed:', err); + setError('Search failed'); + return { artists: [], albums: [], songs: [] }; + } + }, [searchOffline]); + + const getAlbum = useCallback(async (albumId: string) => { + setError(null); + try { + return await getAlbumOffline(albumId); + } catch (err) { + console.error('Failed to get album:', err); + setError('Failed to get album'); + return null; + } + }, [getAlbumOffline]); + + const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => { + setError(null); + try { + // For now, use the original implementation if online, or search offline + if (isOnline && originalNavidrome.api) { + return await originalNavidrome.getArtist(artistId); + } else { + // Try to find artist in offline data + const allArtists = await getArtistsOffline(); + const artist = allArtists.find(a => a.id === artistId); + if (!artist) return null; + + const allAlbums = await getAlbumsOffline(); + const artistAlbums = allAlbums.filter(a => a.artistId === artistId); + + return { artist, albums: artistAlbums }; + } + } catch (err) { + console.error('Failed to get artist:', err); + setError('Failed to get artist'); + return null; + } + }, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]); + + const getPlaylistsWrapper = useCallback(async (): Promise => { + try { + return await getPlaylistsOffline(); + } catch (err) { + console.error('Failed to get playlists:', err); + return []; + } + }, [getPlaylistsOffline]); + + // Offline-capable operations + const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { + setError(null); + try { + await starOffline(id, type); + // Refresh relevant data + if (type === 'album') { + await loadAlbums(); + } else if (type === 'artist') { + await loadArtists(); + } + } catch (err) { + console.error('Failed to star item:', err); + setError('Failed to star item'); + throw err; + } + }, [starOffline, loadAlbums, loadArtists]); + + const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { + setError(null); + try { + await unstarOffline(id, type); + // Refresh relevant data + if (type === 'album') { + await loadAlbums(); + } else if (type === 'artist') { + await loadArtists(); + } + } catch (err) { + console.error('Failed to unstar item:', err); + setError('Failed to unstar item'); + throw err; + } + }, [unstarOffline, loadAlbums, loadArtists]); + + const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise => { + setError(null); + try { + const playlist = await createPlaylistOffline(name, songIds); + await loadPlaylists(); // Refresh playlists + return playlist; + } catch (err) { + console.error('Failed to create playlist:', err); + setError('Failed to create playlist'); + throw err; + } + }, [createPlaylistOffline, loadPlaylists]); + + const scrobble = useCallback(async (songId: string) => { + try { + await scrobbleOffline(songId); + } catch (err) { + console.error('Failed to scrobble:', err); + // Don't set error state for scrobbling failures as they're not critical + } + }, [scrobbleOffline]); + + // Sync management + const syncLibrary = useCallback(async () => { + setError(null); + try { + await syncLibraryFromServer(); + await refreshData(); // Refresh local state after sync + } catch (err) { + console.error('Library sync failed:', err); + setError('Library sync failed'); + throw err; + } + }, [syncLibraryFromServer, refreshData]); + + const syncPendingOperations = useCallback(async () => { + try { + await syncPendingOps(); + await refreshStats(); + } catch (err) { + console.error('Failed to sync pending operations:', err); + // Don't throw or set error for pending operations sync + } + }, [syncPendingOps, refreshStats]); + + const clearOfflineData = useCallback(async () => { + try { + await clearOfflineDataInternal(); + setAlbums([]); + setArtists([]); + setPlaylists([]); + } catch (err) { + console.error('Failed to clear offline data:', err); + setError('Failed to clear offline data'); + throw err; + } + }, [clearOfflineDataInternal]); + + const value: OfflineNavidromeContextType = { + // Data + albums, + artists, + playlists, + + // Loading states + isLoading, + albumsLoading, + artistsLoading, + playlistsLoading, + + // Connection state + isOnline, + isOfflineReady, + + // Error state + error, + + // Offline sync status + isSyncing, + lastSync, + pendingOperations, + + // Methods + searchMusic, + getAlbum, + getArtist, + getPlaylists: getPlaylistsWrapper, + refreshData, + + // Offline-capable operations + starItem, + unstarItem, + createPlaylist, + scrobble, + + // Sync management + syncLibrary, + syncPendingOperations, + clearOfflineData + }; + + return ( + + {children} + + ); +}; + +export const useOfflineNavidrome = (): OfflineNavidromeContextType => { + const context = useContext(OfflineNavidromeContext); + if (context === undefined) { + throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider'); + } + return context; +}; diff --git a/app/components/OfflineNavidromeProvider.tsx b/app/components/OfflineNavidromeProvider.tsx new file mode 100644 index 0000000..b1abd49 --- /dev/null +++ b/app/components/OfflineNavidromeProvider.tsx @@ -0,0 +1,281 @@ +'use client'; + +import React, { createContext, useContext, ReactNode } from 'react'; +import { Album, Artist, Song, Playlist } from '@/lib/navidrome'; +import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext'; +import { useOfflineLibrary } from '@/hooks/use-offline-library'; + +interface OfflineNavidromeContextType { + // All the original NavidromeContext methods but with offline-first behavior + getAlbums: (starred?: boolean) => Promise; + getArtists: (starred?: boolean) => Promise; + getSongs: (albumId?: string, artistId?: string) => Promise; + getPlaylists: () => Promise; + + // Offline-aware operations + starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + createPlaylist: (name: string, songIds?: string[]) => Promise; + updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise; + deletePlaylist: (id: string) => Promise; + scrobble: (songId: string) => Promise; + + // Offline state + isOfflineMode: boolean; + hasPendingOperations: boolean; + lastSync: Date | null; +} + +const OfflineNavidromeContext = createContext(undefined); + +interface OfflineNavidromeProviderInnerProps { + children: ReactNode; +} + +// Inner component that has access to both contexts +const OfflineNavidromeProviderInner: React.FC = ({ children }) => { + const navidromeContext = useNavidrome(); + const offlineLibrary = useOfflineLibrary(); + + // Offline-first data retrieval methods + const getAlbums = async (starred?: boolean): Promise => { + if (!offlineLibrary.isOnline || !navidromeContext.api) { + // Offline mode - get from IndexedDB + return await offlineLibrary.getAlbums(starred); + } + + try { + // Online mode - try server first, fallback to offline + const albums = starred + ? await navidromeContext.api.getAlbums('starred', 1000) + : await navidromeContext.api.getAlbums('alphabeticalByName', 1000); + return albums; + } catch (error) { + console.warn('Server request failed, falling back to offline data:', error); + return await offlineLibrary.getAlbums(starred); + } + }; + + const getArtists = async (starred?: boolean): Promise => { + if (!offlineLibrary.isOnline || !navidromeContext.api) { + return await offlineLibrary.getArtists(starred); + } + + try { + const artists = await navidromeContext.api.getArtists(); + if (starred) { + // Filter starred artists from the full list + const starredData = await navidromeContext.api.getStarred2(); + const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []); + return artists.filter(artist => starredArtistIds.has(artist.id)); + } + return artists; + } catch (error) { + console.warn('Server request failed, falling back to offline data:', error); + return await offlineLibrary.getArtists(starred); + } + }; + + const getSongs = async (albumId?: string, artistId?: string): Promise => { + if (!offlineLibrary.isOnline || !navidromeContext.api) { + return await offlineLibrary.getSongs(albumId, artistId); + } + + try { + if (albumId) { + const { songs } = await navidromeContext.api.getAlbum(albumId); + return songs; + } else if (artistId) { + const { albums } = await navidromeContext.api.getArtist(artistId); + const allSongs: Song[] = []; + for (const album of albums) { + const { songs } = await navidromeContext.api.getAlbum(album.id); + allSongs.push(...songs); + } + return allSongs; + } else { + return await navidromeContext.getAllSongs(); + } + } catch (error) { + console.warn('Server request failed, falling back to offline data:', error); + return await offlineLibrary.getSongs(albumId, artistId); + } + }; + + const getPlaylists = async (): Promise => { + if (!offlineLibrary.isOnline || !navidromeContext.api) { + return await offlineLibrary.getPlaylists(); + } + + try { + return await navidromeContext.api.getPlaylists(); + } catch (error) { + console.warn('Server request failed, falling back to offline data:', error); + return await offlineLibrary.getPlaylists(); + } + }; + + // Offline-aware operations (queue for sync when offline) + const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + await navidromeContext.starItem(id, type); + // Update offline data immediately + await offlineLibrary.starOffline(id, type); + return; + } catch (error) { + console.warn('Server star failed, queuing for sync:', error); + } + } + + // Queue for sync when back online + await offlineLibrary.starOffline(id, type); + await offlineLibrary.queueSyncOperation({ + type: 'star', + entityType: type, + entityId: id, + data: {} + }); + }; + + const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + await navidromeContext.unstarItem(id, type); + await offlineLibrary.unstarOffline(id, type); + return; + } catch (error) { + console.warn('Server unstar failed, queuing for sync:', error); + } + } + + await offlineLibrary.unstarOffline(id, type); + await offlineLibrary.queueSyncOperation({ + type: 'unstar', + entityType: type, + entityId: id, + data: {} + }); + }; + + const createPlaylist = async (name: string, songIds?: string[]): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + const playlist = await navidromeContext.createPlaylist(name, songIds); + await offlineLibrary.createPlaylistOffline(name, songIds || []); + return; + } catch (error) { + console.warn('Server playlist creation failed, queuing for sync:', error); + } + } + + // Create offline + await offlineLibrary.createPlaylistOffline(name, songIds || []); + await offlineLibrary.queueSyncOperation({ + type: 'create_playlist', + entityType: 'playlist', + entityId: 'temp-' + Date.now(), + data: { name, songIds: songIds || [] } + }); + }; + + const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + await navidromeContext.updatePlaylist(id, name, comment, songIds); + await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds); + return; + } catch (error) { + console.warn('Server playlist update failed, queuing for sync:', error); + } + } + + await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds); + await offlineLibrary.queueSyncOperation({ + type: 'update_playlist', + entityType: 'playlist', + entityId: id, + data: { name, comment, songIds } + }); + }; + + const deletePlaylist = async (id: string): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + await navidromeContext.deletePlaylist(id); + await offlineLibrary.deletePlaylistOffline(id); + return; + } catch (error) { + console.warn('Server playlist deletion failed, queuing for sync:', error); + } + } + + await offlineLibrary.deletePlaylistOffline(id); + await offlineLibrary.queueSyncOperation({ + type: 'delete_playlist', + entityType: 'playlist', + entityId: id, + data: {} + }); + }; + + const scrobble = async (songId: string): Promise => { + if (offlineLibrary.isOnline && navidromeContext.api) { + try { + await navidromeContext.scrobble(songId); + return; + } catch (error) { + console.warn('Server scrobble failed, queuing for sync:', error); + } + } + + await offlineLibrary.queueSyncOperation({ + type: 'scrobble', + entityType: 'song', + entityId: songId, + data: { timestamp: Date.now() } + }); + }; + + const contextValue: OfflineNavidromeContextType = { + getAlbums, + getArtists, + getSongs, + getPlaylists, + starItem, + unstarItem, + createPlaylist, + updatePlaylist, + deletePlaylist, + scrobble, + isOfflineMode: !offlineLibrary.isOnline, + hasPendingOperations: offlineLibrary.stats.pendingOperations > 0, + lastSync: offlineLibrary.lastSync + }; + + return ( + + {children} + + ); +}; + +// Main provider component +export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + return ( + + + {children} + + + ); +}; + +// Hook to use the offline-aware Navidrome context +export const useOfflineNavidrome = (): OfflineNavidromeContextType => { + const context = useContext(OfflineNavidromeContext); + if (!context) { + throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider'); + } + return context; +}; diff --git a/app/components/OfflineStatusIndicator.tsx b/app/components/OfflineStatusIndicator.tsx new file mode 100644 index 0000000..f739db8 --- /dev/null +++ b/app/components/OfflineStatusIndicator.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { useOfflineLibrary } from '@/hooks/use-offline-library'; +import { Wifi, WifiOff, Download, Clock } from 'lucide-react'; + +export function OfflineStatusIndicator() { + const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary(); + + if (!isOnline) { + return ( + + + Offline Mode + + ); + } + + if (isSyncing) { + return ( + + + Syncing... + + ); + } + + if (stats.pendingOperations > 0) { + return ( + + + {stats.pendingOperations} pending + + ); + } + + return ( + + + Online + + ); +} + +export function OfflineLibraryStats() { + const { stats, lastSync } = useOfflineLibrary(); + + if (!stats.albums && !stats.songs && !stats.artists) { + return null; + } + + return ( +
+
+ ๐Ÿ“€ {stats.albums} albums โ€ข ๐ŸŽต {stats.songs} songs โ€ข ๐Ÿ‘ค {stats.artists} artists +
+ {lastSync && ( +
+ Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()} +
+ )} +
+ ); +} diff --git a/app/components/PostHogProvider.tsx b/app/components/PostHogProvider.tsx index 2afcd45..4d6705c 100644 --- a/app/components/PostHogProvider.tsx +++ b/app/components/PostHogProvider.tsx @@ -11,7 +11,8 @@ function PathnameTracker() { const searchParams = useSearchParams() useEffect(() => { - if (posthogClient) { + // Only track if PostHog client is available and properly initialized + if (posthogClient && typeof posthogClient.capture === 'function') { posthogClient.capture('$pageview', { path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''), }) @@ -31,20 +32,35 @@ function SuspendedPostHogPageView() { 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", - }) + const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + + // Only initialize PostHog if we have a valid key + if (posthogKey && posthogKey.trim() !== '') { + posthog.init(posthogKey, { + 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", + }); + } else { + console.log('PostHog not initialized - NEXT_PUBLIC_POSTHOG_KEY not provided'); + } }, []) - return ( - - - {children} - - ) + // Only provide PostHog context if we have a key + const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + + if (posthogKey && posthogKey.trim() !== '') { + return ( + + + {children} + + ); + } + + // Return children without PostHog context if no key is provided + return <>{children}; } \ No newline at end of file diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index c332370..4ace375 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -2,7 +2,7 @@ import React from "react"; import { AudioPlayerProvider } from "../components/AudioPlayerContext"; -import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext"; +import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider"; import { NavidromeConfigProvider } from "../components/NavidromeConfigContext"; import { ThemeProvider } from "../components/ThemeProvider"; import { PostHogProvider } from "../components/PostHogProvider"; @@ -14,8 +14,20 @@ import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color"; import { LoginForm } from "./start-screen"; import Image from "next/image"; +// Service Worker registration +if (typeof window !== 'undefined' && '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); + }); +} + 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 [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash @@ -58,10 +70,9 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) { return <>{children}; } - // Show start screen ONLY if: - // 1. First-time user (no onboarding completed), OR - // 2. User has completed onboarding BUT there's an error AND no config exists - const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig); + // Show start screen ONLY if first-time user (no onboarding completed) + // In offline-first mode, we don't need to check for errors since the app works offline + const shouldShowStartScreen = !hasCompletedOnboarding; if (shouldShowStartScreen) { return ( @@ -87,7 +98,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo - + @@ -96,7 +107,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo - + diff --git a/app/components/UserProfile.tsx b/app/components/UserProfile.tsx index 8152e5a..31d59c5 100644 --- a/app/components/UserProfile.tsx +++ b/app/components/UserProfile.tsx @@ -94,9 +94,9 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) { }} /> ) : ( -
- -
+
+ +
)} @@ -106,8 +106,8 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) { {`${userInfo.username}'s ) : ( @@ -207,3 +207,4 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) { ); } } + diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 7673ce2..c03336e 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -24,8 +24,9 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { OfflineIndicator } from "@/app/components/OfflineIndicator"; interface AlbumArtworkProps extends React.HTMLAttributes { album: Album @@ -148,6 +149,16 @@ export function AlbumArtwork({
handlePlayAlbum(album)}/>
+ + {/* Offline indicator in top-right corner */} +
+ +

{album.name}

diff --git a/app/layout.tsx b/app/layout.tsx index 7068897..9a71680 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -26,12 +26,6 @@ export const metadata = { 'max-snippet': -1, }, }, - viewport: { - width: 'device-width', - initialScale: 1, - maximumScale: 1, - userScalable: false, - }, appleWebApp: { capable: true, statusBarStyle: 'black-translucent', @@ -57,6 +51,13 @@ export const metadata = { }, }; +export const viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", @@ -76,6 +77,7 @@ export default function Layout({ children }: LayoutProps) { return ( +