diff --git a/.github/instructions/routing-and-navigation.instructions.md b/.github/instructions/routing-and-navigation.instructions.md
index d4890a2..78b32a4 100644
--- a/.github/instructions/routing-and-navigation.instructions.md
+++ b/.github/instructions/routing-and-navigation.instructions.md
@@ -47,7 +47,7 @@ router.replace('/login');
## Layout Files
- `app/_layout.tsx` – Root Stack with auth guard, splash screen, and theme-aware header styles.
-- `app/(tabs)/_layout.tsx` – Tab navigator with `MiniPlayer` above the tab bar and `SearchableHeader` as custom header.
+- `app/(tabs)/_layout.tsx` – Tab navigator with `PlayerPhoneMini` (via `BottomChrome`) above the tab bar and `SearchableHeader` as custom header.
## Auth Guard
diff --git a/babel.config.js b/babel.config.js
index 9d89e13..7a007c1 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -2,5 +2,16 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
+ env: {
+ // Production builds (BABEL_ENV/NODE_ENV=production, e.g. EAS release
+ // builds and `expo export`) strip ALL console.* calls. The app has its
+ // own opt-in file-based logging (see the Logging screen / imageCacheLogger
+ // + diagnostics stores) for anything user-facing, so console output is
+ // dev-only noise that isn't visible to users — no point shipping it or
+ // spending cycles on it. Dev builds keep console intact.
+ production: {
+ plugins: ['transform-remove-console'],
+ },
+ },
};
};
diff --git a/package-lock.json b/package-lock.json
index d689ff2..909c0ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "substreamer",
- "version": "8.0.65",
+ "version": "8.0.68",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "substreamer",
- "version": "8.0.65",
+ "version": "8.0.68",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -21,15 +21,15 @@
"@react-native-vector-icons/ionicons": "^13.1.1",
"@react-native-vector-icons/material-design-icons": "^13.1.1",
"@shopify/flash-list": "^2.3.1",
- "expo": "~56.0.5",
+ "expo": "~56.0.8",
"expo-async-fs": "file:./modules/expo-async-fs",
"expo-backup-exclusions": "file:./modules/expo-backup-exclusions",
"expo-battery": "~56.0.4",
"expo-blur": "~56.0.3",
- "expo-build-properties": "~56.0.15",
+ "expo-build-properties": "~56.0.16",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.15",
- "expo-crypto": "56.0.3",
+ "expo-crypto": "~56.0.4",
"expo-device": "~56.0.4",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
@@ -39,14 +39,14 @@
"expo-image-resize": "file:./modules/expo-image-resize",
"expo-intent-launcher": "~56.0.4",
"expo-linear-gradient": "~56.0.4",
- "expo-linking": "~56.0.12",
+ "expo-linking": "~56.0.13",
"expo-localization": "~56.0.6",
- "expo-location": "~56.0.14",
+ "expo-location": "~56.0.15",
"expo-move-to-back": "file:./modules/expo-move-to-back",
- "expo-notifications": "~56.0.14",
- "expo-router": "~56.2.7",
+ "expo-notifications": "~56.0.15",
+ "expo-router": "~56.2.8",
"expo-screen-orientation": "~56.0.5",
- "expo-sharing": "~56.0.14",
+ "expo-sharing": "~56.0.15",
"expo-sqlite": "~56.0.4",
"expo-ssl-trust": "file:./modules/expo-ssl-trust",
"expo-status-bar": "~56.0.4",
@@ -75,7 +75,8 @@
"@testing-library/react-native": "^13.3.3",
"@types/jest": "~29.5.14",
"@types/react": "^19.2.15",
- "expo-dev-client": "~56.0.16",
+ "babel-plugin-transform-remove-console": "^6.9.4",
+ "expo-dev-client": "~56.0.18",
"jest": "~29.7.0",
"jest-expo": "~56.0.4",
"patch-package": "^8.0.1",
@@ -1409,9 +1410,9 @@
"license": "MIT AND Apache-2.0"
},
"node_modules/@expo/cli": {
- "version": "56.1.12",
- "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.12.tgz",
- "integrity": "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA==",
+ "version": "56.1.13",
+ "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.13.tgz",
+ "integrity": "sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
@@ -1427,9 +1428,9 @@
"@expo/metro-config": "~56.0.13",
"@expo/metro-file-map": "^56.0.3",
"@expo/osascript": "^2.6.0",
- "@expo/package-manager": "^1.12.0",
+ "@expo/package-manager": "^1.12.1",
"@expo/plist": "^0.7.0",
- "@expo/prebuild-config": "^56.0.13",
+ "@expo/prebuild-config": "^56.0.14",
"@expo/require-utils": "^56.1.3",
"@expo/router-server": "^56.0.12",
"@expo/schema-utils": "^56.0.0",
@@ -1856,9 +1857,9 @@
}
},
"node_modules/@expo/package-manager": {
- "version": "1.12.0",
- "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.0.tgz",
- "integrity": "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==",
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz",
+ "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==",
"license": "MIT",
"dependencies": {
"@expo/json-file": "^10.2.0",
@@ -1881,9 +1882,9 @@
}
},
"node_modules/@expo/prebuild-config": {
- "version": "56.0.13",
- "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.13.tgz",
- "integrity": "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA==",
+ "version": "56.0.14",
+ "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.14.tgz",
+ "integrity": "sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==",
"license": "MIT",
"dependencies": {
"@expo/config": "~56.0.9",
@@ -1893,7 +1894,7 @@
"@expo/json-file": "^10.2.0",
"@react-native/normalize-colors": "0.85.3",
"debug": "^4.3.1",
- "expo-modules-autolinking": "~56.0.13",
+ "expo-modules-autolinking": "~56.0.14",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
}
@@ -1980,9 +1981,9 @@
"license": "MIT"
},
"node_modules/@expo/ui": {
- "version": "56.0.14",
- "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.14.tgz",
- "integrity": "sha512-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg==",
+ "version": "56.0.15",
+ "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.15.tgz",
+ "integrity": "sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^2.1.0",
@@ -2039,9 +2040,19 @@
"license": "Python-2.0"
},
"node_modules/@expo/xcpretty/node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4547,6 +4558,13 @@
"@babel/plugin-syntax-flow": "^7.12.1"
}
},
+ "node_modules/babel-plugin-transform-remove-console": {
+ "version": "6.9.4",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz",
+ "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/babel-preset-current-node-syntax": {
"version": "1.2.0",
"dev": true,
@@ -4573,9 +4591,9 @@
}
},
"node_modules/babel-preset-expo": {
- "version": "56.0.13",
- "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.13.tgz",
- "integrity": "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ==",
+ "version": "56.0.14",
+ "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.14.tgz",
+ "integrity": "sha512-+JKVMYf3HajO3tPRA9DlKd/VhZOPTHyTzUo2yZajfMAoQ3l5VEdGVxm2MzX4DXMNKXwsC8GOeTRx7CrO/5dBDA==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.20.5",
@@ -4624,7 +4642,7 @@
"peerDependencies": {
"@babel/runtime": "^7.20.0",
"expo": "*",
- "expo-widgets": "^56.0.15",
+ "expo-widgets": "^56.0.16",
"react-refresh": ">=0.14.0 <1.0.0"
},
"peerDependenciesMeta": {
@@ -5918,13 +5936,13 @@
}
},
"node_modules/expo": {
- "version": "56.0.5",
- "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.5.tgz",
- "integrity": "sha512-5rTo664JOpRIx41CGW6gbnQJyU5JCu6P1JXMgMzISViIIyevmaThBM3yxoTDKutqGAopFuZNhqHPUCWUg6WhEA==",
+ "version": "56.0.8",
+ "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.8.tgz",
+ "integrity": "sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.0",
- "@expo/cli": "^56.1.12",
+ "@expo/cli": "^56.1.13",
"@expo/config": "~56.0.9",
"@expo/config-plugins": "~56.0.8",
"@expo/devtools": "~56.0.2",
@@ -5935,14 +5953,14 @@
"@expo/metro": "~56.0.0",
"@expo/metro-config": "~56.0.13",
"@ungap/structured-clone": "^1.3.0",
- "babel-preset-expo": "~56.0.13",
+ "babel-preset-expo": "~56.0.14",
"expo-asset": "~56.0.15",
"expo-constants": "~56.0.16",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
"expo-keep-awake": "~56.0.3",
- "expo-modules-autolinking": "~56.0.13",
- "expo-modules-core": "~56.0.13",
+ "expo-modules-autolinking": "~56.0.14",
+ "expo-modules-core": "~56.0.14",
"pretty-format": "^29.7.0",
"react-refresh": "^0.14.2",
"whatwg-url-minimum": "^0.1.2"
@@ -6033,9 +6051,9 @@
}
},
"node_modules/expo-build-properties": {
- "version": "56.0.15",
- "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.15.tgz",
- "integrity": "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g==",
+ "version": "56.0.16",
+ "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.16.tgz",
+ "integrity": "sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^56.0.0",
@@ -6071,23 +6089,23 @@
}
},
"node_modules/expo-crypto": {
- "version": "56.0.3",
- "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.3.tgz",
- "integrity": "sha512-Ehiub29JVhN69RbMfaBoZbrrT55o9zU5YojHg48W63aCSN7lGyFz5g8JdUN3mXMaZCAUoExdk24NJPvMgbFZ+w==",
+ "version": "56.0.4",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
+ "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-client": {
- "version": "56.0.16",
- "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.16.tgz",
- "integrity": "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg==",
+ "version": "56.0.18",
+ "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.18.tgz",
+ "integrity": "sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "expo-dev-launcher": "~56.0.16",
- "expo-dev-menu": "~56.0.15",
+ "expo-dev-launcher": "~56.0.18",
+ "expo-dev-menu": "~56.0.16",
"expo-dev-menu-interface": "~56.0.0",
"expo-manifests": "~56.0.4",
"expo-updates-interface": "~56.0.1"
@@ -6097,14 +6115,14 @@
}
},
"node_modules/expo-dev-launcher": {
- "version": "56.0.16",
- "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.16.tgz",
- "integrity": "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw==",
+ "version": "56.0.18",
+ "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.18.tgz",
+ "integrity": "sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^56.0.0",
- "expo-dev-menu": "~56.0.15",
+ "expo-dev-menu": "~56.0.16",
"expo-manifests": "~56.0.4"
},
"peerDependencies": {
@@ -6113,9 +6131,9 @@
}
},
"node_modules/expo-dev-menu": {
- "version": "56.0.15",
- "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.15.tgz",
- "integrity": "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ==",
+ "version": "56.0.16",
+ "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.16.tgz",
+ "integrity": "sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6242,9 +6260,9 @@
}
},
"node_modules/expo-linking": {
- "version": "56.0.12",
- "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.12.tgz",
- "integrity": "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ==",
+ "version": "56.0.13",
+ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.13.tgz",
+ "integrity": "sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==",
"license": "MIT",
"dependencies": {
"expo-constants": "~56.0.16",
@@ -6269,9 +6287,9 @@
}
},
"node_modules/expo-location": {
- "version": "56.0.14",
- "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.14.tgz",
- "integrity": "sha512-k9p6mR11o5S0R4yUs3uWLJfnSk6XIB9UIgSYiNu2goGLWb2f0sazuZ0iYhuc2p2wIsdidhpL/51ZXjtZl5JCOg==",
+ "version": "56.0.15",
+ "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.15.tgz",
+ "integrity": "sha512-CM5+1untDxsuN0NIgsBS9cRel5xh8UXstQS6KtQw/run5PiArqCl51cnTuG+aqjYgE+9gweSG70PI6A1Ax1XTA==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.10.1"
@@ -6294,9 +6312,9 @@
}
},
"node_modules/expo-modules-autolinking": {
- "version": "56.0.13",
- "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.13.tgz",
- "integrity": "sha512-6JwVGX+geAikA4zNyZPX3H+PWINZdjjUMV0VLDmfR2gToA0Gu8AdbONP0+2yNuwBYFgQr0MrBtxCDU3YE6avRQ==",
+ "version": "56.0.14",
+ "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.14.tgz",
+ "integrity": "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==",
"license": "MIT",
"dependencies": {
"@expo/require-utils": "^56.1.3",
@@ -6309,9 +6327,9 @@
}
},
"node_modules/expo-modules-core": {
- "version": "56.0.13",
- "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.13.tgz",
- "integrity": "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ==",
+ "version": "56.0.14",
+ "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.14.tgz",
+ "integrity": "sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==",
"license": "MIT",
"dependencies": {
"@expo/expo-modules-macros-plugin": "~0.0.9",
@@ -6343,9 +6361,9 @@
"link": true
},
"node_modules/expo-notifications": {
- "version": "56.0.14",
- "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.14.tgz",
- "integrity": "sha512-A+BDJYyBIkC17Bfqlrbf9A80npjOyoTbaSCydP2agfhVv+Ld7DuOYOJSApBmtzBZM0LvdUVX/pdrwjEp1ixmaw==",
+ "version": "56.0.15",
+ "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.15.tgz",
+ "integrity": "sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.10.1",
@@ -6361,15 +6379,15 @@
}
},
"node_modules/expo-router": {
- "version": "56.2.7",
- "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.7.tgz",
- "integrity": "sha512-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA==",
+ "version": "56.2.8",
+ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.8.tgz",
+ "integrity": "sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==",
"license": "MIT",
"dependencies": {
"@expo/log-box": "^56.0.12",
"@expo/metro-runtime": "^56.0.13",
"@expo/schema-utils": "^56.0.0",
- "@expo/ui": "^56.0.14",
+ "@expo/ui": "^56.0.15",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.12",
"@react-native-masked-view/masked-view": "^0.3.2",
@@ -6401,7 +6419,7 @@
"@testing-library/react-native": ">= 13.2.0",
"expo": "*",
"expo-constants": "^56.0.16",
- "expo-linking": "^56.0.12",
+ "expo-linking": "^56.0.13",
"react": "*",
"react-dom": "*",
"react-native": "*",
@@ -6540,9 +6558,9 @@
}
},
"node_modules/expo-sharing": {
- "version": "56.0.14",
- "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.14.tgz",
- "integrity": "sha512-Hu7pm3U9vn9NFGBe5EUM6ct6wBhAc7Zgl5koOYpJnMvL6n85bkIA8sLvvxB6V+p4JRoh3TD6xXpOIr23qwsV2w==",
+ "version": "56.0.15",
+ "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.15.tgz",
+ "integrity": "sha512-6Hy1+Mjy4UYXkFiDK3Ea934NUmA71i8dmZkDe+rrUHRzZAv4FR+q/VyiT7LzNFEqpT4wn4wcI66lc2QY526RsA==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^56.0.8",
diff --git a/package.json b/package.json
index 34034de..4135325 100644
--- a/package.json
+++ b/package.json
@@ -41,15 +41,15 @@
"@react-native-vector-icons/ionicons": "^13.1.1",
"@react-native-vector-icons/material-design-icons": "^13.1.1",
"@shopify/flash-list": "^2.3.1",
- "expo": "~56.0.5",
+ "expo": "~56.0.8",
"expo-async-fs": "file:./modules/expo-async-fs",
"expo-backup-exclusions": "file:./modules/expo-backup-exclusions",
"expo-battery": "~56.0.4",
"expo-blur": "~56.0.3",
- "expo-build-properties": "~56.0.15",
+ "expo-build-properties": "~56.0.16",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.15",
- "expo-crypto": "56.0.3",
+ "expo-crypto": "~56.0.4",
"expo-device": "~56.0.4",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
@@ -59,14 +59,14 @@
"expo-image-resize": "file:./modules/expo-image-resize",
"expo-intent-launcher": "~56.0.4",
"expo-linear-gradient": "~56.0.4",
- "expo-linking": "~56.0.12",
+ "expo-linking": "~56.0.13",
"expo-localization": "~56.0.6",
- "expo-location": "~56.0.14",
+ "expo-location": "~56.0.15",
"expo-move-to-back": "file:./modules/expo-move-to-back",
- "expo-notifications": "~56.0.14",
- "expo-router": "~56.2.7",
+ "expo-notifications": "~56.0.15",
+ "expo-router": "~56.2.8",
"expo-screen-orientation": "~56.0.5",
- "expo-sharing": "~56.0.14",
+ "expo-sharing": "~56.0.15",
"expo-sqlite": "~56.0.4",
"expo-ssl-trust": "file:./modules/expo-ssl-trust",
"expo-status-bar": "~56.0.4",
@@ -95,7 +95,8 @@
"@testing-library/react-native": "^13.3.3",
"@types/jest": "~29.5.14",
"@types/react": "^19.2.15",
- "expo-dev-client": "~56.0.16",
+ "babel-plugin-transform-remove-console": "^6.9.4",
+ "expo-dev-client": "~56.0.18",
"jest": "~29.7.0",
"jest-expo": "~56.0.4",
"patch-package": "^8.0.1",
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 367440c..5a76874 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -37,8 +37,8 @@ import { mixHexColors } from '../utils/colors';
import AnimatedSplashScreen from '../components/AnimatedSplashScreen';
import { CertificatePromptModal } from '../components/CertificatePromptModal';
import { CreateShareSheet } from '../components/CreateShareSheet';
-import { ExpandedPlayerView } from '../components/ExpandedPlayerView';
-import { PlayerPanel } from '../components/PlayerPanel';
+import { PlayerTabletLandscape } from '../components/player/PlayerTabletLandscape';
+import { PlayerTabletSplitview } from '../components/player/PlayerTabletSplitview';
import { SplitLayout } from '../components/SplitLayout';
import { MbidSearchSheet } from '../components/MbidSearchSheet';
import { MoreOptionsSheet } from '../components/MoreOptionsSheet';
@@ -725,13 +725,13 @@ export default function RootLayout() {
}
- panel={showPanel ? : null}
+ panel={showPanel ? : null}
panelPlaceholder={{null}}
/>
{/* Full-screen expanded player — covers everything including SplitLayout */}
{showPanel && (
-
+
)}
{/* Global more-options bottom sheet driven by moreOptionsStore */}
@@ -777,7 +777,7 @@ export default function RootLayout() {
{/* Global error pill. Used by `playerService.fail(...)` to surface
genuine playback failures (offline + no cached tracks, RNTP
errors). Lifts itself above the BottomChrome (DownloadBanner +
- MiniPlayer) when present so it doesn't stack on top. */}
+ mini player) when present so it doesn't stack on top. */}
diff --git a/src/app/player.tsx b/src/app/player.tsx
index 52be6d3..9272750 100644
--- a/src/app/player.tsx
+++ b/src/app/player.tsx
@@ -1,5 +1,8 @@
-import { PlayerView } from '@/screens/player-view';
+import { useIsTabletPortrait } from '@/hooks/useIsTabletPortrait';
+import { PlayerPhonePortrait } from '@/screens/player/player-phone-portrait';
+import { PlayerTabletPortrait } from '@/screens/player/player-tablet-portrait';
export default function PlayerRoute() {
- return ;
+ const tabletPortrait = useIsTabletPortrait();
+ return tabletPortrait ? : ;
}
diff --git a/src/components/AlbumInfoContent.tsx b/src/components/AlbumInfoContent.tsx
index eeb6bd1..47a790b 100644
--- a/src/components/AlbumInfoContent.tsx
+++ b/src/components/AlbumInfoContent.tsx
@@ -1,6 +1,6 @@
import FontAwesome5 from "@react-native-vector-icons/fontawesome5/static";
import Ionicons from "@react-native-vector-icons/ionicons/static";
-import { memo, useCallback, useMemo, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Linking,
Pressable,
@@ -10,12 +10,19 @@ import {
Text,
View,
} from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withTiming,
+} from 'react-native-reanimated';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { FormatBadge } from './FormatBadge';
import { useRefreshControlKey } from '../hooks/useRefreshControlKey';
-import { type Child } from '../services/subsonicService';
+import { isVariousArtists, type Child } from '../services/subsonicService';
import { hexWithAlpha } from '../utils/colors';
import { getEffectiveFormat } from '../utils/effectiveFormat';
import { getGenreNames } from '../utils/genreHelpers';
@@ -115,15 +122,26 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
return phrases;
}, [track, t]);
- // Build credit rows (album artist if different, composer)
+ // Compilation = album credited to "Various Artists" (any casing). This is how
+ // Navidrome/OpenSubsonic surfaces compilations; the per-album `isCompilation`
+ // flag only rides on AlbumID3 (getAlbum), which the player never fetches.
+ const isCompilation = isVariousArtists(track.displayAlbumArtist ?? track.artist);
+
+ // Build credit rows (album artist if different, composer). For compilations
+ // the "Various Artists" album-artist row is redundant with the placeholder, so
+ // skip it.
const credits = useMemo(() => {
const rows: { label: string; value: string }[] = [];
- if (track.displayAlbumArtist && track.displayAlbumArtist !== track.artist) {
+ if (
+ !isCompilation &&
+ track.displayAlbumArtist &&
+ track.displayAlbumArtist !== track.artist
+ ) {
rows.push({ label: t('detailAlbumArtist'), value: track.displayAlbumArtist });
}
if (track.displayComposer) rows.push({ label: t('detailComposer'), value: track.displayComposer });
return rows;
- }, [track, t]);
+ }, [track, t, isCompilation]);
const handleLastFm = useCallback(() => {
if (albumInfo?.lastFmUrl) Linking.openURL(albumInfo.lastFmUrl);
@@ -187,51 +205,7 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
)}
) : (albumInfoLoading || refreshing) ? (
- /* Skeleton placeholder — mirrors the real layout */
- ((() => {
- // Theme-aware skeleton fill: derives from `textSecondary` so light
- // mode gets a dark-gray bar (visible on white) and dark mode gets
- // a light-gray bar (visible on black). The hardcoded white-alpha
- // previously used was invisible on light backgrounds.
- const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) };
- return (
-
- {/* Hero block */}
-
-
-
-
-
- {[72, 96, 60, 84].map((w, i) => (
-
- ))}
-
-
-
- {/* Inline metadata strip */}
-
-
- {/* Description */}
-
-
- {[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => (
-
- ))}
-
-
- {/* External links */}
-
-
- {[90, 110, 95].map((w, i) => (
-
- ))}
-
-
- );
- })())
+
) : (
<>
{/* ── Hero header block (centered) ── */}
@@ -334,7 +308,25 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
)}
- ) : null}
+ ) : (
+ /* No description — show a friendly placeholder in the bio slot,
+ styled like the "no lyrics available" empty state for
+ consistency across player segments. */
+
+
+
+
+
+ {isCompilation ? t('albumDetailsCompilation') : t('albumDetailsNotFound')}
+
+
+
+ )}
>
)}
{/* ── External links (centered) ── */}
@@ -385,6 +377,76 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
);
});
+/* ------------------------------------------------------------------ */
+/* Skeleton placeholder — mirrors the real layout, with a looping */
+/* opacity pulse so it reads as "loading" rather than a frozen frame. */
+/* ------------------------------------------------------------------ */
+
+const AlbumInfoSkeleton = memo(function AlbumInfoSkeleton({
+ colors,
+}: {
+ colors: AlbumInfoContentProps['colors'];
+}) {
+ // Theme-aware skeleton fill: derives from `textSecondary` so light mode gets
+ // a dark-gray bar (visible on white) and dark mode a light-gray bar (visible
+ // on black).
+ const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) };
+
+ // Looping pulse. Starts at 1 (never 0) and breathes between 0.4 and 1 — the
+ // mount-and-repeat shape used elsewhere (e.g. tuned-in) so it can't get stuck
+ // invisible. Only mounted while loading, so it stops on unmount.
+ const pulse = useSharedValue(1);
+ useEffect(() => {
+ pulse.value = withRepeat(
+ withSequence(
+ withTiming(0.4, { duration: 700 }),
+ withTiming(1, { duration: 700 }),
+ ),
+ -1,
+ );
+ }, [pulse]);
+
+ const pulseStyle = useAnimatedStyle(() => ({ opacity: pulse.value }));
+
+ return (
+
+ {/* Hero block */}
+
+
+
+
+
+ {[72, 96, 60, 84].map((w, i) => (
+
+ ))}
+
+
+
+ {/* Inline metadata strip */}
+
+
+ {/* Description */}
+
+
+ {[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => (
+
+ ))}
+
+
+ {/* External links */}
+
+
+ {[90, 110, 95].map((w, i) => (
+
+ ))}
+
+
+ );
+});
+
/* ------------------------------------------------------------------ */
/* Styles */
/* ------------------------------------------------------------------ */
@@ -470,6 +532,17 @@ const styles = StyleSheet.create({
textAlign: 'right',
},
+ /* No-description placeholder (compilation / not found) */
+ placeholderBlock: {
+ marginBottom: 4,
+ },
+ placeholderInner: {
+ alignItems: 'center',
+ paddingVertical: 28,
+ paddingHorizontal: 32,
+ gap: 12,
+ },
+
/* Album description */
descriptionSection: {
marginBottom: 4,
diff --git a/src/components/BookmarkButton.tsx b/src/components/BookmarkButton.tsx
new file mode 100644
index 0000000..0df314a
--- /dev/null
+++ b/src/components/BookmarkButton.tsx
@@ -0,0 +1,74 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
+
+import { useTheme } from '../hooks/useTheme';
+import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService';
+import { bookmarkSheetStore } from '../store/bookmarkSheetStore';
+import { bookmarksStore } from '../store/bookmarksStore';
+import { playbackToastStore } from '../store/playbackToastStore';
+import { playerStore } from '../store/playerStore';
+
+export interface BookmarkButtonProps {
+ size?: number;
+ /** Container padding/spacing — varies per player surface. */
+ style?: StyleProp;
+}
+
+/**
+ * Saves a play-queue bookmark. Auto-names and commits immediately when the
+ * auto-name preference is on; otherwise opens the manual-name sheet. Shared
+ * across player surfaces.
+ */
+export const BookmarkButton = memo(function BookmarkButton({
+ size = 24,
+ style,
+}: BookmarkButtonProps) {
+ const { t, i18n } = useTranslation();
+ const { colors } = useTheme();
+ const autoName = bookmarksStore((s) => s.autoName);
+ const queueLength = playerStore((s) => s.queue.length);
+ const disabled = queueLength === 0;
+
+ const handlePress = useCallback(() => {
+ // Capture the queue/position NOW, at tap time, regardless of which path we
+ // take — the manual-name sheet commits this same snapshot on Save.
+ const snapshot = capturePlayerSnapshot();
+ if (!snapshot) return;
+ const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name);
+ const suggested = buildAutoName(t, i18n.language, existingNames);
+ if (autoName) {
+ commitBookmark(snapshot, suggested);
+ playbackToastStore.getState().flashSuccess(t('bookmarkSaved'));
+ } else {
+ bookmarkSheetStore.getState().showCreate(suggested, snapshot);
+ }
+ }, [autoName, t, i18n.language]);
+
+ return (
+ [
+ style,
+ pressed && !disabled && styles.pressed,
+ disabled && styles.disabled,
+ ]}
+ >
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ pressed: {
+ opacity: 0.6,
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+});
diff --git a/src/components/BottomChrome.tsx b/src/components/BottomChrome.tsx
index 4912e81..3e74f1e 100644
--- a/src/components/BottomChrome.tsx
+++ b/src/components/BottomChrome.tsx
@@ -2,7 +2,9 @@ import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DownloadBanner } from './DownloadBanner';
-import { MiniPlayer } from './MiniPlayer';
+import { PlayerPhoneMini } from './player/PlayerPhoneMini';
+import { PlayerTabletPortraitMini } from './player/PlayerTabletPortraitMini';
+import { useIsTabletPortrait } from '../hooks/useIsTabletPortrait';
import { useLayoutMode } from '../hooks/useLayoutMode';
import { authStore } from '../store/authStore';
import { musicCacheStore } from '../store/musicCacheStore';
@@ -12,12 +14,12 @@ import { playerStore } from '../store/playerStore';
* Single shared bottom-chrome stack rendered both inside the tabs
* `renderTabBar` callback and as a footer on every non-tab Stack screen.
*
- * Composes `` above `` with **independent**
+ * Composes `` above `` with **independent**
* visibility:
* - banner is visible whenever the download queue has any
* downloading/queued/error rows;
- * - MiniPlayer is visible whenever there is a current track AND the
- * layout is compact (wide layouts don't show the MiniPlayer).
+ * - PlayerPhoneMini is visible whenever there is a current track AND the
+ * layout is compact (wide layouts don't show the mini player).
*
* Either piece can be on while the other is off — e.g. a download
* starts before the user plays anything, or the user clears the play
@@ -39,6 +41,7 @@ interface BottomChromeProps {
export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps = {}) {
const isWide = useLayoutMode() === 'wide';
+ const isTabletPortrait = useIsTabletPortrait();
const isLoggedIn = authStore((s) => s.isLoggedIn);
const hasCurrentTrack = playerStore((s) => s.currentTrack !== null);
// Mirrors `DownloadBanner`'s own filter so the two can't drift. Counts
@@ -52,7 +55,7 @@ export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps
const insets = useSafeAreaInsets();
if (!isLoggedIn) return null;
- // On wide layouts the MiniPlayer never renders, so the chrome only has a
+ // On wide layouts the mini player never renders, so the chrome only has a
// reason to mount when there are downloads.
if (isWide && !hasDownloads) return null;
// On compact layouts we need EITHER a track or an active download.
@@ -66,7 +69,9 @@ export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps
]}
>
{hasDownloads && }
- {!isWide && hasCurrentTrack && }
+ {!isWide && hasCurrentTrack && (
+ isTabletPortrait ? :
+ )}
);
}
diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx
index 68a827a..7131a88 100644
--- a/src/components/CachedImage.tsx
+++ b/src/components/CachedImage.tsx
@@ -236,7 +236,17 @@ export const CachedImage = memo(function CachedImage({
// One log line per state transition. Kept minimal so user logs are
// scannable; the service has its own logs for downloads/retries.
useEffect(() => {
- if (!coverArtId) return;
+ if (!coverArtId) {
+ // No usable id AND no bundled fallback → a genuine missing-id
+ // placeholder: the parent handed us an entity with no id. This is the
+ // otherwise-silent stuck case (every other branch is gated on a truthy
+ // id), so log it to make recurrence diagnosable. Sentinels render a
+ // bundled fallbackUri and are intentionally skipped.
+ if (!fallbackUri) {
+ logImageCache(`CachedImage placeholder id-missing size=${size}`);
+ }
+ return;
+ }
const where = cachedUri && !localErroredRef.current
? 'local'
: isRemote
@@ -245,7 +255,7 @@ export const CachedImage = memo(function CachedImage({
logImageCache(
`CachedImage state id=${coverArtId} size=${size} ${where} remoteFailed=${remoteFailed}`,
);
- }, [coverArtId, size, cachedUri, isRemote, remoteFailed]);
+ }, [coverArtId, size, cachedUri, isRemote, remoteFailed, fallbackUri]);
const flatStyle = StyleSheet.flatten(style) as (ImageStyle & ViewStyle) | undefined;
const logoSize = computeLogoSize(
diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx
new file mode 100644
index 0000000..253e4f8
--- /dev/null
+++ b/src/components/FavoriteButton.tsx
@@ -0,0 +1,62 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
+
+import { useIsStarred } from '../hooks/useIsStarred';
+import { useTheme } from '../hooks/useTheme';
+import { toggleStar } from '../services/moreOptionsService';
+import { offlineModeStore } from '../store/offlineModeStore';
+
+export interface FavoriteButtonProps {
+ trackId: string;
+ size?: number;
+ /** Container padding/spacing — varies per player surface. */
+ style?: StyleProp;
+}
+
+/** Heart toggle for a song. Disabled offline. Shared across all player surfaces. */
+export const FavoriteButton = memo(function FavoriteButton({
+ trackId,
+ size = 24,
+ style,
+}: FavoriteButtonProps) {
+ const { t } = useTranslation();
+ const { colors } = useTheme();
+ const starred = useIsStarred('song', trackId);
+ const offlineMode = offlineModeStore((s) => s.offlineMode);
+
+ const handleToggle = useCallback(() => {
+ toggleStar('song', trackId);
+ }, [trackId]);
+
+ return (
+ [
+ style,
+ pressed && !offlineMode && styles.pressed,
+ offlineMode && styles.disabled,
+ ]}
+ >
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ pressed: {
+ opacity: 0.6,
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+});
diff --git a/src/components/MoreOptionsSheet.tsx b/src/components/MoreOptionsSheet.tsx
index ba15bcb..99dbe93 100644
--- a/src/components/MoreOptionsSheet.tsx
+++ b/src/components/MoreOptionsSheet.tsx
@@ -22,6 +22,7 @@ import { useDownloadStatus, type DownloadStatus } from '../hooks/useDownloadStat
import { useIsStarred } from '../hooks/useIsStarred';
import { useRating } from '../hooks/useRating';
import { useTheme } from '../hooks/useTheme';
+import { coverArtIdForEntity } from '../utils/coverArtId';
import { tabletLayoutStore } from '../store/tabletLayoutStore';
import {
addAlbumToQueue,
@@ -103,13 +104,9 @@ function getSubtitle(entity: MoreOptionsEntity, t: (key: string, options?: Recor
}
function getCoverArtId(entity: MoreOptionsEntity): string | undefined {
- // Cover-art lookups key off the entity ID, not the server's
- // `coverArt` field. Same canonical ID for every track in an album so
- // MiniPlayer / lock-screen / queue rows all share one cached file.
- if (entity.type === 'song') {
- return (entity.item as Child).albumId ?? entity.item.id;
- }
- return entity.item.id;
+ // Cover-art lookups key off the entity ID, not the server's `coverArt`
+ // field — the single rule lives in src/utils/coverArtId.ts.
+ return coverArtIdForEntity(entity.item);
}
function isStarrable(entity: MoreOptionsEntity): boolean {
@@ -193,7 +190,7 @@ export function MoreOptionsSheet() {
const entity = moreOptionsStore((s) => s.entity);
const source = moreOptionsStore((s) => s.source);
const hide = moreOptionsStore((s) => s.hide);
- const isPlayerSource = source === 'player' || source === 'playerpanel' || source === 'playerexpanded';
+ const isPlayerSource = source !== 'default';
const starType: 'song' | 'album' | 'artist' =
entity?.type === 'album' || entity?.type === 'artist' ? entity.type : 'song';
@@ -358,7 +355,7 @@ export function MoreOptionsSheet() {
const handleGoToArtist = useCallback(() => {
if (!entity) return;
handleClose();
- if (source === 'playerexpanded') {
+ if (source === 'player-tablet-landscape') {
tabletLayoutStore.getState().setPlayerExpanded(false);
}
const artistId =
@@ -375,7 +372,7 @@ export function MoreOptionsSheet() {
const handleGoToAlbum = useCallback(() => {
if (!entity || entity.type !== 'song') return;
handleClose();
- if (source === 'playerexpanded') {
+ if (source === 'player-tablet-landscape') {
tabletLayoutStore.getState().setPlayerExpanded(false);
}
const albumId = (entity.item as Child).albumId;
@@ -466,10 +463,8 @@ export function MoreOptionsSheet() {
const handleSetRating = useCallback(async () => {
if (!entity || !isRatable(entity)) return;
// Entity-ID based cover art (see src/utils/coverArtId.ts) — songs key
- // off the parent album so MiniPlayer / rating sheet share one cache.
- const coverArtId = entity.type === 'song'
- ? ((entity.item as Child).albumId ?? entity.item.id)
- : entity.item.id;
+ // off the parent album so mini player / rating sheet share one cache.
+ const coverArtId = coverArtIdForEntity(entity.item);
await moreOptionsStore.getState().hideAndAwait();
setRatingStore.getState().show(
entity.type as 'song' | 'album' | 'artist',
diff --git a/src/components/PlaybackToast.tsx b/src/components/PlaybackToast.tsx
index f1cc8c0..42efe39 100644
--- a/src/components/PlaybackToast.tsx
+++ b/src/components/PlaybackToast.tsx
@@ -31,7 +31,7 @@ const CAPSULE_BORDER_RADIUS = CAPSULE_HEIGHT / 2;
const SUCCESS_DISPLAY_MS = 1400;
const ERROR_DISPLAY_MS = 2200;
const BOTTOM_OFFSET = 24;
-/** Keep in sync with MINI_PLAYER_HEIGHT in `MiniPlayer.tsx`. */
+/** Keep in sync with MINI_PLAYER_HEIGHT in `PlayerPhoneMini.tsx`. */
const MINI_PLAYER_HEIGHT = 56;
const SPRING_CONFIG = { damping: 14, stiffness: 200, mass: 0.8 };
@@ -45,11 +45,11 @@ export function PlaybackToast() {
const hide = playbackToastStore((s) => s.hide);
const { colors } = useTheme();
const insets = useSafeAreaInsets();
- // Lift the pill above the bottom chrome (DownloadBanner + MiniPlayer)
+ // Lift the pill above the bottom chrome (DownloadBanner + mini player)
// when either is rendered so they don't stack. The chrome lives in
// `BottomChrome` (per-screen and inside the tabs `renderTabBar`); its
// visibility rules must match this predicate so the offsets align.
- // Banner visibility is independent of MiniPlayer visibility — the banner
+ // Banner visibility is independent of mini player visibility — the banner
// can be on screen with no track playing (downloads queued, queue
// cleared while downloading, etc.), so it gets its own offset term.
const isLoggedIn = authStore((s) => s.isLoggedIn);
diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx
index 766346e..5d3b59d 100644
--- a/src/components/SectionTitle.tsx
+++ b/src/components/SectionTitle.tsx
@@ -7,8 +7,21 @@
import { StyleSheet, Text } from 'react-native';
-export function SectionTitle({ title, color }: { title: string; color: string }) {
- return {title};
+export function SectionTitle({
+ title,
+ color,
+ large = false,
+}: {
+ title: string;
+ color: string;
+ /** Larger, title-case heading for prominent feature pages (e.g. Tuned In). */
+ large?: boolean;
+}) {
+ return (
+
+ {title}
+
+ );
}
const styles = StyleSheet.create({
@@ -20,4 +33,11 @@ const styles = StyleSheet.create({
marginBottom: 10,
marginLeft: 4,
},
+ sectionTitleLarge: {
+ fontSize: 22,
+ fontWeight: '700',
+ letterSpacing: 0.2,
+ marginBottom: 18,
+ marginLeft: 4,
+ },
});
diff --git a/src/components/ShuffleButton.tsx b/src/components/ShuffleButton.tsx
index c1c4cfb..c2768af 100644
--- a/src/components/ShuffleButton.tsx
+++ b/src/components/ShuffleButton.tsx
@@ -8,11 +8,13 @@ import { useTheme } from '../hooks/useTheme';
export interface ShuffleButtonProps {
onPress: () => void;
disabled?: boolean;
+ size?: number;
}
export const ShuffleButton = memo(function ShuffleButton({
onPress,
disabled = false,
+ size = 20,
}: ShuffleButtonProps) {
const { t } = useTranslation();
const { colors } = useTheme();
@@ -29,7 +31,7 @@ export const ShuffleButton = memo(function ShuffleButton({
(pressed || disabled) && styles.pressed,
]}
>
-
+
);
});
diff --git a/src/components/ShuffleOverlay.tsx b/src/components/ShuffleOverlay.tsx
new file mode 100644
index 0000000..122a14e
--- /dev/null
+++ b/src/components/ShuffleOverlay.tsx
@@ -0,0 +1,61 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
+import Animated, { type AnimatedStyle } from 'react-native-reanimated';
+
+import { type ThemeColors } from '../constants/theme';
+import { absoluteFill } from '../utils/styles';
+
+export interface ShuffleOverlayProps {
+ visible: boolean;
+ overlayStyle: AnimatedStyle;
+ spinStyle: AnimatedStyle;
+ colors: ThemeColors;
+}
+
+/** Full-screen "Shuffling…" spin overlay. Drive with `useShuffleOverlay`. */
+export const ShuffleOverlay = memo(function ShuffleOverlay({
+ visible,
+ overlayStyle,
+ spinStyle,
+ colors,
+}: ShuffleOverlayProps) {
+ const { t } = useTranslation();
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+
+
+ {t('shuffling')}
+
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ shuffleOverlay: {
+ ...absoluteFill,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 20,
+ },
+ shuffleCard: {
+ borderRadius: 16,
+ paddingHorizontal: 32,
+ paddingVertical: 24,
+ alignItems: 'center',
+ gap: 12,
+ },
+ shuffleText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/src/components/__tests__/AlbumInfoContent.test.tsx b/src/components/__tests__/AlbumInfoContent.test.tsx
index 0eb03fa..4ba37e2 100644
--- a/src/components/__tests__/AlbumInfoContent.test.tsx
+++ b/src/components/__tests__/AlbumInfoContent.test.tsx
@@ -13,6 +13,19 @@ jest.mock('react-native-svg', () => {
return { __esModule: true, default: View, Path: View };
});
+jest.mock('react-native-reanimated', () => {
+ const { View } = require('react-native');
+ return {
+ __esModule: true,
+ default: { View },
+ useSharedValue: (init: number) => ({ value: init }),
+ useAnimatedStyle: (fn: () => object) => fn(),
+ withRepeat: (val: any) => val,
+ withSequence: (...args: any[]) => args[args.length - 1],
+ withTiming: (val: number) => val,
+ };
+});
+
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Linking } from 'react-native';
@@ -344,7 +357,7 @@ describe('AlbumInfoContent', () => {
it('renders displayAlbumArtist when different from artist', () => {
const trackWithAlbumArtist = {
...MOCK_TRACK,
- displayAlbumArtist: 'Various Artists',
+ displayAlbumArtist: 'The Producers',
} as Child;
const { getByText } = render(
@@ -362,7 +375,50 @@ describe('AlbumInfoContent', () => {
);
expect(getByText('Album Artist')).toBeTruthy();
- expect(getByText('Various Artists')).toBeTruthy();
+ expect(getByText('The Producers')).toBeTruthy();
+ });
+
+ it('shows the compilation placeholder and hides the VA credit row for Various Artists', () => {
+ const compilationTrack = {
+ ...MOCK_TRACK,
+ displayAlbumArtist: 'Various Artists',
+ } as Child;
+
+ const { getByText, queryByText } = render(
+ ,
+ );
+
+ expect(getByText("Album details aren't available for compilations.")).toBeTruthy();
+ // The redundant "Album Artist: Various Artists" credit row is suppressed.
+ expect(queryByText('Album Artist')).toBeNull();
+ });
+
+ it('shows the not-found placeholder when no notes are available', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('No album details available for this track.')).toBeTruthy();
});
it('does not render album artist row when same as artist', () => {
diff --git a/src/components/__tests__/BottomChrome.test.tsx b/src/components/__tests__/BottomChrome.test.tsx
index 058bb8d..ef2ed45 100644
--- a/src/components/__tests__/BottomChrome.test.tsx
+++ b/src/components/__tests__/BottomChrome.test.tsx
@@ -1,10 +1,19 @@
jest.mock('../../store/persistence/kvStorage', () => require('../../store/persistence/__mocks__/kvStorage'));
-jest.mock('../MiniPlayer', () => {
+jest.mock('../player/PlayerPhoneMini', () => {
const { View } = require('react-native');
- return { MiniPlayer: () => };
+ return { PlayerPhoneMini: () => };
});
+jest.mock('../player/PlayerTabletPortraitMini', () => {
+ const { View } = require('react-native');
+ return { PlayerTabletPortraitMini: () => };
+});
+
+jest.mock('../../hooks/useIsTabletPortrait', () => ({
+ useIsTabletPortrait: jest.fn(() => false),
+}));
+
jest.mock('../DownloadBanner', () => {
const { View } = require('react-native');
return {
@@ -24,6 +33,7 @@ jest.mock('react-native-safe-area-context', () => ({
import React from 'react';
import { render } from '@testing-library/react-native';
+import { useIsTabletPortrait } from '../../hooks/useIsTabletPortrait';
import { useLayoutMode } from '../../hooks/useLayoutMode';
import { authStore } from '../../store/authStore';
import { musicCacheStore } from '../../store/musicCacheStore';
@@ -32,6 +42,7 @@ import type { DownloadQueueItem } from '../../store/musicCacheStore';
import { BottomChrome } from '../BottomChrome';
const mockUseLayoutMode = useLayoutMode as jest.Mock;
+const mockUseIsTabletPortrait = useIsTabletPortrait as jest.Mock;
const TRACK = {
id: 't1',
@@ -59,6 +70,7 @@ function makeQueueItem(overrides: Partial = {}): DownloadQueu
beforeEach(() => {
mockUseLayoutMode.mockReturnValue('compact');
+ mockUseIsTabletPortrait.mockReturnValue(false);
authStore.setState({ isLoggedIn: true });
playerStore.setState({ currentTrack: null });
musicCacheStore.setState({ downloadQueue: [] });
@@ -67,7 +79,7 @@ beforeEach(() => {
describe('BottomChrome', () => {
/* ---- visibility table ---- */
- it('compact + has-track + no-downloads → MiniPlayer only, banner unmounted', () => {
+ it('compact + has-track + no-downloads → mini player only, banner unmounted', () => {
playerStore.setState({ currentTrack: TRACK });
const { getByTestId, queryByTestId } = render();
expect(getByTestId('mini-player')).toBeTruthy();
@@ -76,7 +88,15 @@ describe('BottomChrome', () => {
expect(queryByTestId('download-banner')).toBeNull();
});
- it('compact + no-track + has-downloads → banner only, MiniPlayer absent', () => {
+ it('compact + tablet-portrait + has-track → tablet mini player, phone mini absent', () => {
+ mockUseIsTabletPortrait.mockReturnValue(true);
+ playerStore.setState({ currentTrack: TRACK });
+ const { getByTestId, queryByTestId } = render();
+ expect(getByTestId('tablet-mini-player')).toBeTruthy();
+ expect(queryByTestId('mini-player')).toBeNull();
+ });
+
+ it('compact + no-track + has-downloads → banner only, mini player absent', () => {
musicCacheStore.setState({
downloadQueue: [makeQueueItem({ status: 'downloading' })],
});
@@ -107,7 +127,7 @@ describe('BottomChrome', () => {
expect(toJSON()).toBeNull();
});
- it('wide + has-downloads → banner only (no MiniPlayer on wide)', () => {
+ it('wide + has-downloads → banner only (no mini player on wide)', () => {
mockUseLayoutMode.mockReturnValue('wide');
playerStore.setState({ currentTrack: TRACK });
musicCacheStore.setState({
diff --git a/src/components/__tests__/PlaybackToast.test.tsx b/src/components/__tests__/PlaybackToast.test.tsx
index 760d632..625f185 100644
--- a/src/components/__tests__/PlaybackToast.test.tsx
+++ b/src/components/__tests__/PlaybackToast.test.tsx
@@ -125,7 +125,7 @@ describe('PlaybackToast bottom offset', () => {
expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET);
});
- it('MiniPlayer only (track playing, no downloads): adds MiniPlayer height', () => {
+ it('mini player only (track playing, no downloads): adds mini player height', () => {
playerStore.setState({ currentTrack: { id: 't1' } as any });
expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + MINI_PLAYER_HEIGHT);
});
@@ -137,7 +137,7 @@ describe('PlaybackToast bottom offset', () => {
expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + BANNER_HEIGHT);
});
- it('both visible: adds banner + MiniPlayer heights', () => {
+ it('both visible: adds banner + mini player heights', () => {
playerStore.setState({ currentTrack: { id: 't1' } as any });
musicCacheStore.setState({
downloadQueue: [makeQueueItem({ status: 'downloading' })],
@@ -147,17 +147,17 @@ describe('PlaybackToast bottom offset', () => {
);
});
- it('wide layout: MiniPlayer is hidden so its height is NOT added', () => {
+ it('wide layout: mini player is hidden so its height is NOT added', () => {
mockLayoutMode = 'wide';
playerStore.setState({ currentTrack: { id: 't1' } as any });
musicCacheStore.setState({
downloadQueue: [makeQueueItem({ status: 'downloading' })],
});
- // Banner still on, MiniPlayer suppressed by isWide.
+ // Banner still on, mini player suppressed by isWide.
expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + BANNER_HEIGHT);
});
- it('logged out: MiniPlayer is hidden so its height is NOT added', () => {
+ it('logged out: mini player is hidden so its height is NOT added', () => {
authStore.setState({ isLoggedIn: false });
playerStore.setState({ currentTrack: { id: 't1' } as any });
expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET);
diff --git a/src/components/player/PlayerModeContent.tsx b/src/components/player/PlayerModeContent.tsx
new file mode 100644
index 0000000..666cfa4
--- /dev/null
+++ b/src/components/player/PlayerModeContent.tsx
@@ -0,0 +1,243 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import { FlashList } from '@shopify/flash-list';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { AlbumInfoContent } from '@/components/AlbumInfoContent';
+import { LyricsContent } from '@/components/LyricsContent';
+import { QueueItemRow } from '@/components/QueueItemRow';
+import { closeOpenRow } from '@/components/SwipeableRow';
+import { type ThemeColors } from '@/constants/theme';
+import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo';
+import { usePlayerLyrics } from '@/hooks/usePlayerLyrics';
+import { type Child } from '@/services/subsonicService';
+import { sanitizeBiographyText } from '@/utils/formatters';
+
+export type PlayerMode = 'queue' | 'info' | 'lyrics';
+
+export interface PlayerModeContentProps {
+ /** Active content view — owned by the parent screen (the centered toggle). */
+ mode: PlayerMode;
+ currentTrack: Child;
+ queue: Child[];
+ currentTrackIndex: number | null;
+ colors: ThemeColors;
+ /** Muted-primary variant for the active queue row highlight. */
+ queueColors: ThemeColors;
+ offlineMode: boolean;
+ onQueueItemPress: (index: number) => void;
+ onQueueItemLongPress: (track: Child) => void;
+ onShareQueue: () => void;
+ onClearQueue: () => void;
+}
+
+/**
+ * Inline "Up Next" content host for the tablet-portrait player. Renders the
+ * Queue / Album Info / Lyrics views (selected by the parent's centered toggle)
+ * directly on the page — transparent, no chrome — so the single page-wide
+ * gradient shows through. Deliberately in-tree (NOT a Modal) so the global
+ * MoreOptionsSheet can open over it without stacking two native modals.
+ */
+export const PlayerModeContent = memo(function PlayerModeContent({
+ mode,
+ currentTrack,
+ queue,
+ currentTrackIndex,
+ colors,
+ queueColors,
+ offlineMode,
+ onQueueItemPress,
+ onQueueItemLongPress,
+ onShareQueue,
+ onClearQueue,
+}: PlayerModeContentProps) {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+
+ // Album info — only fetch while the info view is actually showing.
+ const albumId = currentTrack.albumId ?? null;
+ const {
+ entry: albumInfoEntry,
+ loading: albumInfoLoading,
+ error: albumInfoError,
+ refreshing: albumInfoRefreshing,
+ handleRetry: handleRetryAlbumInfo,
+ handleRefresh: handleRefreshAlbumInfo,
+ } = usePlayerAlbumInfo(albumId, currentTrack.artist, currentTrack.album, {
+ enabled: mode === 'info',
+ });
+
+ const sanitizedNotes = useMemo(() => {
+ const serverNotes = albumInfoEntry?.albumInfo.notes;
+ if (serverNotes) {
+ const sanitized = sanitizeBiographyText(serverNotes);
+ if (sanitized) return sanitized;
+ }
+ return albumInfoEntry?.enrichedNotes ?? null;
+ }, [albumInfoEntry?.albumInfo.notes, albumInfoEntry?.enrichedNotes]);
+
+ const notesAttributionUrl = albumInfoEntry?.enrichedNotesUrl ?? null;
+
+ const trackId = currentTrack.id;
+ const {
+ entry: lyricsEntry,
+ loading: lyricsLoading,
+ error: lyricsError,
+ handleRetry: handleRetryLyrics,
+ } = usePlayerLyrics(trackId, currentTrack.artist, currentTrack.title);
+
+ const renderQueueItem = useCallback(
+ ({ item, index }: { item: Child; index: number }) => (
+
+ ),
+ [currentTrackIndex, queueColors, onQueueItemPress, onQueueItemLongPress],
+ );
+
+ const keyExtractor = useCallback(
+ (item: Child, index: number) => `${item.id}-${index}`,
+ [],
+ );
+
+ const listContentStyle = useMemo(
+ () => ({ paddingBottom: insets.bottom + 16 }),
+ [insets.bottom],
+ );
+
+ return (
+
+ {mode === 'queue' ? (
+ <>
+ {queue.length > 0 && (
+
+
+ {t('queue')}
+
+
+ [styles.queueActionButton, pressed && styles.pressed]}
+ >
+
+
+ [styles.queueActionButton, pressed && styles.pressed]}
+ >
+
+ {t('clear')}
+
+
+
+
+ )}
+
+ >
+ ) : mode === 'info' ? (
+ // AlbumInfoContent relies on the parent for side padding (landscape gets
+ // it from the right column). Provide it here, aligned to the hero band.
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ queueHeaderRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 12,
+ },
+ queueHeaderText: {
+ fontSize: 12,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ queueActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ queueActionButton: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 4,
+ },
+ clearButtonText: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ infoWrap: {
+ // Constrain to a centered readable column. AlbumInfoContent's credit rows
+ // use space-between, which flings label/value to opposite edges across the
+ // full-width portrait panel; a capped, centered width keeps them legible
+ // (and mirrors the narrower landscape column it was designed for).
+ flex: 1,
+ width: '100%',
+ maxWidth: 720,
+ alignSelf: 'center',
+ paddingHorizontal: 24,
+ },
+ lyricsContainer: {
+ flex: 1,
+ },
+ pressed: {
+ opacity: 0.6,
+ },
+});
diff --git a/src/components/MiniPlayer.tsx b/src/components/player/PlayerPhoneMini.tsx
similarity index 93%
rename from src/components/MiniPlayer.tsx
rename to src/components/player/PlayerPhoneMini.tsx
index 3b60f1a..8329152 100644
--- a/src/components/MiniPlayer.tsx
+++ b/src/components/player/PlayerPhoneMini.tsx
@@ -6,21 +6,21 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati
import { useTranslation } from 'react-i18next';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
-import { CachedImage } from './CachedImage';
-import { MarqueeText } from './MarqueeText';
-import WaveformLogo from './WaveformLogo';
-import { useImagePalette } from '../hooks/useImagePalette';
-import { useTheme } from '../hooks/useTheme';
-import { skipToNext, togglePlayPause } from '../services/playerService';
-import { playbackSettingsStore } from '../store/playbackSettingsStore';
-import { playerStore } from '../store/playerStore';
-
-import { absoluteFill } from '../utils/styles';
+import { CachedImage } from '@/components/CachedImage';
+import { MarqueeText } from '@/components/MarqueeText';
+import WaveformLogo from '@/components/WaveformLogo';
+import { useImagePalette } from '@/hooks/useImagePalette';
+import { useTheme } from '@/hooks/useTheme';
+import { skipToNext, togglePlayPause } from '@/services/playerService';
+import { playbackSettingsStore } from '@/store/playbackSettingsStore';
+import { playerStore } from '@/store/playerStore';
+
+import { absoluteFill } from '@/utils/styles';
const MINI_PLAYER_HEIGHT = 56;
/** Matches the placeholder cover art background (rgb 150,150,150). */
const PLACEHOLDER_BG = '#969696';
-export function MiniPlayer() {
+export function PlayerPhoneMini() {
const { colors } = useTheme();
const { t } = useTranslation();
const currentTrack = playerStore((s) => s.currentTrack);
diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/player/PlayerTabletLandscape.tsx
similarity index 79%
rename from src/components/ExpandedPlayerView.tsx
rename to src/components/player/PlayerTabletLandscape.tsx
index 6928aa6..507bd29 100644
--- a/src/components/ExpandedPlayerView.tsx
+++ b/src/components/player/PlayerTabletLandscape.tsx
@@ -13,76 +13,66 @@ import {
type LayoutChangeEvent,
} from 'react-native';
import Animated, {
- Easing,
Extrapolation,
- cancelAnimation,
interpolate,
useAnimatedStyle,
- useSharedValue,
- withRepeat,
- withTiming,
- runOnJS,
type SharedValue,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { AlbumInfoContent } from './AlbumInfoContent';
-import { LyricsContent } from './LyricsContent';
-import { CachedImage } from './CachedImage';
-import { MarqueeText } from './MarqueeText';
-import { MoreOptionsButton } from './MoreOptionsButton';
-import { PlaybackRateButton } from './PlaybackRateButton';
-import { PlayerProgressBar } from './PlayerProgressBar';
-import { QueueItemRow } from './QueueItemRow';
-import { RepeatButton } from './RepeatButton';
-import { ShuffleButton } from './ShuffleButton';
-import { SkipIntervalButton } from './SkipIntervalButton';
-import { SleepTimerButton } from './SleepTimerButton';
-import { SleepTimerCapsule } from './SleepTimerCapsule';
-import { closeOpenRow } from './SwipeableRow';
-import { useCanSkip } from '../hooks/useCanSkip';
-import { useImagePalette } from '../hooks/useImagePalette';
-import { mixHexColors } from '../utils/colors';
-import { useIsStarred } from '../hooks/useIsStarred';
-import { useTheme } from '../hooks/useTheme';
-import { useThemedAlert } from '../hooks/useThemedAlert';
-import { toggleStar } from '../services/moreOptionsService';
+import { AlbumInfoContent } from '@/components/AlbumInfoContent';
+import { LyricsContent } from '@/components/LyricsContent';
+import { BookmarkButton } from '@/components/BookmarkButton';
+import { CachedImage } from '@/components/CachedImage';
+import { FavoriteButton } from '@/components/FavoriteButton';
+import { MarqueeText } from '@/components/MarqueeText';
+import { MoreOptionsButton } from '@/components/MoreOptionsButton';
+import { PlaybackRateButton } from '@/components/PlaybackRateButton';
+import { PlayerProgressBar } from '@/components/PlayerProgressBar';
+import { QueueItemRow } from '@/components/QueueItemRow';
+import { RepeatButton } from '@/components/RepeatButton';
+import { ShuffleButton } from '@/components/ShuffleButton';
+import { ShuffleOverlay } from '@/components/ShuffleOverlay';
+import { SkipIntervalButton } from '@/components/SkipIntervalButton';
+import { SleepTimerButton } from '@/components/SleepTimerButton';
+import { SleepTimerCapsule } from '@/components/SleepTimerCapsule';
+import { closeOpenRow } from '@/components/SwipeableRow';
+import { useCanSkip } from '@/hooks/useCanSkip';
+import { useImagePalette } from '@/hooks/useImagePalette';
+import { mixHexColors } from '@/utils/colors';
+import { usePlayerActions } from '@/hooks/usePlayerActions';
+import { useShuffleOverlay } from '@/hooks/useShuffleOverlay';
+import { useTheme } from '@/hooks/useTheme';
import {
- clearQueue,
retryPlayback,
- seekTo,
- shuffleQueue,
skipToNext,
skipToPrevious,
- skipToTrack,
togglePlayPause,
-} from '../services/playerService';
-import { sanitizeBiographyText } from '../utils/formatters';
-import { type Child } from '../services/subsonicService';
-import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo';
-import { usePlayerLyrics } from '../hooks/usePlayerLyrics';
-import { playbackSettingsStore } from '../store/playbackSettingsStore';
-import { createShareStore } from '../store/createShareStore';
-import { moreOptionsStore } from '../store/moreOptionsStore';
-import { offlineModeStore } from '../store/offlineModeStore';
-import { playerStore } from '../store/playerStore';
-import { tabletLayoutStore } from '../store/tabletLayoutStore';
-
-import { absoluteFill } from '../utils/styles';
+} from '@/services/playerService';
+import { sanitizeBiographyText } from '@/utils/formatters';
+import { type Child } from '@/services/subsonicService';
+import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo';
+import { usePlayerLyrics } from '@/hooks/usePlayerLyrics';
+import { playbackSettingsStore } from '@/store/playbackSettingsStore';
+import { moreOptionsStore } from '@/store/moreOptionsStore';
+import { offlineModeStore } from '@/store/offlineModeStore';
+import { playerStore } from '@/store/playerStore';
+import { tabletLayoutStore } from '@/store/tabletLayoutStore';
+
+import { absoluteFill } from '@/utils/styles';
const HERO_COVER_SIZE = 600;
const CONTENT_PADDING = 40;
const COLUMN_GAP = 32;
-interface ExpandedPlayerViewProps {
+interface PlayerTabletLandscapeProps {
expandProgress: SharedValue;
}
-export function ExpandedPlayerView({
+export function PlayerTabletLandscape({
expandProgress,
-}: ExpandedPlayerViewProps) {
+}: PlayerTabletLandscapeProps) {
const { t } = useTranslation();
const { colors } = useTheme();
- const { alert } = useThemedAlert();
const insets = useSafeAreaInsets();
const currentTrack = playerStore((s) => s.currentTrack);
const currentTrackIndex = playerStore((s) => s.currentTrackIndex);
@@ -214,71 +204,20 @@ export function ExpandedPlayerView({
tabletLayoutStore.getState().setPlayerExpanded(false);
}, []);
- const handleSeek = useCallback((seconds: number) => {
- seekTo(seconds);
- }, []);
-
- const handleQueueItemPress = useCallback((index: number) => {
- skipToTrack(index);
- }, []);
-
- const handleQueueItemLongPress = useCallback((track: Child) => {
- moreOptionsStore.getState().show({ type: 'song', item: track }, 'playerexpanded');
- }, []);
-
- const handleClearQueue = useCallback(() => {
- alert(
- t('clearQueue'),
- t('clearQueueMessage'),
- [
- { text: t('cancel'), style: 'cancel' },
- { text: t('clear'), style: 'destructive', onPress: clearQueue },
- ],
- );
- }, []);
-
- const handleShareQueue = useCallback(() => {
- const ids = queue.map((t) => t.id);
- if (ids.length > 0) {
- createShareStore.getState().showQueue(ids);
- }
- }, [queue]);
-
- // --- Shuffle overlay ---
- const [shuffling, setShuffling] = useState(false);
- const shuffleOverlayOpacity = useSharedValue(0);
- const spinAnim = useSharedValue(0);
-
- const shuffleOverlayStyle = useAnimatedStyle(() => ({
- opacity: shuffleOverlayOpacity.value,
- }));
-
- const spinStyle = useAnimatedStyle(() => ({
- transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }],
- }));
+ const {
+ handleSeek,
+ handleQueueItemPress,
+ handleQueueItemLongPress,
+ handleShareQueue,
+ handleClearQueue,
+ } = usePlayerActions({ source: 'player-tablet-landscape' });
- const handleShuffle = useCallback(async () => {
- if (shuffling) return;
- setShuffling(true);
- spinAnim.value = 0;
-
- shuffleOverlayOpacity.value = withTiming(1, { duration: 250 });
- spinAnim.value = withRepeat(
- withTiming(1, { duration: 800, easing: Easing.linear }),
- -1,
- );
-
- const MIN_DISPLAY = 2000;
- await Promise.all([
- shuffleQueue(),
- new Promise((r) => setTimeout(r, MIN_DISPLAY)),
- ]);
-
- cancelAnimation(spinAnim);
- shuffleOverlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => {
- if (finished) runOnJS(setShuffling)(false);
- });
- }, [shuffling, shuffleOverlayOpacity, spinAnim]);
+ const {
+ shuffling,
+ handleShuffle,
+ overlayStyle: shuffleOverlayStyle,
+ spinStyle,
+ } = useShuffleOverlay();
// --- Queue rendering ---
@@ -399,11 +338,11 @@ export function ExpandedPlayerView({
- moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'playerexpanded')
+ moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-landscape')
}
color={colors.textPrimary}
/>
-
+
-
+
@@ -452,10 +395,6 @@ export function ExpandedPlayerView({
- {showSkipInterval && (
-
- )}
-
[
@@ -476,10 +415,6 @@ export function ExpandedPlayerView({
)}
- {showSkipInterval && (
-
- )}
-
- {/* Secondary controls row — sleep timer button */}
- {showSleepTimer && (
-
-
-
+ {/* Secondary controls row — sleep timer, then skip-interval
+ buttons under prev/next with the playback rate between them */}
+
+
+ {showSleepTimer && }
+
+
+ {showSkipInterval && (
+
+ )}
+
+
-
-
+ {showSkipInterval && (
+
+ )}
- )}
+
+
+
+
{/* Quality badge */}
{qualityLabel && (
@@ -654,67 +600,17 @@ export function ExpandedPlayerView({
{/* Shuffle overlay */}
- {shuffling && (
-
-
-
-
-
-
- Shuffling\u2026
-
-
-
- )}
+
);
}
-/* ------------------------------------------------------------------ */
-/* Favorite button */
-/* ------------------------------------------------------------------ */
-
-const ExpandedFavoriteButton = memo(function ExpandedFavoriteButton({
- trackId,
- colors,
-}: {
- trackId: string;
- colors: { red: string; textSecondary: string };
-}) {
- const { t } = useTranslation();
- const starred = useIsStarred('song', trackId);
- const offlineMode = offlineModeStore((s) => s.offlineMode);
-
- const handleToggle = useCallback(() => {
- toggleStar('song', trackId);
- }, [trackId]);
-
- return (
- [
- styles.favoriteButton,
- pressed && !offlineMode && styles.pressed,
- offlineMode && styles.disabled,
- ]}
- >
-
-
- );
-});
-
/* ------------------------------------------------------------------ */
/* Styles */
@@ -831,11 +727,22 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
height: 36,
+ marginTop: 20,
marginBottom: 8,
},
secondaryCenter: {
width: 248,
},
+ secondaryCenterRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ },
+ secondaryRateSlot: {
+ width: 64,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
controlSideLeft: {
flex: 1,
alignItems: 'flex-start',
@@ -869,6 +776,12 @@ const styles = StyleSheet.create({
favoriteButton: {
padding: 4,
},
+ bookmarkButton: {
+ paddingVertical: 4,
+ },
+ secondaryLeftInset: {
+ paddingLeft: 4,
+ },
qualityBadge: {
fontSize: 12,
fontWeight: '500',
@@ -928,26 +841,6 @@ const styles = StyleSheet.create({
padding: 4,
},
- /* --- Shuffle overlay --- */
- shuffleOverlay: {
- ...absoluteFill,
- backgroundColor: 'rgba(0,0,0,0.5)',
- alignItems: 'center',
- justifyContent: 'center',
- zIndex: 20,
- },
- shuffleCard: {
- borderRadius: 16,
- paddingHorizontal: 32,
- paddingVertical: 24,
- alignItems: 'center',
- gap: 12,
- },
- shuffleText: {
- fontSize: 16,
- fontWeight: '600',
- },
-
pressed: {
opacity: 0.6,
},
diff --git a/src/components/player/PlayerTabletPortraitMini.tsx b/src/components/player/PlayerTabletPortraitMini.tsx
new file mode 100644
index 0000000..7c4892c
--- /dev/null
+++ b/src/components/player/PlayerTabletPortraitMini.tsx
@@ -0,0 +1,374 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import { LinearGradient } from 'expo-linear-gradient';
+import { useRouter } from 'expo-router';
+import { memo, useCallback, useMemo, useState } from 'react';
+import { ActivityIndicator, type LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import Animated, { useAnimatedStyle } from 'react-native-reanimated';
+
+import { CachedImage } from '@/components/CachedImage';
+import { FavoriteButton } from '@/components/FavoriteButton';
+import { PlayerProgressBar } from '@/components/PlayerProgressBar';
+import WaveformLogo from '@/components/WaveformLogo';
+import { type ThemeColors } from '@/constants/theme';
+import { useCanSkip } from '@/hooks/useCanSkip';
+import { useImagePalette } from '@/hooks/useImagePalette';
+import { useTheme } from '@/hooks/useTheme';
+import { retryPlayback, seekTo, skipToNext, skipToPrevious, togglePlayPause } from '@/services/playerService';
+import { playerStore } from '@/store/playerStore';
+import { absoluteFill } from '@/utils/styles';
+
+const COVER_SIZE = 76;
+/** Matches the placeholder cover art background (rgb 150,150,150). */
+const PLACEHOLDER_BG = '#969696';
+
+/** Append alpha hex to a colour string (supports #RGB, #RRGGBB). */
+const withAlpha = (hex: string, alpha: number) => {
+ const a = Math.round(alpha * 255).toString(16).padStart(2, '0');
+ return `${hex}${a}`;
+};
+
+/**
+ * Tablet-portrait mini player — the persistent now-playing footer on tablets
+ * held in portrait. A taller floating card than the phone bar: a 3-column split
+ * of cover art (left), centered title / artist·album·year over an inline
+ * draggable seek bar (center), and favorite / play-pause / next controls
+ * (right). Tapping the art or text opens the full /player screen.
+ *
+ * Phone (PlayerPhoneMini) and tablet landscape (split-view, no mini) are
+ * unaffected. Branched in by BottomChrome via useIsTabletPortrait.
+ */
+export function PlayerTabletPortraitMini() {
+ const { colors } = useTheme();
+ const { t } = useTranslation();
+ const currentTrack = playerStore((s) => s.currentTrack);
+ const queueLoading = playerStore((s) => s.queueLoading);
+
+ const router = useRouter();
+ const openPlayer = useCallback(() => router.push('/player'), [router]);
+
+ // The controls cluster is wider than the cover, which would shove the
+ // centered text/progress off to the left. Measure the controls' width and
+ // mirror it onto the cover side so the centre column is symmetric about the
+ // card's centre (robust across tablet widths and themes).
+ const [sideWidth, setSideWidth] = useState(0);
+ const onControlsLayout = useCallback((e: LayoutChangeEvent) => {
+ const w = e.nativeEvent.layout.width;
+ setSideWidth((prev) => (Math.abs(prev - w) > 0.5 ? w : prev));
+ }, []);
+
+ // Colour extraction (theme-aware; secondary preferred for a calmer top hue).
+ const { primary, secondary, gradientOpacity } = useImagePalette(
+ currentTrack ? (currentTrack.albumId ?? currentTrack.id) : undefined,
+ );
+ const gradientAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: gradientOpacity.value,
+ }));
+
+ // The default track colour (colors.border) is only 6–8% opacity and gets
+ // lost over the album-art gradient, so use a much stronger unplayed-track
+ // colour for this surface only — textPrimary at 28% stays theme-aware
+ // (light track in dark mode, dark track in light mode).
+ const progressColors = useMemo(
+ () => ({ ...colors, border: withAlpha(colors.textPrimary, 0.28) }),
+ [colors],
+ );
+
+ if (!currentTrack) return null;
+
+ const extractedTop = secondary ?? primary ?? colors.card;
+ const topColor = queueLoading ? PLACEHOLDER_BG : extractedTop;
+ const gradientColors: readonly [string, string, ...string[]] = [
+ withAlpha(topColor, 0.65),
+ withAlpha(colors.background, 0.65),
+ ];
+
+ return (
+
+ {/* Album-art accent gradient over the solid card */}
+
+
+
+
+
+ {/* Column 1 — cover art (tap to expand). Sized to match the controls
+ column so the centre column stays centred on the card. */}
+
+ [styles.coverWrap, pressed && styles.pressed]}
+ >
+ {queueLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Column 2 — centered title + artist + progress bar */}
+
+ pressed && styles.pressed}
+ >
+
+ {queueLoading ? t('loading') : currentTrack.title}
+
+
+
+ {!queueLoading && (
+ pressed && styles.pressed}
+ >
+
+ {currentTrack.artist ?? t('unknownArtist')}
+
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Column 3 — transport controls */}
+
+
+
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Progress bar — isolated so high-frequency position ticks don't */
+/* re-render the whole card. Mirrors player-tablet-portrait.tsx. */
+/* ------------------------------------------------------------------ */
+
+const ProgressBar = memo(function ProgressBar({ colors }: { colors: ThemeColors }) {
+ const position = playerStore((s) => s.position);
+ const duration = playerStore((s) => s.duration);
+ const bufferedPosition = playerStore((s) => s.bufferedPosition);
+ const playbackState = playerStore((s) => s.playbackState);
+ const error = playerStore((s) => s.error);
+ const retrying = playerStore((s) => s.retrying);
+ const isBuffering = playbackState === 'buffering' || playbackState === 'loading';
+
+ return (
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Play / pause — filled circle, mirrors the tablet full player. */
+/* ------------------------------------------------------------------ */
+
+const PlayPauseButton = memo(function PlayPauseButton({ colors }: { colors: ThemeColors }) {
+ const playbackState = playerStore((s) => s.playbackState);
+ const queueLoading = playerStore((s) => s.queueLoading);
+ const error = playerStore((s) => s.error);
+ const isPlaying = playbackState === 'playing' || playbackState === 'buffering';
+ const isBuffering = playbackState === 'buffering' || playbackState === 'loading';
+
+ return (
+ [
+ styles.playPauseButton,
+ { backgroundColor: colors.textPrimary },
+ pressed && styles.pressed,
+ ]}
+ >
+ {(isBuffering || queueLoading) ? (
+
+ ) : (
+
+ )}
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Skip to previous. */
+/* ------------------------------------------------------------------ */
+
+const PrevButton = memo(function PrevButton({ colors }: { colors: ThemeColors }) {
+ const { canSkipPrevious } = useCanSkip();
+ return (
+ [styles.controlButton, pressed && canSkipPrevious && styles.pressed]}
+ >
+
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Skip to next. */
+/* ------------------------------------------------------------------ */
+
+const NextButton = memo(function NextButton({ colors }: { colors: ThemeColors }) {
+ const { canSkipNext } = useCanSkip();
+ const handlePress = useCallback(() => {
+ if (canSkipNext) skipToNext();
+ }, [canSkipNext]);
+
+ return (
+ [styles.controlButton, pressed && canSkipNext && styles.pressed]}
+ >
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ card: {
+ marginHorizontal: 12,
+ marginBottom: 8,
+ borderRadius: 16,
+ overflow: 'hidden',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.22,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ band: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ sideLeft: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ },
+ coverFavorite: {
+ marginLeft: 28,
+ padding: 4,
+ },
+ coverWrap: {
+ borderRadius: 10,
+ },
+ cover: {
+ width: COVER_SIZE,
+ height: COVER_SIZE,
+ borderRadius: 10,
+ backgroundColor: 'rgba(0,0,0,0.06)',
+ },
+ coverPlaceholder: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ center: {
+ flex: 1,
+ paddingHorizontal: 16,
+ justifyContent: 'center',
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ lineHeight: 20,
+ textAlign: 'center',
+ },
+ subtitle: {
+ fontSize: 13,
+ lineHeight: 17,
+ marginTop: 2,
+ textAlign: 'center',
+ },
+ progressSection: {
+ width: '100%',
+ marginTop: 6,
+ alignItems: 'center',
+ },
+ progressInner: {
+ width: '80%',
+ },
+ controls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ controlButton: {
+ padding: 4,
+ },
+ playPauseButton: {
+ width: 52,
+ height: 52,
+ borderRadius: 26,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ playIcon: {
+ marginLeft: 2,
+ },
+ pressed: {
+ opacity: 0.6,
+ },
+ disabled: {
+ opacity: 0.35,
+ },
+});
diff --git a/src/components/PlayerPanel.tsx b/src/components/player/PlayerTabletSplitview.tsx
similarity index 65%
rename from src/components/PlayerPanel.tsx
rename to src/components/player/PlayerTabletSplitview.tsx
index 5b41b05..ac3dc61 100644
--- a/src/components/PlayerPanel.tsx
+++ b/src/components/player/PlayerTabletSplitview.tsx
@@ -1,6 +1,6 @@
import Ionicons from "@react-native-vector-icons/ionicons/static";
import { FlashList } from '@shopify/flash-list';
-import { memo, useCallback, useMemo, useState } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
@@ -9,64 +9,44 @@ import {
Text,
View,
} from 'react-native';
-import Animated, {
- Easing,
- cancelAnimation,
- interpolate,
- useAnimatedStyle,
- useSharedValue,
- withRepeat,
- withTiming,
- runOnJS,
-} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { GradientBackground } from './GradientBackground';
-import { CachedImage } from './CachedImage';
-import { MarqueeText } from './MarqueeText';
-import { MoreOptionsButton } from './MoreOptionsButton';
-import { PlaybackRateButton } from './PlaybackRateButton';
-import { PlayerProgressBar } from './PlayerProgressBar';
-import { QueueItemRow } from './QueueItemRow';
-import { RepeatButton } from './RepeatButton';
-import { ShuffleButton } from './ShuffleButton';
-import { SkipIntervalButton } from './SkipIntervalButton';
-import { SleepTimerButton } from './SleepTimerButton';
-import { SleepTimerCapsule } from './SleepTimerCapsule';
-import { closeOpenRow } from './SwipeableRow';
-import { type ThemeColors } from '../constants/theme';
-import { useCanSkip } from '../hooks/useCanSkip';
-import { useIsStarred } from '../hooks/useIsStarred';
-import { mixHexColors } from '../utils/colors';
-import { useTheme } from '../hooks/useTheme';
-import { useThemedAlert } from '../hooks/useThemedAlert';
-import { toggleStar } from '../services/moreOptionsService';
+import { GradientBackground } from '@/components/GradientBackground';
+import { CachedImage } from '@/components/CachedImage';
+import { FavoriteButton } from '@/components/FavoriteButton';
+import { MarqueeText } from '@/components/MarqueeText';
+import { MoreOptionsButton } from '@/components/MoreOptionsButton';
+import { PlaybackRateButton } from '@/components/PlaybackRateButton';
+import { PlayerProgressBar } from '@/components/PlayerProgressBar';
+import { QueueItemRow } from '@/components/QueueItemRow';
+import { RepeatButton } from '@/components/RepeatButton';
+import { ShuffleButton } from '@/components/ShuffleButton';
+import { ShuffleOverlay } from '@/components/ShuffleOverlay';
+import { SleepTimerCapsule } from '@/components/SleepTimerCapsule';
+import { closeOpenRow } from '@/components/SwipeableRow';
+import { type ThemeColors } from '@/constants/theme';
+import { useCanSkip } from '@/hooks/useCanSkip';
+import { mixHexColors } from '@/utils/colors';
+import { usePlayerActions } from '@/hooks/usePlayerActions';
+import { useShuffleOverlay } from '@/hooks/useShuffleOverlay';
+import { useTheme } from '@/hooks/useTheme';
import {
- clearQueue,
retryPlayback,
- seekTo,
- shuffleQueue,
skipToNext,
skipToPrevious,
- skipToTrack,
togglePlayPause,
-} from '../services/playerService';
-import { type Child } from '../services/subsonicService';
-import { playbackSettingsStore } from '../store/playbackSettingsStore';
-import { createShareStore } from '../store/createShareStore';
-import { moreOptionsStore } from '../store/moreOptionsStore';
-import { offlineModeStore } from '../store/offlineModeStore';
-import { playerStore } from '../store/playerStore';
-import { tabletLayoutStore } from '../store/tabletLayoutStore';
-
-import { absoluteFill } from '../utils/styles';
+} from '@/services/playerService';
+import { type Child } from '@/services/subsonicService';
+import { moreOptionsStore } from '@/store/moreOptionsStore';
+import { playerStore } from '@/store/playerStore';
+import { tabletLayoutStore } from '@/store/tabletLayoutStore';
+
const COVER_SIZE = 300;
const PADDING = 16;
-export function PlayerPanel() {
+export function PlayerTabletSplitview() {
const { t } = useTranslation();
const { colors } = useTheme();
- const { alert } = useThemedAlert();
const insets = useSafeAreaInsets();
const queueContentContainerStyle = useMemo(
() => ({ paddingBottom: insets.bottom + 16 }),
@@ -77,75 +57,24 @@ export function PlayerPanel() {
const queue = playerStore((s) => s.queue);
const queueLoading = playerStore((s) => s.queueLoading);
- const handleSeek = useCallback((seconds: number) => {
- seekTo(seconds);
- }, []);
-
- const handleQueueItemPress = useCallback((index: number) => {
- skipToTrack(index);
- }, []);
-
- const handleQueueItemLongPress = useCallback((track: Child) => {
- moreOptionsStore.getState().show({ type: 'song', item: track }, 'playerpanel');
- }, []);
-
- const handleClearQueue = useCallback(() => {
- alert(
- t('clearQueue'),
- t('clearQueueMessage'),
- [
- { text: t('cancel'), style: 'cancel' },
- { text: t('clear'), style: 'destructive', onPress: clearQueue },
- ],
- );
- }, []);
-
- const handleShareQueue = useCallback(() => {
- const ids = queue.map((t) => t.id);
- if (ids.length > 0) {
- createShareStore.getState().showQueue(ids);
- }
- }, [queue]);
+ const {
+ handleSeek,
+ handleQueueItemPress,
+ handleQueueItemLongPress,
+ handleShareQueue,
+ handleClearQueue,
+ } = usePlayerActions({ source: 'player-tablet-splitview' });
const handleExpand = useCallback(() => {
tabletLayoutStore.getState().setPlayerExpanded(true);
}, []);
- // --- Shuffle overlay state ---
- const [shuffling, setShuffling] = useState(false);
- const overlayOpacity = useSharedValue(0);
- const spinAnim = useSharedValue(0);
-
- const overlayAnimatedStyle = useAnimatedStyle(() => ({
- opacity: overlayOpacity.value,
- }));
-
- const spinStyle = useAnimatedStyle(() => ({
- transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }],
- }));
-
- const handleShuffle = useCallback(async () => {
- if (shuffling) return;
- setShuffling(true);
- spinAnim.value = 0;
-
- overlayOpacity.value = withTiming(1, { duration: 250 });
- spinAnim.value = withRepeat(
- withTiming(1, { duration: 800, easing: Easing.linear }),
- -1,
- );
-
- const MIN_DISPLAY = 2000;
- await Promise.all([
- shuffleQueue(),
- new Promise((r) => setTimeout(r, MIN_DISPLAY)),
- ]);
-
- cancelAnimation(spinAnim);
- overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => {
- if (finished) runOnJS(setShuffling)(false);
- });
- }, [shuffling, overlayOpacity, spinAnim]);
+ const {
+ shuffling,
+ handleShuffle,
+ overlayStyle,
+ spinStyle,
+ } = useShuffleOverlay();
// Muted primary for active queue item highlight
const queueColors = useMemo(() => ({
@@ -258,66 +187,16 @@ export function PlayerPanel() {
{/* Shuffle overlay */}
- {shuffling && (
-
-
-
-
-
-
- Shuffling…
-
-
-
- )}
+
);
}
-/* ------------------------------------------------------------------ */
-/* Favorite button */
-/* ------------------------------------------------------------------ */
-
-const PanelFavoriteButton = memo(function PanelFavoriteButton({
- trackId,
- colors,
-}: {
- trackId: string;
- colors: { red: string; textSecondary: string };
-}) {
- const { t } = useTranslation();
- const starred = useIsStarred('song', trackId);
- const offlineMode = offlineModeStore((s) => s.offlineMode);
-
- const handleToggle = useCallback(() => {
- toggleStar('song', trackId);
- }, [trackId]);
-
- return (
- [
- styles.favoriteButton,
- pressed && !offlineMode && styles.pressed,
- offlineMode && styles.disabled,
- ]}
- >
-
-
- );
-});
-
/* ------------------------------------------------------------------ */
/* Panel header: cover art, controls, queue heading */
/* ------------------------------------------------------------------ */
@@ -343,8 +222,6 @@ const PanelHeader = memo(function PanelHeader({
const error = playerStore((s) => s.error);
const retrying = playerStore((s) => s.retrying);
- const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons);
- const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton);
const { canSkipNext, canSkipPrevious } = useCanSkip();
const isPlaying =
@@ -374,7 +251,7 @@ const PanelHeader = memo(function PanelHeader({
- moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'playerpanel')
+ moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-splitview')
}
color={colors.textPrimary}
/>
@@ -409,7 +286,7 @@ const PanelHeader = memo(function PanelHeader({
{currentTrack.artist ?? t('unknownArtist')}
-
+
@@ -444,10 +321,6 @@ const PanelHeader = memo(function PanelHeader({
- {showSkipInterval && (
-
- )}
-
[
@@ -468,10 +341,6 @@ const PanelHeader = memo(function PanelHeader({
)}
- {showSkipInterval && (
-
- )}
-
- {/* Secondary controls row — sleep timer button */}
- {showSleepTimer && (
-
-
-
-
-
-
-
- )}
-
);
});
@@ -595,16 +453,6 @@ const styles = StyleSheet.create({
paddingHorizontal: PADDING,
marginBottom: 8,
},
- secondaryControls: {
- flexDirection: 'row',
- alignItems: 'center',
- height: 36,
- paddingHorizontal: PADDING,
- marginBottom: 8,
- },
- secondaryCenter: {
- width: 190,
- },
controlSideLeft: {
flex: 1,
alignItems: 'flex-start',
@@ -670,22 +518,4 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '600',
},
- shuffleOverlay: {
- ...absoluteFill,
- backgroundColor: 'rgba(0,0,0,0.5)',
- alignItems: 'center',
- justifyContent: 'center',
- zIndex: 20,
- },
- shuffleCard: {
- borderRadius: 16,
- paddingHorizontal: 32,
- paddingVertical: 24,
- alignItems: 'center',
- gap: 12,
- },
- shuffleText: {
- fontSize: 16,
- fontWeight: '600',
- },
});
diff --git a/src/components/settings/DownloadedMusicCard.tsx b/src/components/settings/DownloadedMusicCard.tsx
index bae6c23..88bd4f6 100644
--- a/src/components/settings/DownloadedMusicCard.tsx
+++ b/src/components/settings/DownloadedMusicCard.tsx
@@ -1,16 +1,28 @@
import Ionicons from '@react-native-vector-icons/ionicons/static';
import { useRouter } from 'expo-router';
-import { useCallback, useState } from 'react';
-import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../hooks/useTheme';
+import { useThemedAlert } from '../../hooks/useThemedAlert';
import { settingsStyles } from '../../styles/settingsStyles';
+import { albumLibraryStore } from '../../store/albumLibraryStore';
+import { connectivityStore } from '../../store/connectivityStore';
+import { fullLibraryDownloadStore } from '../../store/fullLibraryDownloadStore';
import {
musicCacheStore,
type MaxConcurrentDownloads,
} from '../../store/musicCacheStore';
+import { offlineModeStore } from '../../store/offlineModeStore';
+import { playlistLibraryStore } from '../../store/playlistLibraryStore';
+import {
+ canDownloadFullLibrary,
+ enqueueFullLibraryDownload,
+} from '../../services/fullLibraryDownloadService';
+import { clearDownloadQueue } from '../../services/musicCacheService';
+import { fireAndForget } from '../../utils/fireAndForget';
import { formatBytes } from '../../utils/formatters';
import { SettingsSectionTitle } from './SettingsSectionTitle';
@@ -19,6 +31,7 @@ const CONCURRENT_OPTIONS: MaxConcurrentDownloads[] = [1, 3, 5];
export function DownloadedMusicCard() {
const { t } = useTranslation();
const { colors } = useTheme();
+ const { alert } = useThemedAlert();
const router = useRouter();
const insets = useSafeAreaInsets();
const [sheetVisible, setSheetVisible] = useState(false);
@@ -29,11 +42,68 @@ export function DownloadedMusicCard() {
const musicQueueCount = musicCacheStore((s) => s.downloadQueue.length);
const maxConcurrentDownloads = musicCacheStore((s) => s.maxConcurrentDownloads);
+ // Full-library download progress / availability.
+ const fullLib = fullLibraryDownloadStore();
+ const online =
+ !offlineModeStore((s) => s.offlineMode) && connectivityStore((s) => s.isServerReachable);
+
const handleSelect = useCallback((value: MaxConcurrentDownloads) => {
musicCacheStore.getState().setMaxConcurrentDownloads(value);
setSheetVisible(false);
}, []);
+ const handleDownloadFullLibrary = useCallback(() => {
+ if (!canDownloadFullLibrary()) {
+ alert(t('downloadFullLibrary'), t('downloadFullLibraryOffline'));
+ return;
+ }
+ // The full-library download needs a clean queue to track its own progress.
+ // If anything is queued (in-progress, errored, or waiting), tell the user to
+ // clear it themselves first and stop here.
+ if (musicQueueCount > 0) {
+ alert(
+ t('downloadFullLibraryQueueNotEmptyTitle'),
+ t('downloadFullLibraryQueueNotEmptyBody'),
+ );
+ return;
+ }
+
+ const albums = albumLibraryStore.getState().albums.length;
+ const playlists = playlistLibraryStore.getState().playlists.length;
+ alert(
+ t('downloadFullLibraryConfirmTitle'),
+ t('downloadFullLibraryConfirmBody', { albums, playlists }),
+ [
+ { text: t('cancel'), style: 'cancel' },
+ {
+ text: t('downloadFullLibraryConfirm'),
+ style: 'default',
+ onPress: () => fireAndForget(enqueueFullLibraryDownload(), 'fullLibraryDownload'),
+ },
+ ],
+ );
+ }, [alert, t, musicQueueCount]);
+
+ // Cancelling stops adding more AND clears whatever was queued so far.
+ const handleCancelFullLibrary = useCallback(() => {
+ fullLibraryDownloadStore.getState().cancel();
+ clearDownloadQueue();
+ }, []);
+
+ // Surface preparing/queueing failures to the user, then clear the flag.
+ useEffect(() => {
+ if (fullLib.error) {
+ alert(t('downloadFullLibrary'), fullLib.error);
+ fullLibraryDownloadStore.getState().clearError();
+ }
+ }, [fullLib.error, alert, t]);
+
+ const fullLibProgressLabel = fullLib.phase === 'preparing'
+ ? t('preparingFullLibrary')
+ : fullLib.albumsQueued < fullLib.albumsTotal
+ ? t('addingAlbumsToQueue', { queued: fullLib.albumsQueued, total: fullLib.albumsTotal })
+ : t('addingPlaylistsToQueue', { queued: fullLib.playlistsQueued, total: fullLib.playlistsTotal });
+
return (
<>
@@ -100,6 +170,49 @@ export function DownloadedMusicCard() {
+
+ {/* Download Full Library — one-shot that queues every album + playlist */}
+ {fullLib.active ? (
+ <>
+
+
+
+ {fullLibProgressLabel}
+
+
+
+ [
+ settingsStyles.actionRowButton,
+ { borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth },
+ pressed && settingsStyles.pressed,
+ ]}
+ >
+
+ {t('cancel')}
+
+
+
+ >
+ ) : (
+
+ [
+ settingsStyles.actionRowButton,
+ { backgroundColor: colors.primary },
+ (!online || pressed) && settingsStyles.pressed,
+ ]}
+ >
+
+
+ {t('downloadFullLibrary')}
+
+
+
+ )}
diff --git a/src/hooks/useIsTabletPortrait.ts b/src/hooks/useIsTabletPortrait.ts
new file mode 100644
index 0000000..196c796
--- /dev/null
+++ b/src/hooks/useIsTabletPortrait.ts
@@ -0,0 +1,12 @@
+import { useWindowDimensions } from 'react-native';
+
+/**
+ * True when the device is a tablet (smallest screen dimension ≥ 600dp, matching
+ * the `IS_TABLET` rule in app/_layout.tsx) currently held in portrait. Used to
+ * route the /player screen to the tablet-portrait layout; phones and
+ * tablet-landscape are unaffected.
+ */
+export function useIsTabletPortrait(): boolean {
+ const { width, height } = useWindowDimensions();
+ return height >= width && Math.min(width, height) >= 600;
+}
diff --git a/src/hooks/useMixBuilder.ts b/src/hooks/useMixBuilder.ts
new file mode 100644
index 0000000..7d5e344
--- /dev/null
+++ b/src/hooks/useMixBuilder.ts
@@ -0,0 +1,137 @@
+/**
+ * Stateful business logic for the "Build a Mix" feature, shared by both the
+ * phone bottom-sheet builder and the embedded tablet panel so the two stay
+ * presentation-only. Owns genre/decade selection, the genre search, and the
+ * play action; the pure era/mix helpers live in `tunedInService`.
+ */
+
+import { useCallback, useMemo, useState } from 'react';
+
+import { connectivityStore } from '../store/connectivityStore';
+import { genreStore } from '../store/genreStore';
+import { layoutPreferencesStore } from '../store/layoutPreferencesStore';
+import { offlineModeStore } from '../store/offlineModeStore';
+import { playTrack } from '../services/playerService';
+import {
+ decadeRangesForLabels,
+ fetchCustomMix,
+ fetchMixSongs,
+} from '../services/tunedInService';
+import { selectionAsync } from '../utils/haptics';
+
+/** Max genres a mix can combine. */
+export const MAX_SELECTED_GENRES = 3;
+
+export interface MixBuilder {
+ selectedGenres: string[];
+ selectedDecades: string[];
+ loading: boolean;
+ searchQuery: string;
+ setSearchQuery: (q: string) => void;
+ /** History genres + genres added via search, deduped. */
+ displayGenres: string[];
+ /** Server genres matching the current search query (excludes already-shown). */
+ searchResults: string[];
+ toggleGenre: (genre: string) => void;
+ selectSearchResult: (genre: string) => void;
+ toggleDecade: (label: string) => void;
+ /** Resolve the selection to songs and start playback. */
+ play: () => Promise;
+}
+
+export function useMixBuilder(availableGenres: string[]): MixBuilder {
+ const [selectedGenres, setSelectedGenres] = useState([]);
+ const [selectedDecades, setSelectedDecades] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [addedGenres, setAddedGenres] = useState([]);
+
+ const serverGenres = genreStore((s) => s.genres);
+
+ const displayGenres = useMemo(() => {
+ const availableSet = new Set(availableGenres.map((g) => g.toLowerCase()));
+ const extra = addedGenres.filter((g) => !availableSet.has(g.toLowerCase()));
+ return [...extra, ...availableGenres];
+ }, [availableGenres, addedGenres]);
+
+ const searchResults = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (query.length === 0) return [];
+ const displaySet = new Set(displayGenres.map((g) => g.toLowerCase()));
+ return serverGenres
+ .filter((g) => {
+ const name = g.value.toLowerCase();
+ return name.includes(query) && !displaySet.has(name);
+ })
+ .slice(0, 8)
+ .map((g) => g.value);
+ }, [searchQuery, serverGenres, displayGenres]);
+
+ const toggleGenre = useCallback((genre: string) => {
+ setSelectedGenres((prev) => {
+ if (prev.includes(genre)) return prev.filter((g) => g !== genre);
+ if (prev.length >= MAX_SELECTED_GENRES) return prev;
+ return [...prev, genre];
+ });
+ }, []);
+
+ const selectSearchResult = useCallback((genre: string) => {
+ selectionAsync();
+ setAddedGenres((prev) => [genre, ...prev.filter((g) => g !== genre)]);
+ setSelectedGenres((prev) => {
+ if (prev.includes(genre)) return prev;
+ if (prev.length >= MAX_SELECTED_GENRES) return prev;
+ return [genre, ...prev];
+ });
+ setSearchQuery('');
+ }, []);
+
+ const toggleDecade = useCallback((label: string) => {
+ selectionAsync();
+ setSelectedDecades((prev) =>
+ prev.includes(label) ? prev.filter((d) => d !== label) : [...prev, label],
+ );
+ }, []);
+
+ const play = useCallback(async () => {
+ if (loading) return;
+ selectionAsync();
+ setLoading(true);
+ try {
+ const online =
+ !offlineModeStore.getState().offlineMode &&
+ connectivityStore.getState().isServerReachable;
+ const ll = layoutPreferencesStore.getState().listLength;
+ const decadeRanges = decadeRangesForLabels(selectedDecades);
+
+ let songs;
+ if (selectedGenres.length === 0 && decadeRanges.length === 0) {
+ // Nothing picked — fully random "Mix It Up".
+ songs = await fetchMixSongs(
+ online ? { type: 'random', size: ll } : { type: 'offline' },
+ ll,
+ );
+ } else {
+ // Genre-only, era-only, or both (incl. multiple non-contiguous decades).
+ songs = await fetchCustomMix(selectedGenres, decadeRanges, online, ll);
+ }
+ if (songs.length > 0) await playTrack(songs[0], songs);
+ } finally {
+ setLoading(false);
+ }
+ }, [loading, selectedGenres, selectedDecades]);
+
+ return {
+ selectedGenres,
+ selectedDecades,
+ loading,
+ searchQuery,
+ setSearchQuery,
+ displayGenres,
+ searchResults,
+ toggleGenre,
+ selectSearchResult,
+ toggleDecade,
+ play,
+ };
+}
diff --git a/src/hooks/usePlayerActions.ts b/src/hooks/usePlayerActions.ts
new file mode 100644
index 0000000..9c78abf
--- /dev/null
+++ b/src/hooks/usePlayerActions.ts
@@ -0,0 +1,63 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useThemedAlert } from './useThemedAlert';
+import { clearQueue, seekTo, skipToTrack } from '../services/playerService';
+import { createShareStore } from '../store/createShareStore';
+import { moreOptionsStore, type MoreOptionsSource } from '../store/moreOptionsStore';
+import { playerStore } from '../store/playerStore';
+import { type Child } from '../services/subsonicService';
+
+export interface UsePlayerActionsOptions {
+ /** Identifies which player UI opened the more-options sheet. */
+ source: MoreOptionsSource;
+ /** Override for the clear-queue confirm action (defaults to `clearQueue`). */
+ onClearConfirmed?: () => void;
+}
+
+/**
+ * Shared queue/playback handlers used by every player surface. Playback
+ * business logic lives in `playerService`; these are the thin UI wrappers.
+ */
+export function usePlayerActions({ source, onClearConfirmed }: UsePlayerActionsOptions) {
+ const { t } = useTranslation();
+ const { alert } = useThemedAlert();
+
+ const handleSeek = useCallback((seconds: number) => {
+ seekTo(seconds);
+ }, []);
+
+ const handleQueueItemPress = useCallback((index: number) => {
+ skipToTrack(index);
+ }, []);
+
+ const handleQueueItemLongPress = useCallback((track: Child) => {
+ moreOptionsStore.getState().show({ type: 'song', item: track }, source);
+ }, [source]);
+
+ const handleShareQueue = useCallback(() => {
+ const ids = playerStore.getState().queue.map((track) => track.id);
+ if (ids.length > 0) {
+ createShareStore.getState().showQueue(ids);
+ }
+ }, []);
+
+ const handleClearQueue = useCallback(() => {
+ alert(
+ t('clearQueue'),
+ t('clearQueueMessage'),
+ [
+ { text: t('cancel'), style: 'cancel' },
+ { text: t('clear'), style: 'destructive', onPress: onClearConfirmed ?? clearQueue },
+ ],
+ );
+ }, [alert, t, onClearConfirmed]);
+
+ return {
+ handleSeek,
+ handleQueueItemPress,
+ handleQueueItemLongPress,
+ handleShareQueue,
+ handleClearQueue,
+ };
+}
diff --git a/src/hooks/usePlayerAlbumInfo.ts b/src/hooks/usePlayerAlbumInfo.ts
index 9549e1d..f89ee3c 100644
--- a/src/hooks/usePlayerAlbumInfo.ts
+++ b/src/hooks/usePlayerAlbumInfo.ts
@@ -1,8 +1,8 @@
/**
* Shared player album-info fetch coordination. Owns the store selectors,
* the fetch-attempt guard ref, the gated effect, retry/refresh handlers,
- * and refreshing state. The phone (`player-view.tsx`) and tablet
- * (`ExpandedPlayerView.tsx`) used to implement this independently — see
+ * and refreshing state. The phone (`player-phone-portrait.tsx`) and tablet
+ * (`PlayerTabletLandscape.tsx`) used to implement this independently — see
* Phase 6 of `plans/2026-05-22-audit-remediation-roadmap.md` for the
* full rationale.
*
diff --git a/src/hooks/usePlayerLyrics.ts b/src/hooks/usePlayerLyrics.ts
index 197260e..6775b6c 100644
--- a/src/hooks/usePlayerLyrics.ts
+++ b/src/hooks/usePlayerLyrics.ts
@@ -1,8 +1,8 @@
/**
* Shared player lyrics fetch coordination. Owns the store selectors, the
* fetch-attempt guard ref, the gated effect, and the retry handler. The
- * phone (`player-view.tsx`) and tablet (`ExpandedPlayerView.tsx`) used to
- * implement this independently — see Phase 6 of
+ * phone (`player-phone-portrait.tsx`) and tablet (`PlayerTabletLandscape.tsx`)
+ * used to implement this independently — see Phase 6 of
* `plans/2026-05-22-audit-remediation-roadmap.md` for full rationale.
*
* No refresh handler: neither surface exposes one for lyrics, matching the
diff --git a/src/hooks/useShuffleOverlay.ts b/src/hooks/useShuffleOverlay.ts
new file mode 100644
index 0000000..41dfcee
--- /dev/null
+++ b/src/hooks/useShuffleOverlay.ts
@@ -0,0 +1,58 @@
+import { useCallback, useState } from 'react';
+import { type ViewStyle } from 'react-native';
+import {
+ Easing,
+ cancelAnimation,
+ interpolate,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { shuffleQueue } from '../services/playerService';
+
+/**
+ * Owns the shuffle action plus its full-screen "Shuffling…" spin overlay.
+ * Pair with the `` component, passing the returned
+ * `overlayStyle`/`spinStyle` and gating render on `shuffling`.
+ */
+export function useShuffleOverlay() {
+ const [shuffling, setShuffling] = useState(false);
+ const overlayOpacity = useSharedValue(0);
+ const spinAnim = useSharedValue(0);
+
+ const overlayStyle = useAnimatedStyle(() => ({
+ opacity: overlayOpacity.value,
+ }));
+
+ const spinStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }],
+ }));
+
+ const handleShuffle = useCallback(async () => {
+ if (shuffling) return;
+ setShuffling(true);
+ spinAnim.value = 0;
+
+ overlayOpacity.value = withTiming(1, { duration: 250 });
+ spinAnim.value = withRepeat(
+ withTiming(1, { duration: 800, easing: Easing.linear }),
+ -1,
+ );
+
+ const MIN_DISPLAY = 2000;
+ await Promise.all([
+ shuffleQueue(),
+ new Promise((r) => setTimeout(r, MIN_DISPLAY)),
+ ]);
+
+ cancelAnimation(spinAnim);
+ overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => {
+ if (finished) runOnJS(setShuffling)(false);
+ });
+ }, [shuffling, overlayOpacity, spinAnim]);
+
+ return { shuffling, handleShuffle, overlayStyle, spinStyle };
+}
diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts
index 8600959..8f0463d 100644
--- a/src/i18n/i18n.ts
+++ b/src/i18n/i18n.ts
@@ -45,7 +45,25 @@ import '@formatjs/intl-datetimeformat/locale-data/zh.js';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
-import { getLocales } from 'expo-localization';
+import { getCalendars, getLocales } from 'expo-localization';
+
+// `@formatjs/intl-datetimeformat` (force-polyfilled above) defaults its
+// timezone to UTC — without this, EVERY Intl-formatted time renders in UTC.
+// The hour bucketing uses native `Date.getHours()` (already local), but the
+// chart labels / "peak hour" text go through Intl, so they appeared shifted by
+// the device's UTC offset (e.g. a 1 PM peak labelled "1 AM" for +12 users).
+// Point the polyfill at the device timezone; `add-all-tz` is already loaded.
+try {
+ const deviceTimeZone = getCalendars()[0]?.timeZone;
+ const DTF = Intl.DateTimeFormat as unknown as {
+ __setDefaultTimeZone?: (tz: string) => void;
+ };
+ if (deviceTimeZone && typeof DTF.__setDefaultTimeZone === 'function') {
+ DTF.__setDefaultTimeZone(deviceTimeZone);
+ }
+} catch {
+ // Native timezone unavailable (e.g. tests) — fall back to the UTC default.
+}
// Locale JSON imports — add new imports here when enabling a language
import en from './locales/en.json';
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index ffceed2..378e6fc 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -415,6 +415,18 @@
"browseDownloadedMusic": "Browse Downloaded Music",
"downloadQueue": "Download Queue",
"downloadQueueWithCount": "Download Queue ({{count}})",
+ "downloadFullLibrary": "Download Full Library",
+ "downloadFullLibraryConfirmTitle": "Download entire library?",
+ "downloadFullLibraryConfirmBody": "This queues all {{albums}} albums and {{playlists}} playlists for download. It can take a while and use significant storage.",
+ "downloadFullLibraryConfirm": "Download",
+ "downloadFullLibraryOffline": "Connect to your server to download the full library.",
+ "downloadFullLibraryQueueNotEmptyTitle": "Download queue not empty",
+ "downloadFullLibraryQueueNotEmptyBody": "Please clear the download queue before downloading your full library.",
+ "downloadFullLibraryFailed": "Couldn't prepare the full-library download. Check your connection and try again.",
+ "downloadFullLibraryPartial": "Couldn't queue {{failed}} of {{total}} items. Try again to pick up what's missing.",
+ "addingAlbumsToQueue": "Adding albums… {{queued}} / {{total}}",
+ "addingPlaylistsToQueue": "Adding playlists… {{queued}} / {{total}}",
+ "preparingFullLibrary": "Preparing library…",
"cachedAlbums": "Cached albums",
"cachedArtists": "Cached artists",
"cachedPlaylists": "Cached playlists",
@@ -860,6 +872,8 @@
"failedToLoad": "Failed to load",
"albumInfoFailedToLoad": "Couldn't load album info.",
"albumInfoTimedOut": "Couldn't load album info — the server took too long to respond.",
+ "albumDetailsNotFound": "No album details available for this track.",
+ "albumDetailsCompilation": "Album details aren't available for compilations.",
"buildAMix": "Build a Mix",
"pickGenresDecadesMore": "Pick genres, decades & more",
"searchGenresPlaceholder": "Search genres...",
@@ -963,6 +977,8 @@
"heavyRotation": "Heavy Rotation",
"mostPlayedThisWeek": "Your most played this week",
"decadeAny": "Any",
+ "decadeEarlier": "Earlier",
+ "decadeRecent": "Recent",
"theDecade": "The {{decade}}",
"monthJan": "Jan",
"monthFeb": "Feb",
diff --git a/src/screens/home.tsx b/src/screens/home.tsx
index 9cad242..edb6f7f 100644
--- a/src/screens/home.tsx
+++ b/src/screens/home.tsx
@@ -202,7 +202,11 @@ function AlbumSection({
) : (
a.id).slice(0, LIST_LENGTH_DISPLAY_CAP)}
renderItem={renderItem}
keyExtractor={keyExtractor}
horizontal
diff --git a/src/screens/my-listening.tsx b/src/screens/my-listening.tsx
index 9aabf31..10d9310 100644
--- a/src/screens/my-listening.tsx
+++ b/src/screens/my-listening.tsx
@@ -384,7 +384,7 @@ export function MyListeningScreen() {
subtitle={item.artist}
count={item.count}
maxCount={analytics.topAlbums[0].count}
- coverArtId={item.albumId ?? item.coverArt}
+ coverArtId={item.albumId}
colors={colors}
index={i}
onPress={onOpenAlbum(item.albumId)}
diff --git a/src/screens/__tests__/player-view.test.tsx b/src/screens/player/__tests__/player-phone-portrait.test.tsx
similarity index 78%
rename from src/screens/__tests__/player-view.test.tsx
rename to src/screens/player/__tests__/player-phone-portrait.test.tsx
index 3fdae19..d664ac1 100644
--- a/src/screens/__tests__/player-view.test.tsx
+++ b/src/screens/player/__tests__/player-phone-portrait.test.tsx
@@ -1,6 +1,6 @@
-jest.mock('../../store/persistence/kvStorage', () => require('../../store/persistence/__mocks__/kvStorage'));
+jest.mock('@/store/persistence/kvStorage', () => require('@/store/persistence/__mocks__/kvStorage'));
-jest.mock('../../hooks/useTheme', () => ({
+jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({
theme: 'dark',
colors: {
@@ -17,7 +17,7 @@ jest.mock('../../hooks/useTheme', () => ({
}),
}));
-jest.mock('../../hooks/useImagePalette', () => ({
+jest.mock('@/hooks/useImagePalette', () => ({
useImagePalette: () => ({
primary: '#333333',
secondary: null,
@@ -25,15 +25,15 @@ jest.mock('../../hooks/useImagePalette', () => ({
}),
}));
-jest.mock('../../hooks/useCanSkip', () => ({
+jest.mock('@/hooks/useCanSkip', () => ({
useCanSkip: () => ({ canSkipNext: true, canSkipPrevious: true }),
}));
-jest.mock('../../hooks/useIsStarred', () => ({
+jest.mock('@/hooks/useIsStarred', () => ({
useIsStarred: () => false,
}));
-jest.mock('../../hooks/useThemedAlert', () => ({
+jest.mock('@/hooks/useThemedAlert', () => ({
useThemedAlert: () => ({
alert: jest.fn(),
}),
@@ -84,76 +84,76 @@ jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
}));
-jest.mock('../../components/CachedImage', () => {
+jest.mock('@/components/CachedImage', () => {
const { View } = require('react-native');
return { CachedImage: (props: { coverArtId?: string }) => };
});
-jest.mock('../../components/MarqueeText', () => {
+jest.mock('@/components/MarqueeText', () => {
const { Text } = require('react-native');
return { MarqueeText: ({ children, style }: { children: React.ReactNode; style?: object }) => {children} };
});
-jest.mock('../../components/PlayerProgressBar', () => {
+jest.mock('@/components/PlayerProgressBar', () => {
const { View } = require('react-native');
return { PlayerProgressBar: () => };
});
-jest.mock('../../components/PlaybackRateButton', () => {
+jest.mock('@/components/PlaybackRateButton', () => {
const { View } = require('react-native');
return { PlaybackRateButton: () => };
});
-jest.mock('../../components/RepeatButton', () => {
+jest.mock('@/components/RepeatButton', () => {
const { View } = require('react-native');
return { RepeatButton: () => };
});
-jest.mock('../../components/ShuffleButton', () => {
+jest.mock('@/components/ShuffleButton', () => {
const { View } = require('react-native');
return { ShuffleButton: () => };
});
-jest.mock('../../components/SkipIntervalButton', () => {
+jest.mock('@/components/SkipIntervalButton', () => {
const { View } = require('react-native');
return { SkipIntervalButton: () => };
});
-jest.mock('../../components/QueueItemRow', () => {
+jest.mock('@/components/QueueItemRow', () => {
const { Text } = require('react-native');
return { QueueItemRow: ({ track }: { track: { title: string } }) => {track.title} };
});
-jest.mock('../../components/SwipeableRow', () => ({
+jest.mock('@/components/SwipeableRow', () => ({
closeOpenRow: jest.fn(),
}));
-jest.mock('../../components/MoreOptionsButton', () => {
+jest.mock('@/components/MoreOptionsButton', () => {
const { View } = require('react-native');
return { MoreOptionsButton: () => };
});
-jest.mock('../../components/ThemedAlert', () => {
+jest.mock('@/components/ThemedAlert', () => {
const { View } = require('react-native');
return { ThemedAlert: () => };
});
-jest.mock('../../components/EmptyState', () => {
+jest.mock('@/components/EmptyState', () => {
const { Text } = require('react-native');
return { EmptyState: ({ title }: { title: string }) => {title} };
});
-jest.mock('../../components/AlbumInfoContent', () => {
+jest.mock('@/components/AlbumInfoContent', () => {
const { Text } = require('react-native');
return { AlbumInfoContent: () => AlbumInfoContent };
});
-jest.mock('../../components/LyricsContent', () => {
+jest.mock('@/components/LyricsContent', () => {
const { Text } = require('react-native');
return { LyricsContent: () => LyricsContent };
});
-jest.mock('../../store/lyricsStore', () => {
+jest.mock('@/store/lyricsStore', () => {
const fetchLyrics = jest.fn();
const state = {
entries: {},
@@ -168,7 +168,7 @@ jest.mock('../../store/lyricsStore', () => {
return { lyricsStore: store };
});
-jest.mock('../../services/playerService', () => ({
+jest.mock('@/services/playerService', () => ({
clearQueue: jest.fn(),
retryPlayback: jest.fn(),
seekTo: jest.fn(),
@@ -179,15 +179,15 @@ jest.mock('../../services/playerService', () => ({
togglePlayPause: jest.fn(),
}));
-jest.mock('../../services/moreOptionsService', () => ({
+jest.mock('@/services/moreOptionsService', () => ({
toggleStar: jest.fn(),
}));
-jest.mock('../../utils/formatters', () => ({
+jest.mock('@/utils/formatters', () => ({
sanitizeBiographyText: jest.fn((text: string) => text),
}));
-jest.mock('../../utils/stringHelpers', () => ({
+jest.mock('@/utils/stringHelpers', () => ({
minDelay: () => Promise.resolve(),
}));
@@ -221,11 +221,11 @@ jest.mock('@shopify/flash-list', () => {
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react-native';
-import { playerStore } from '../../store/playerStore';
-import { type Child } from '../../services/subsonicService';
+import { playerStore } from '@/store/playerStore';
+import { type Child } from '@/services/subsonicService';
// Must import after mocks
-const { PlayerView } = require('../player-view');
+const { PlayerPhonePortrait } = require('@/screens/player/player-phone-portrait');
const MOCK_TRACK: Child = {
id: 'track-1',
@@ -259,15 +259,15 @@ beforeEach(() => {
});
});
-describe('PlayerView', () => {
+describe('PlayerPhonePortrait', () => {
it('renders empty state when no current track', () => {
playerStore.setState({ currentTrack: null });
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('Nothing Playing')).toBeTruthy();
});
it('renders player content by default (player tab)', () => {
- const { getByText, queryByText } = render();
+ const { getByText, queryByText } = render();
// Hero player content visible
expect(getByText('Test Song')).toBeTruthy();
@@ -283,7 +283,7 @@ describe('PlayerView', () => {
});
it('switches to queue tab when queue icon pressed', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -295,7 +295,7 @@ describe('PlayerView', () => {
});
it('shows queue header with shuffle, share, clear actions', () => {
- const { getByLabelText } = render();
+ const { getByLabelText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -306,7 +306,7 @@ describe('PlayerView', () => {
});
it('switches to lyrics tab and mounts LyricsContent', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
act(() => {
fireEvent.press(getByLabelText('Lyrics'));
@@ -316,7 +316,7 @@ describe('PlayerView', () => {
});
it('switches to info tab showing album info', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
act(() => {
fireEvent.press(getByLabelText('Album Info'));
@@ -326,7 +326,7 @@ describe('PlayerView', () => {
});
it('returns to player tab when Now Playing pressed', () => {
- const { getByLabelText, getAllByText } = render();
+ const { getByLabelText, getAllByText } = render();
// Switch to queue
act(() => {
@@ -344,12 +344,12 @@ describe('PlayerView', () => {
it('renders loading state when queue is loading', () => {
playerStore.setState({ queueLoading: true });
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('Loading\u2026')).toBeTruthy();
});
it('renders transport control buttons', () => {
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('play-back')).toBeTruthy();
expect(getByText('pause')).toBeTruthy(); // playing state shows pause
@@ -358,24 +358,24 @@ describe('PlayerView', () => {
it('renders play icon when paused', () => {
playerStore.setState({ playbackState: 'paused' });
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('play')).toBeTruthy();
});
it('renders favorite button', () => {
- const { getByLabelText } = render();
+ const { getByLabelText } = render();
expect(getByLabelText('Add to Favorites')).toBeTruthy();
});
it('presses favorite button without error', () => {
- const { getByLabelText } = render();
+ const { getByLabelText } = render();
fireEvent.press(getByLabelText('Add to Favorites'));
- const { toggleStar } = require('../../services/moreOptionsService');
+ const { toggleStar } = require('@/services/moreOptionsService');
expect(toggleStar).toHaveBeenCalledWith('song', 'track-1');
});
it('presses play/pause button', () => {
- const { getByText } = render();
+ const { getByText } = render();
// Find the pause icon (since state is 'playing')
const pauseIcon = getByText('pause');
// The icon is inside a Pressable; fire on the closest pressable parent
@@ -383,22 +383,22 @@ describe('PlayerView', () => {
});
it('presses skip forward button', () => {
- const { getByText } = render();
+ const { getByText } = render();
fireEvent.press(getByText('play-forward'));
- const { skipToNext } = require('../../services/playerService');
+ const { skipToNext } = require('@/services/playerService');
expect(skipToNext).toHaveBeenCalled();
});
it('presses skip backward button', () => {
- const { getByText } = render();
+ const { getByText } = render();
fireEvent.press(getByText('play-back'));
- const { skipToPrevious } = require('../../services/playerService');
+ const { skipToPrevious } = require('@/services/playerService');
expect(skipToPrevious).toHaveBeenCalled();
});
it('renders queue empty state when queue has no items', () => {
playerStore.setState({ queue: [] });
- const { getByLabelText, queryByLabelText } = render();
+ const { getByLabelText, queryByLabelText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -410,14 +410,14 @@ describe('PlayerView', () => {
it('shows buffering indicator when buffering', () => {
playerStore.setState({ playbackState: 'buffering' });
- const { queryByText } = render();
+ const { queryByText } = render();
// In buffering state, the play icon should not be shown (ActivityIndicator shows instead)
expect(queryByText('play')).toBeNull();
expect(queryByText('pause')).toBeNull();
});
it('mounts info tab lazily on first selection', () => {
- const { getByLabelText, queryByText, getByText } = render();
+ const { getByLabelText, queryByText, getByText } = render();
// Info tab should not be mounted initially
expect(queryByText('AlbumInfoContent')).toBeNull();
@@ -432,17 +432,18 @@ describe('PlayerView', () => {
});
it('renders shuffle button in queue tab', () => {
- const { getByLabelText, getByTestId } = render();
+ const { getByLabelText, getAllByTestId } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
});
- expect(getByTestId('shuffle-button')).toBeTruthy();
+ // Shuffle now appears both in the player controls and the queue header.
+ expect(getAllByTestId('shuffle-button').length).toBeGreaterThanOrEqual(2);
});
it('calls share queue when share button pressed', () => {
- const { getByLabelText } = render();
+ const { getByLabelText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -452,7 +453,7 @@ describe('PlayerView', () => {
});
it('calls clear queue when clear button pressed', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -462,7 +463,7 @@ describe('PlayerView', () => {
});
it('renders all queue items in queue tab', () => {
- const { getByLabelText, getByText, getAllByText } = render();
+ const { getByLabelText, getByText, getAllByText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -478,7 +479,7 @@ describe('PlayerView', () => {
const routerBack = jest.fn();
jest.spyOn(require('expo-router'), 'useRouter').mockReturnValue({ back: routerBack });
- render();
+ render();
// Simulate track being cleared after being populated
act(() => {
@@ -489,7 +490,7 @@ describe('PlayerView', () => {
});
it('invokes skipToTrack when queue item pressed', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
act(() => {
fireEvent.press(getByLabelText('Queue'));
@@ -500,14 +501,14 @@ describe('PlayerView', () => {
});
it('invokes seekTo when progress bar seeks', () => {
- // This exercises the handleSeek callback defined in PlayerView
+ // This exercises the handleSeek callback defined in PlayerPhonePortrait
// The progress bar is mocked, so we verify it renders without error
- const { getByTestId } = render();
+ const { getByTestId } = render();
expect(getByTestId('progress-bar')).toBeTruthy();
});
it('preserves mounted tabs when switching between them', () => {
- const { getByLabelText, getByText } = render();
+ const { getByLabelText, getByText } = render();
// Mount queue tab
act(() => {
diff --git a/src/screens/player-view.tsx b/src/screens/player/player-phone-portrait.tsx
similarity index 76%
rename from src/screens/player-view.tsx
rename to src/screens/player/player-phone-portrait.tsx
index f4989b2..baa06fc 100644
--- a/src/screens/player-view.tsx
+++ b/src/screens/player/player-phone-portrait.tsx
@@ -14,69 +14,59 @@ import {
} from 'react-native';
import Animated, {
Easing,
- cancelAnimation,
interpolate,
useAnimatedStyle,
useSharedValue,
- withRepeat,
withTiming,
- runOnJS,
} from 'react-native-reanimated';
import { Pressable as GHPressable } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
-import { AlbumInfoContent } from '../components/AlbumInfoContent';
-import { LyricsContent } from '../components/LyricsContent';
-import { CachedImage } from '../components/CachedImage';
-import { EmptyState } from '../components/EmptyState';
-import { MarqueeText } from '../components/MarqueeText';
-import { MoreOptionsButton } from '../components/MoreOptionsButton';
-import { PlaybackRateButton } from '../components/PlaybackRateButton';
-import { PlayerProgressBar } from '../components/PlayerProgressBar';
-import { PlayerTabBar, type PlayerTab } from '../components/PlayerTabBar';
-import { RepeatButton } from '../components/RepeatButton';
-import { ShuffleButton } from '../components/ShuffleButton';
-import { SkipIntervalButton } from '../components/SkipIntervalButton';
-import { SleepTimerButton } from '../components/SleepTimerButton';
-import { SleepTimerCapsule } from '../components/SleepTimerCapsule';
-import { QueueItemRow } from '../components/QueueItemRow';
-import { closeOpenRow } from '../components/SwipeableRow';
-import { type ThemeColors } from '../constants/theme';
-import { useCanSkip } from '../hooks/useCanSkip';
-import { useImagePalette } from '../hooks/useImagePalette';
-import { useIsStarred } from '../hooks/useIsStarred';
-import { useTheme } from '../hooks/useTheme';
-import { ThemedAlert } from '../components/ThemedAlert';
-import { useThemedAlert } from '../hooks/useThemedAlert';
-import { toggleStar } from '../services/moreOptionsService';
-import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService';
-import { bookmarkSheetStore } from '../store/bookmarkSheetStore';
-import { bookmarksStore } from '../store/bookmarksStore';
-import { playbackToastStore } from '../store/playbackToastStore';
-import { offlineModeStore } from '../store/offlineModeStore';
+import { AlbumInfoContent } from '@/components/AlbumInfoContent';
+import { LyricsContent } from '@/components/LyricsContent';
+import { BookmarkButton } from '@/components/BookmarkButton';
+import { CachedImage } from '@/components/CachedImage';
+import { FavoriteButton } from '@/components/FavoriteButton';
+import { EmptyState } from '@/components/EmptyState';
+import { MarqueeText } from '@/components/MarqueeText';
+import { MoreOptionsButton } from '@/components/MoreOptionsButton';
+import { PlaybackRateButton } from '@/components/PlaybackRateButton';
+import { PlayerProgressBar } from '@/components/PlayerProgressBar';
+import { PlayerTabBar, type PlayerTab } from '@/components/PlayerTabBar';
+import { RepeatButton } from '@/components/RepeatButton';
+import { ShuffleButton } from '@/components/ShuffleButton';
+import { ShuffleOverlay } from '@/components/ShuffleOverlay';
+import { SkipIntervalButton } from '@/components/SkipIntervalButton';
+import { SleepTimerButton } from '@/components/SleepTimerButton';
+import { SleepTimerCapsule } from '@/components/SleepTimerCapsule';
+import { QueueItemRow } from '@/components/QueueItemRow';
+import { closeOpenRow } from '@/components/SwipeableRow';
+import { type ThemeColors } from '@/constants/theme';
+import { useCanSkip } from '@/hooks/useCanSkip';
+import { useImagePalette } from '@/hooks/useImagePalette';
+import { usePlayerActions } from '@/hooks/usePlayerActions';
+import { useShuffleOverlay } from '@/hooks/useShuffleOverlay';
+import { useTheme } from '@/hooks/useTheme';
+import { offlineModeStore } from '@/store/offlineModeStore';
import {
clearQueue,
retryPlayback,
- seekTo,
- shuffleQueue,
skipToNext,
skipToPrevious,
- skipToTrack,
togglePlayPause,
-} from '../services/playerService';
-import { sanitizeBiographyText } from '../utils/formatters';
-import { type Child } from '../services/subsonicService';
-import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo';
-import { usePlayerLyrics } from '../hooks/usePlayerLyrics';
-import { playbackSettingsStore } from '../store/playbackSettingsStore';
-import { createShareStore } from '../store/createShareStore';
-import { moreOptionsStore } from '../store/moreOptionsStore';
-import { playerStore } from '../store/playerStore';
-import { mixHexColors } from '../utils/colors';
-
-
-import { absoluteFill } from '../utils/styles';
+} from '@/services/playerService';
+import { sanitizeBiographyText } from '@/utils/formatters';
+import { type Child } from '@/services/subsonicService';
+import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo';
+import { usePlayerLyrics } from '@/hooks/usePlayerLyrics';
+import { playbackSettingsStore } from '@/store/playbackSettingsStore';
+import { moreOptionsStore } from '@/store/moreOptionsStore';
+import { playerStore } from '@/store/playerStore';
+import { mixHexColors } from '@/utils/colors';
+
+
+import { absoluteFill } from '@/utils/styles';
const HERO_PADDING = 32;
const HERO_COVER_SIZE = 600;
const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
@@ -89,10 +79,9 @@ const TAB_SLIDE_DISTANCE = 12;
* handed a fresh object on every parent re-render. */
const QUEUE_CONTENT_CONTAINER_STYLE = { paddingBottom: 12 } as const;
-export function PlayerView() {
+export function PlayerPhonePortrait() {
const { colors } = useTheme();
const { t } = useTranslation();
- const { alert } = useThemedAlert();
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const router = useRouter();
@@ -216,7 +205,7 @@ export function PlayerView() {
currentTrack ? (
- moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player')
+ moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-phone-portrait')
}
color={colors.textPrimary}
/>
@@ -224,85 +213,30 @@ export function PlayerView() {
});
}, [currentTrack, navigation, onClose, colors.textPrimary]);
- const handleSeek = useCallback((seconds: number) => {
- seekTo(seconds);
- }, []);
-
- const handleQueueItemPress = useCallback((index: number) => {
- skipToTrack(index);
- }, []);
-
- const handleQueueItemLongPress = useCallback((track: Child) => {
- moreOptionsStore.getState().show({ type: 'song', item: track }, 'player');
- }, []);
-
- const handleClearQueue = useCallback(() => {
- alert(
- t('clearQueue'),
- t('clearQueueMessage'),
- [
- { text: t('cancel'), style: 'cancel' },
- {
- text: t('clear'),
- style: 'destructive',
- onPress: () => {
- onClose();
- setTimeout(() => {
- clearQueue();
- }, 350);
- },
- },
- ],
- );
+ const onClearConfirmed = useCallback(() => {
+ onClose();
+ setTimeout(() => clearQueue(), 350);
}, [onClose]);
- // --- Shuffle overlay state ---
- const [shuffling, setShuffling] = useState(false);
- const overlayOpacity = useSharedValue(0);
- const spinAnim = useSharedValue(0);
+ const {
+ handleSeek,
+ handleQueueItemPress,
+ handleQueueItemLongPress,
+ handleShareQueue,
+ handleClearQueue,
+ } = usePlayerActions({ source: 'player-phone-portrait', onClearConfirmed });
+
+ const {
+ shuffling,
+ handleShuffle,
+ overlayStyle,
+ spinStyle,
+ } = useShuffleOverlay();
const gradientAnimatedStyle = useAnimatedStyle(() => ({
opacity: gradientOpacity.value,
}));
- const overlayAnimatedStyle = useAnimatedStyle(() => ({
- opacity: overlayOpacity.value,
- }));
-
- const spinStyle = useAnimatedStyle(() => ({
- transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }],
- }));
-
- const handleShuffle = useCallback(async () => {
- if (shuffling) return;
- setShuffling(true);
- spinAnim.value = 0;
-
- overlayOpacity.value = withTiming(1, { duration: 250 });
- spinAnim.value = withRepeat(
- withTiming(1, { duration: 800, easing: Easing.linear }),
- -1,
- );
-
- const MIN_DISPLAY = 2000;
- await Promise.all([
- shuffleQueue(),
- new Promise((r) => setTimeout(r, MIN_DISPLAY)),
- ]);
-
- cancelAnimation(spinAnim);
- overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => {
- if (finished) runOnJS(setShuffling)(false);
- });
- }, [shuffling, overlayOpacity, spinAnim]);
-
- const handleShareQueue = useCallback(() => {
- const ids = queue.map((t) => t.id);
- if (ids.length > 0) {
- createShareStore.getState().showQueue(ids);
- }
- }, [queue]);
-
// Muted primary for active queue item highlight
const queueColors = useMemo(() => ({
...colors,
@@ -368,7 +302,7 @@ export function PlayerView() {
moreOptionsStore.getState().show({ type: 'song', item: currentTrack! }, 'player')}
+ onPress={() => moreOptionsStore.getState().show({ type: 'song', item: currentTrack! }, 'player-phone-portrait')}
hidden={!currentTrack}
/>
@@ -405,6 +339,8 @@ export function PlayerView() {
colors={colors}
queueLoading={queueLoading}
handleSeek={handleSeek}
+ handleShuffle={handleShuffle}
+ shuffling={shuffling}
/>
@@ -469,110 +405,17 @@ export function PlayerView() {
{/* Shuffle overlay */}
- {shuffling && (
-
-
-
-
-
-
- {t('shuffling')}
-
-
-
- )}
+
>
);
}
-/* ------------------------------------------------------------------ */
-/* Favorite button */
-/* ------------------------------------------------------------------ */
-
-const FavoriteButton = memo(function FavoriteButton({
- trackId,
- colors,
-}: {
- trackId: string;
- colors: { red: string; textSecondary: string };
-}) {
- const { t } = useTranslation();
- const starred = useIsStarred('song', trackId);
- const offlineMode = offlineModeStore((s) => s.offlineMode);
-
- const handleToggle = useCallback(() => {
- toggleStar('song', trackId);
- }, [trackId]);
-
- return (
- [
- styles.favoriteButton,
- pressed && !offlineMode && styles.pressed,
- offlineMode && styles.disabled,
- ]}
- >
-
-
- );
-});
-
-/* ------------------------------------------------------------------ */
-/* Bookmark button */
-/* ------------------------------------------------------------------ */
-
-const BookmarkButton = memo(function BookmarkButton({ colors }: { colors: ThemeColors }) {
- const { t, i18n } = useTranslation();
- const autoName = bookmarksStore((s) => s.autoName);
- const queueLength = playerStore((s) => s.queue.length);
- const disabled = queueLength === 0;
-
- const handlePress = useCallback(() => {
- // Capture the queue/position NOW, at tap time, regardless of which path we
- // take — the manual-name sheet commits this same snapshot on Save.
- const snapshot = capturePlayerSnapshot();
- if (!snapshot) return;
- const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name);
- const suggested = buildAutoName(t, i18n.language, existingNames);
- if (autoName) {
- commitBookmark(snapshot, suggested);
- playbackToastStore.getState().flashSuccess(t('bookmarkSaved'));
- } else {
- bookmarkSheetStore.getState().showCreate(suggested, snapshot);
- }
- }, [autoName, t, i18n.language]);
-
- return (
- [
- styles.favoriteButton,
- pressed && !disabled && styles.pressed,
- disabled && styles.disabled,
- ]}
- >
-
-
- );
-});
-
/* ------------------------------------------------------------------ */
/* Player content (hero, controls) — "Player" tab */
/* ------------------------------------------------------------------ */
@@ -582,6 +425,8 @@ interface PlayerContentProps {
colors: ThemeColors;
queueLoading: boolean;
handleSeek: (seconds: number) => void;
+ handleShuffle: () => void;
+ shuffling: boolean;
}
const PlayerContent = memo(function PlayerContent({
@@ -589,6 +434,8 @@ const PlayerContent = memo(function PlayerContent({
colors,
queueLoading,
handleSeek,
+ handleShuffle,
+ shuffling,
}: PlayerContentProps) {
const { t } = useTranslation();
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
@@ -599,6 +446,7 @@ const PlayerContent = memo(function PlayerContent({
const bufferedPosition = playerStore((s) => s.bufferedPosition);
const error = playerStore((s) => s.error);
const retrying = playerStore((s) => s.retrying);
+ const queueLength = playerStore((s) => s.queue.length);
const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons);
const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton);
@@ -672,7 +520,7 @@ const PlayerContent = memo(function PlayerContent({
{currentTrack.artist ?? t('unknownArtist')}
-
+
@@ -691,11 +539,21 @@ const PlayerContent = memo(function PlayerContent({
/>
+ {/* Three equal flex spacers (above the primary row, between the rows,
+ and below the secondary row) evenly distribute the controls in the
+ space under the progress bar — this centers the primary row between
+ the progress bar and the secondary row. */}
+
+
{/* Playback controls */}
- {/* Playback rate toggle */}
+ {/* Shuffle toggle */}
-
+
{/* Transport controls */}
@@ -713,10 +571,6 @@ const PlayerContent = memo(function PlayerContent({
/>
- {showSkipInterval && (
-
- )}
-
[
@@ -737,10 +591,6 @@ const PlayerContent = memo(function PlayerContent({
)}
- {showSkipInterval && (
-
- )}
-
- {/* Secondary controls row — mirrors primary controls layout */}
+ {/* Middle spacer — equal to the spacers above and below the rows. */}
+
+
+ {/* Secondary controls row — mirrors primary controls layout. Skip-interval
+ buttons sit under prev/next with the playback rate between them. */}
-
+
{showSleepTimer && }
-
+
+ {showSkipInterval && (
+
+ )}
+
+
+
+ {showSkipInterval && (
+
+ )}
+
-
+
@@ -1025,6 +889,9 @@ const styles = StyleSheet.create({
paddingLeft: 12,
paddingVertical: 4,
},
+ secondaryLeftInset: {
+ paddingLeft: 4,
+ },
progressSection: {
paddingHorizontal: HERO_PADDING,
maxWidth: 464,
@@ -1055,10 +922,21 @@ const styles = StyleSheet.create({
secondaryCenter: {
width: 248,
},
+ secondaryCenterRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ },
+ secondaryRateSlot: {
+ width: 64,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
secondaryControls: {
flexDirection: 'row',
alignItems: 'center',
height: 40,
+ marginTop: 20,
paddingHorizontal: HERO_PADDING,
maxWidth: 464,
width: '100%',
@@ -1126,22 +1004,4 @@ const styles = StyleSheet.create({
lyricsContainer: {
flex: 1,
},
- shuffleOverlay: {
- ...absoluteFill,
- backgroundColor: 'rgba(0,0,0,0.5)',
- alignItems: 'center',
- justifyContent: 'center',
- zIndex: 20,
- },
- shuffleCard: {
- borderRadius: 16,
- paddingHorizontal: 32,
- paddingVertical: 24,
- alignItems: 'center',
- gap: 12,
- },
- shuffleText: {
- fontSize: 16,
- fontWeight: '600',
- },
});
diff --git a/src/screens/player/player-tablet-portrait.tsx b/src/screens/player/player-tablet-portrait.tsx
new file mode 100644
index 0000000..97b9797
--- /dev/null
+++ b/src/screens/player/player-tablet-portrait.tsx
@@ -0,0 +1,646 @@
+import Ionicons from "@react-native-vector-icons/ionicons/static";
+import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static";
+import { Stack, useNavigation, useRouter } from 'expo-router';
+import { LinearGradient } from 'expo-linear-gradient';
+import { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ ActivityIndicator,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+ useWindowDimensions,
+} from 'react-native';
+import { Pressable as GHPressable } from 'react-native-gesture-handler';
+import Animated, { useAnimatedStyle } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { BookmarkButton } from '@/components/BookmarkButton';
+import { CachedImage } from '@/components/CachedImage';
+import { FavoriteButton } from '@/components/FavoriteButton';
+import { EmptyState } from '@/components/EmptyState';
+import { MarqueeText } from '@/components/MarqueeText';
+import { MoreOptionsButton } from '@/components/MoreOptionsButton';
+import { PlaybackRateButton } from '@/components/PlaybackRateButton';
+import { PlayerProgressBar } from '@/components/PlayerProgressBar';
+import { RepeatButton } from '@/components/RepeatButton';
+import { ShuffleButton } from '@/components/ShuffleButton';
+import { ShuffleOverlay } from '@/components/ShuffleOverlay';
+import { SkipIntervalButton } from '@/components/SkipIntervalButton';
+import { SleepTimerButton } from '@/components/SleepTimerButton';
+import { SleepTimerCapsule } from '@/components/SleepTimerCapsule';
+import { PlayerModeContent, type PlayerMode } from '@/components/player/PlayerModeContent';
+import { type ThemeColors } from '@/constants/theme';
+import { useCanSkip } from '@/hooks/useCanSkip';
+import { useImagePalette } from '@/hooks/useImagePalette';
+import { usePlayerActions } from '@/hooks/usePlayerActions';
+import { useShuffleOverlay } from '@/hooks/useShuffleOverlay';
+import { useTheme } from '@/hooks/useTheme';
+import {
+ clearQueue,
+ retryPlayback,
+ skipToNext,
+ skipToPrevious,
+ togglePlayPause,
+} from '@/services/playerService';
+import { moreOptionsStore } from '@/store/moreOptionsStore';
+import { offlineModeStore } from '@/store/offlineModeStore';
+import { playbackSettingsStore } from '@/store/playbackSettingsStore';
+import { playerStore } from '@/store/playerStore';
+import { mixHexColors } from '@/utils/colors';
+import { absoluteFill } from '@/utils/styles';
+
+const HERO_PADDING = 24;
+const HERO_COVER_SIZE = 600;
+const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
+const COLUMN_GAP = 24;
+const ART_MAX = 440;
+
+/**
+ * Tablet-portrait full-screen Now Playing. A fixed vertical split over a single
+ * page-wide gradient: a large hero band (art + controls) up top, a centered
+ * Queue/Info/Lyrics toggle in the middle, and the selected content filling the
+ * bottom. Used by the /player route only on tablets in portrait (see
+ * useIsTabletPortrait); phone + landscape are unchanged.
+ */
+export function PlayerTabletPortrait() {
+ const { colors } = useTheme();
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation();
+ const router = useRouter();
+ const { height: screenH, width: screenW } = useWindowDimensions();
+
+ const currentTrack = playerStore((s) => s.currentTrack);
+ const currentTrackIndex = playerStore((s) => s.currentTrackIndex);
+ const queue = playerStore((s) => s.queue);
+ const offlineMode = offlineModeStore((s) => s.offlineMode);
+
+ const [mode, setMode] = useState('queue');
+
+ // Info/Lyrics are hidden offline — fall back to the queue if they vanish.
+ useEffect(() => {
+ if (offlineMode && mode !== 'queue') setMode('queue');
+ }, [offlineMode, mode]);
+
+ const onClose = useCallback(() => router.back(), [router]);
+
+ const onClearConfirmed = useCallback(() => {
+ onClose();
+ setTimeout(() => clearQueue(), 350);
+ }, [onClose]);
+
+ const {
+ handleSeek,
+ handleQueueItemPress,
+ handleQueueItemLongPress,
+ handleShareQueue,
+ handleClearQueue,
+ } = usePlayerActions({ source: 'player-tablet-portrait', onClearConfirmed });
+
+ const {
+ shuffling,
+ handleShuffle,
+ overlayStyle,
+ spinStyle,
+ } = useShuffleOverlay();
+
+ // Auto-dismiss when the queue is externally cleared while this screen is open.
+ const [wasPopulated, setWasPopulated] = useState(false);
+ useEffect(() => {
+ if (currentTrack) {
+ setWasPopulated(true);
+ } else if (wasPopulated) {
+ onClose();
+ }
+ }, [currentTrack, wasPopulated, onClose]);
+
+ const { primary, secondary, gradientOpacity } = useImagePalette(
+ currentTrack ? (currentTrack.albumId ?? currentTrack.id) : undefined,
+ );
+ const gradientTopColor = secondary ?? primary ?? colors.background;
+ const gradientColors: readonly [string, string, ...string[]] = [gradientTopColor, colors.background];
+ const gradientLocations: readonly [number, number, ...number[]] = [0, 0.6];
+
+ const gradientAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: gradientOpacity.value,
+ }));
+
+ /* ---- Header: dismiss + more options ---- */
+ useEffect(() => {
+ if (Platform.OS === 'ios') return;
+ navigation.setOptions({
+ headerLeft: () => (
+ [{ opacity: 1 }, pressed && styles.pressed]}
+ >
+
+
+ ),
+ headerRight: () =>
+ currentTrack ? (
+
+ moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-portrait')
+ }
+ color={colors.textPrimary}
+ />
+ ) : null,
+ });
+ }, [currentTrack, navigation, onClose, colors.textPrimary]);
+
+ const headerSpace = insets.top + HEADER_BAR_HEIGHT;
+
+ // Prominent square art on the left of the band, capped so the right column
+ // keeps room for the controls on narrower tablets.
+ const artSize = Math.min(
+ Math.round((screenW - 2 * HERO_PADDING - COLUMN_GAP) * 0.5),
+ ART_MAX,
+ );
+
+ // Bottom content height — preserves the split roughly where the old sheet sat
+ // (~44% of the screen), leaving the band centered in the space above.
+ const bottomSectionHeight = Math.max(Math.round(screenH * 0.44), 320);
+
+ const queueColors = useMemo(
+ () => ({ ...colors, primary: mixHexColors(colors.primary, colors.textPrimary, 0.45) }),
+ [colors],
+ );
+
+ if (!currentTrack) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+ {Platform.OS === 'ios' && (
+ <>
+
+
+
+
+ moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-portrait')}
+ />
+
+ >
+ )}
+
+ {/* Single page-wide gradient background */}
+
+
+
+
+
+
+ {/* Top band: cover art (left) + info/progress/controls (right) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentTrack.title}
+
+
+ {currentTrack.artist ?? t('unknownArtist')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Centered Queue / Info / Lyrics toggle, between the two sections */}
+
+
+ {/* Bottom content: queue / info / lyrics on the page gradient */}
+
+
+
+
+
+ {/* Shuffle overlay */}
+
+
+ >
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Centered Queue / Info / Lyrics toggle */
+/* ------------------------------------------------------------------ */
+
+const ModeToggle = memo(function ModeToggle({
+ mode,
+ onSelect,
+ colors,
+ offlineMode,
+}: {
+ mode: PlayerMode;
+ onSelect: (mode: PlayerMode) => void;
+ colors: ThemeColors;
+ offlineMode: boolean;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ onSelect('queue')}
+ hitSlop={8}
+ accessibilityRole="tab"
+ accessibilityState={{ selected: mode === 'queue' }}
+ accessibilityLabel={t('showQueue')}
+ style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]}
+ >
+
+
+ {!offlineMode && (
+ onSelect('info')}
+ hitSlop={8}
+ accessibilityRole="tab"
+ accessibilityState={{ selected: mode === 'info' }}
+ accessibilityLabel={t('showAlbumInfo')}
+ style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]}
+ >
+
+
+ )}
+ {!offlineMode && (
+ onSelect('lyrics')}
+ hitSlop={8}
+ accessibilityRole="tab"
+ accessibilityState={{ selected: mode === 'lyrics' }}
+ accessibilityLabel={t('showLyrics')}
+ style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]}
+ >
+
+
+ )}
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Progress bar (subscribes to playback position) */
+/* ------------------------------------------------------------------ */
+
+const ProgressBar = memo(function ProgressBar({
+ colors,
+ handleSeek,
+}: {
+ colors: ThemeColors;
+ handleSeek: (seconds: number) => void;
+}) {
+ const position = playerStore((s) => s.position);
+ const duration = playerStore((s) => s.duration);
+ const bufferedPosition = playerStore((s) => s.bufferedPosition);
+ const playbackState = playerStore((s) => s.playbackState);
+ const error = playerStore((s) => s.error);
+ const retrying = playerStore((s) => s.retrying);
+ const isBuffering = playbackState === 'buffering' || playbackState === 'loading';
+
+ return (
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Playback controls (two rows) */
+/* ------------------------------------------------------------------ */
+
+const PlaybackControls = memo(function PlaybackControls({
+ colors,
+ shuffling,
+ handleShuffle,
+ queueLength,
+}: {
+ colors: ThemeColors;
+ shuffling: boolean;
+ handleShuffle: () => void;
+ queueLength: number;
+}) {
+ const playbackState = playerStore((s) => s.playbackState);
+ const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons);
+ const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton);
+ const { canSkipNext, canSkipPrevious } = useCanSkip();
+
+ const isPlaying = playbackState === 'playing' || playbackState === 'buffering';
+ const isBuffering = playbackState === 'buffering' || playbackState === 'loading';
+
+ return (
+ <>
+
+
+
+
+
+
+ [pressed && styles.pressed, !canSkipPrevious && styles.disabled]}
+ >
+
+
+
+ [
+ styles.playPauseButton,
+ { backgroundColor: colors.textPrimary },
+ pressed && styles.playPausePressed,
+ ]}
+ >
+ {isBuffering ? (
+
+ ) : (
+
+ )}
+
+
+ [pressed && styles.pressed, !canSkipNext && styles.disabled]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {showSleepTimer && }
+
+
+ {showSkipInterval && }
+
+
+
+ {showSkipInterval && }
+
+
+
+
+
+ >
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* Styles */
+/* ------------------------------------------------------------------ */
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ },
+ topSection: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: HERO_PADDING,
+ },
+ band: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: COLUMN_GAP,
+ },
+ bandContent: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ heroImageWrap: {
+ borderRadius: 14,
+ overflow: 'hidden',
+ backgroundColor: 'rgba(0,0,0,0.06)',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 10 },
+ shadowOpacity: 0.3,
+ shadowRadius: 18,
+ elevation: 12,
+ },
+ heroImage: {
+ width: '100%',
+ height: '100%',
+ },
+ sleepCapsuleOverlay: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ trackInfoRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ trackInfoText: {
+ flex: 1,
+ minWidth: 0,
+ },
+ trackTitle: {
+ fontSize: 24,
+ fontWeight: '700',
+ },
+ trackArtist: {
+ fontSize: 17,
+ marginTop: 4,
+ },
+ favoriteButton: {
+ paddingLeft: 12,
+ paddingVertical: 4,
+ },
+ secondaryLeftInset: {
+ paddingLeft: 4,
+ },
+ progressSection: {
+ width: '100%',
+ marginBottom: 8,
+ },
+ controls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 8,
+ width: '100%',
+ maxWidth: 420,
+ alignSelf: 'center',
+ },
+ controlSideLeft: {
+ flex: 1,
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ },
+ controlSideRight: {
+ flex: 1,
+ alignItems: 'flex-end',
+ justifyContent: 'center',
+ },
+ transportControls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ width: 248,
+ },
+ secondaryControls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: 40,
+ marginTop: 20,
+ width: '100%',
+ maxWidth: 420,
+ alignSelf: 'center',
+ },
+ secondaryCenter: {
+ width: 248,
+ },
+ secondaryCenterRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ },
+ secondaryRateSlot: {
+ width: 64,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ playPauseButton: {
+ width: 68,
+ height: 68,
+ borderRadius: 34,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ playPausePressed: {
+ opacity: 0.7,
+ },
+ playIcon: {
+ marginLeft: 3,
+ },
+ toggleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 36,
+ paddingVertical: 12,
+ },
+ toggleButton: {
+ padding: 4,
+ },
+ bottomSection: {
+ width: '100%',
+ },
+ pressed: {
+ opacity: 0.6,
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+});
diff --git a/src/screens/tuned-in.tsx b/src/screens/tuned-in.tsx
index 441d33a..9d0732c 100644
--- a/src/screens/tuned-in.tsx
+++ b/src/screens/tuned-in.tsx
@@ -11,6 +11,7 @@ import {
Text,
TextInput,
View,
+ useWindowDimensions,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import Animated, {
@@ -29,15 +30,14 @@ import { GradientBackground } from '../components/GradientBackground';
import { BottomChrome } from '../components/BottomChrome';
import { SectionTitle } from '../components/SectionTitle';
import {
- DECADES,
- fetchCustomMix,
+ SELECTABLE_DECADES,
fetchMixSongs,
generateMixes,
type MixDefinition,
} from '../services/tunedInService';
import { getOfflineSongsByGenre } from '../services/searchService';
import { playTrack } from '../services/playerService';
-import { getAlbum, type Child } from '../services/subsonicService';
+import { getAlbum } from '../services/subsonicService';
import { albumListsStore } from '../store/albumListsStore';
import { completedScrobbleStore } from '../store/completedScrobbleStore';
import { connectivityStore } from '../store/connectivityStore';
@@ -45,6 +45,8 @@ import { favoritesStore } from '../store/favoritesStore';
import { genreStore } from '../store/genreStore';
import { layoutPreferencesStore } from '../store/layoutPreferencesStore';
import { offlineModeStore } from '../store/offlineModeStore';
+import { getGridColumns } from '../hooks/useGridColumns';
+import { MAX_SELECTED_GENRES, useMixBuilder, type MixBuilder } from '../hooks/useMixBuilder';
import { useRefreshControlKey } from '../hooks/useRefreshControlKey';
import { useTheme } from '../hooks/useTheme';
import { useTransitionComplete } from '../hooks/useTransitionComplete';
@@ -54,22 +56,26 @@ import { selectionAsync } from '../utils/haptics';
import { minDelay } from '../utils/stringHelpers';
import { absoluteFill } from '../utils/styles';
-const MAX_SELECTED_GENRES = 3;
const MAX_BUILDER_GENRES = 30;
const JUMP_BACK_IN_SIZE = 150;
const JUMP_BACK_IN_IMAGE = 80;
+/** Tablet Jump Back In artwork — matches the home screen's album cover width. */
+const JUMP_BACK_IN_TABLET_IMAGE = 150;
+
+/* Tablet "For You" bento grid. Phone keeps the hero / medium-row / compact-list
+ stack; tablets (min dimension >= 600) re-flow mixes into a responsive grid so
+ cards stop stretching into thin full-width bars. */
+const SCREEN_H_PADDING = 16;
+const BENTO_GAP = 12;
+const BENTO_TILE_H = 150;
+const TABLET_MIN_DIMENSION = 600;
+/** Embedded builder goes two-column above this content width; single below. */
+const BUILDER_TWO_COL_MIN_WIDTH = 700;
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
-function isOnline(): boolean {
- const { offlineMode } = offlineModeStore.getState();
- if (offlineMode) return false;
- const { isServerReachable } = connectivityStore.getState();
- return isServerReachable;
-}
-
function genreColor(genre: string): string {
let hash = 0;
for (let i = 0; i < genre.length; i++) {
@@ -166,27 +172,30 @@ function useMixCardPlayback(mix: MixDefinition, index: number) {
const HeroMixCard = memo(function HeroMixCard({
mix,
index,
+ fillHeight = false,
}: {
mix: MixDefinition;
index: number;
+ /** Fill a fixed-height grid cell (tablet bento) instead of using minHeight. */
+ fillHeight?: boolean;
}) {
const { loading, error, handlePress, handlePressIn, handlePressOut, animatedStyle, gradientAnimatedStyle } =
useMixCardPlayback(mix, index);
return (
-
+
-
+
{/* Decorative circles */}
@@ -222,9 +231,12 @@ const HeroMixCard = memo(function HeroMixCard({
const MediumMixCard = memo(function MediumMixCard({
mix,
index,
+ fillHeight = false,
}: {
mix: MixDefinition;
index: number;
+ /** Fill a fixed-height grid cell (tablet bento) instead of using minHeight. */
+ fillHeight?: boolean;
}) {
const { loading, error, handlePress, handlePressIn, handlePressOut, animatedStyle, gradientAnimatedStyle } =
useMixCardPlayback(mix, index);
@@ -242,7 +254,7 @@ const MediumMixCard = memo(function MediumMixCard({
colors={mix.gradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
- style={styles.mediumGradient}
+ style={[styles.mediumGradient, fillHeight && styles.fillFlex]}
>
{/* Small play button top-right */}
@@ -379,12 +391,18 @@ const BuildMixButton = memo(function BuildMixButton({
const JumpBackInItem = memo(function JumpBackInItem({
album,
colors,
+ size = JUMP_BACK_IN_IMAGE,
}: {
album: { id: string; name?: string; coverArt?: string };
colors: ThemeColors;
+ /** Artwork edge length. Phone uses the compact default; the tablet grid
+ passes a larger value for a more visual layout. */
+ size?: number;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
+ // Fetch a sharper variant for the larger tablet tiles.
+ const coverFetchSize = size > 120 ? 300 : JUMP_BACK_IN_SIZE;
const handlePress = useCallback(async () => {
if (loading) return;
@@ -402,20 +420,20 @@ const JumpBackInItem = memo(function JumpBackInItem({
}, [album.id, loading]);
return (
-
+
{loading && (
-
+
)}
120 && styles.jumpTitleLarge]}
numberOfLines={1}
>
{album.name ?? t('unknownAlbum')}
@@ -553,149 +571,174 @@ const GenreSearchResult = memo(function GenreSearchResult({
});
/* ------------------------------------------------------------------ */
-/* BuildMixSheetContent */
+/* Build-a-Mix presentation (shared logic in useMixBuilder) */
/* ------------------------------------------------------------------ */
-const BuildMixSheetContent = memo(function BuildMixSheetContent({
+/** Genre search input — identical in the sheet and the panel. */
+const GenreSearchField = memo(function GenreSearchField({
colors,
- availableGenres,
+ value,
+ onChange,
}: {
colors: ThemeColors;
- availableGenres: string[];
+ value: string;
+ onChange: (q: string) => void;
}) {
const { t } = useTranslation();
- const [selectedGenres, setSelectedGenres] = useState([]);
- const [selectedDecadeIndex, setSelectedDecadeIndex] = useState(0);
- const [loading, setLoading] = useState(false);
- const [searchQuery, setSearchQuery] = useState('');
- const [addedGenres, setAddedGenres] = useState([]);
- const chipScrollRef = useRef(null);
- const online = isOnline();
-
- const serverGenres = genreStore((s) => s.genres);
+ return (
+
+
+
+ {value.length > 0 && (
+ onChange('')} hitSlop={8}>
+
+
+ )}
+
+ );
+});
- // Merge added genres (from search) to the front of the chip list
- const displayGenres = useMemo(() => {
- const availableSet = new Set(availableGenres.map((g) => g.toLowerCase()));
- const extraGenres = addedGenres.filter((g) => !availableSet.has(g.toLowerCase()));
- return [...extraGenres, ...availableGenres];
- }, [availableGenres, addedGenres]);
-
- // Filter full server genre list for search
- const searchResults = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (query.length === 0) return [];
-
- const displaySet = new Set(displayGenres.map((g) => g.toLowerCase()));
-
- return serverGenres
- .filter((g) => {
- const name = g.value.toLowerCase();
- return name.includes(query) && !displaySet.has(name);
- })
- .slice(0, 8)
- .map((g) => g.value);
- }, [searchQuery, serverGenres, displayGenres]);
-
- const handleToggleGenre = useCallback((genre: string) => {
- setSelectedGenres((prev) => {
- if (prev.includes(genre)) return prev.filter((g) => g !== genre);
- if (prev.length >= MAX_SELECTED_GENRES) return prev;
- return [...prev, genre];
- });
- }, []);
+/** Genre search-results dropdown (renders nothing when empty). */
+const GenreSearchResults = memo(function GenreSearchResults({
+ colors,
+ results,
+ onSelect,
+}: {
+ colors: ThemeColors;
+ results: string[];
+ onSelect: (genre: string) => void;
+}) {
+ if (results.length === 0) return null;
+ return (
+
+ {results.map((genre) => (
+
+ ))}
+
+ );
+});
- const handleSelectSearchResult = useCallback((genre: string) => {
- selectionAsync();
- setAddedGenres((prev) => [genre, ...prev.filter((g) => g !== genre)]);
- setSelectedGenres((prev) => {
- if (prev.includes(genre)) return prev;
- if (prev.length >= MAX_SELECTED_GENRES) return prev;
- return [genre, ...prev];
- });
- setSearchQuery('');
- chipScrollRef.current?.scrollTo({ x: 0, animated: true });
- }, []);
+/** Primary "Play Mix" action button. */
+const PlayMixButton = memo(function PlayMixButton({
+ colors,
+ loading,
+ onPress,
+}: {
+ colors: ThemeColors;
+ loading: boolean;
+ onPress: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {t('playMix')}
+ >
+ )}
+
+ );
+});
- const handleDecadePress = useCallback((index: number) => {
- selectionAsync();
- setSelectedDecadeIndex(index);
- }, []);
+/* Shared chip / pill renderers so the sheet and panel build identical controls
+ from the shared builder state. */
+function renderGenreChips(builder: MixBuilder, colors: ThemeColors) {
+ return builder.displayGenres.map((genre) => (
+
+ ));
+}
- const handlePlay = useCallback(async () => {
- if (loading) return;
- selectionAsync();
- setLoading(true);
- try {
- let songs: Child[];
- const ll = layoutPreferencesStore.getState().listLength;
- const decade = DECADES[selectedDecadeIndex];
- const hasDecade = decade.fromYear !== undefined && decade.toYear !== undefined;
- if (selectedGenres.length === 0 && !hasDecade) {
- // No selection at all — fully random "Mix It Up"
- const strategy = online
- ? { type: 'random' as const, size: ll }
- : { type: 'offline' as const };
- songs = await fetchMixSongs(strategy, ll);
- } else {
- // Era-only, genre-only, or both — fetchCustomMix handles all three.
- songs = await fetchCustomMix(
- selectedGenres,
- decade.fromYear,
- decade.toYear,
- online,
- ll,
- );
- }
- if (songs.length > 0) {
- await playTrack(songs[0], songs);
- }
- } finally {
- setLoading(false);
- }
- }, [selectedGenres, selectedDecadeIndex, loading, online]);
+function renderDecadePills(
+ builder: MixBuilder,
+ colors: ThemeColors,
+ t: (key: string) => string,
+) {
+ return SELECTABLE_DECADES.map((decade) => (
+ builder.toggleDecade(decade.label)}
+ colors={colors}
+ />
+ ));
+}
+/** "Genres" heading + selected-count, sized small (sheet) or large (panel). */
+const GenresHeading = memo(function GenresHeading({
+ builder,
+ colors,
+ large = false,
+}: {
+ builder: MixBuilder;
+ colors: ThemeColors;
+ large?: boolean;
+}) {
+ const { t } = useTranslation();
return (
-
- {/* Genre chips */}
-
- {selectedGenres.length > 0 ? t('genresWithCount', { selected: selectedGenres.length, max: MAX_SELECTED_GENRES }) : t('genres')}
-
+
+ {builder.selectedGenres.length > 0
+ ? t('genresWithCount', { selected: builder.selectedGenres.length, max: MAX_SELECTED_GENRES })
+ : t('genres')}
+
+ );
+});
- {/* Genre search input */}
-
-
-
- {searchQuery.length > 0 && (
- setSearchQuery('')} hitSlop={8}>
-
-
- )}
-
-
- {/* Search results dropdown */}
- {searchResults.length > 0 && (
-
- {searchResults.map((genre) => (
-
- ))}
-
- )}
+/* ------------------------------------------------------------------ */
+/* MixBuilderSheet — phone (bottom sheet) presentation */
+/* ------------------------------------------------------------------ */
+
+const MixBuilderSheet = memo(function MixBuilderSheet({
+ colors,
+ availableGenres,
+}: {
+ colors: ThemeColors;
+ availableGenres: string[];
+}) {
+ const { t } = useTranslation();
+ const builder = useMixBuilder(availableGenres);
+ const chipScrollRef = useRef(null);
+ const onSelectResult = useCallback(
+ (genre: string) => {
+ builder.selectSearchResult(genre);
+ chipScrollRef.current?.scrollTo({ x: 0, animated: true });
+ },
+ [builder],
+ );
+
+ return (
+
+
+
+
- {displayGenres.map((genre) => (
-
- ))}
+ {renderGenreChips(builder, colors)}
- {/* Decade selector */}
{t('decade')}
-
- {DECADES.map((decade, i) => (
- handleDecadePress(i)}
- colors={colors}
- />
- ))}
+
+ {renderDecadePills(builder, colors, t)}
- {/* Play button */}
-
+
+
+ );
+});
+
+/* ------------------------------------------------------------------ */
+/* MixBuilderPanel — embedded tablet presentation */
+/* ------------------------------------------------------------------ */
+
+const MixBuilderPanel = memo(function MixBuilderPanel({
+ colors,
+ availableGenres,
+}: {
+ colors: ThemeColors;
+ availableGenres: string[];
+}) {
+ const { t } = useTranslation();
+ const builder = useMixBuilder(availableGenres);
+ const { width } = useWindowDimensions();
+ // Genres | Decades side-by-side once there's room; stacked below that.
+ const twoColumn = width - SCREEN_H_PADDING * 2 >= BUILDER_TWO_COL_MIN_WIDTH;
+
+ const genresSection = (
+ <>
+
+
+
+ {renderGenreChips(builder, colors)}
+ >
+ );
+
+ const decadesSection = (
+ <>
+
- {loading ? (
-
- ) : (
- <>
-
- {t('playMix')}
- >
- )}
-
+ {t('decade')}
+
+ {renderDecadePills(builder, colors, t)}
+ >
+ );
-
-
+ return (
+
+ {twoColumn ? (
+
+ {genresSection}
+ {decadesSection}
+
+ ) : (
+ <>
+ {genresSection}
+ {decadesSection}
+ >
+ )}
+
+
);
});
@@ -768,6 +832,15 @@ export function TunedInScreen() {
const transitionComplete = useTransitionComplete();
const headerHeight = useContext(HeaderHeightContext) ?? 0;
const refreshControlKey = useRefreshControlKey();
+ const { width, height: screenHeight } = useWindowDimensions();
+
+ // Tablet "For You" bento: responsive columns with the lead mix spanning 2.
+ const isTablet = Math.min(width, screenHeight) >= TABLET_MIN_DIMENSION;
+ const bentoColumns = getGridColumns(width);
+ const bentoCellW = Math.floor(
+ (width - SCREEN_H_PADDING * 2 - (bentoColumns - 1) * BENTO_GAP) / bentoColumns,
+ );
+ const bentoHeroW = bentoCellW * 2 + BENTO_GAP;
const aggregates = completedScrobbleStore((s) => s.aggregates);
const completedScrobbles = completedScrobbleStore((s) => s.completedScrobbles);
@@ -893,49 +966,86 @@ export function TunedInScreen() {
{/* For You section */}
{mixes.length > 0 && (
-
-
- {/* Hero card */}
- {heroMix && }
-
- {/* Medium cards side by side */}
- {mediumMixes.length > 0 && (
-
- {mediumMixes.map((mix, i) => (
-
- ))}
-
- )}
+
+ {isTablet ? (
+ /* Tablet: responsive bento — lead mix spans 2 cells, the rest are
+ equal gradient tiles, so cards fill the width instead of
+ stretching into thin full-width bars. */
+
+ {mixes.map((mix, i) => (
+
+ {i === 0 ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ ) : (
+ /* Phone: unchanged hero / medium-row / compact-list stack. */
+
+ {heroMix && }
+
+ {mediumMixes.length > 0 && (
+
+ {mediumMixes.map((mix, i) => (
+
+ ))}
+
+ )}
- {/* Compact cards */}
- {compactMixes.map((mix, i) => (
-
- ))}
-
+ {compactMixes.map((mix, i) => (
+
+ ))}
+
+ )}
)}
- {/* Create section */}
+ {/* Create section — embedded builder on tablet, sheet trigger on phone */}
{hasBuilder && (
-
-
+
+ {isTablet ? (
+
+ ) : (
+
+ )}
)}
{/* Jump back in section */}
{showJumpBackIn && (
-
-
- {recentlyPlayed.map((album) => (
-
- ))}
-
+
+ {isTablet ? (
+ /* Tablet: a grid of larger artwork at the home-screen cover size. */
+
+ {recentlyPlayed.map((album) => (
+
+ ))}
+
+ ) : (
+ /* Phone: compact horizontal scroller. */
+
+ {recentlyPlayed.map((album) => (
+
+ ))}
+
+ )}
)}
@@ -945,7 +1055,7 @@ export function TunedInScreen() {
{/* Build a Mix bottom sheet */}
{t('buildAMix')}
-
+
@@ -969,11 +1079,52 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
section: {
- marginBottom: 24,
+ marginBottom: 36,
},
mixList: {
gap: 12,
},
+ /* Tablet bento grid — equal-gap wrap; lead tile spans 2 cells (see render). */
+ bentoGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: BENTO_GAP,
+ alignItems: 'flex-start',
+ },
+ /** Fills a fixed-height bento cell instead of relying on the card's minHeight. */
+ fillFlex: {
+ flex: 1,
+ },
+ /* Embedded (tablet) builder layout */
+ builderColumns: {
+ flexDirection: 'row',
+ gap: 28,
+ },
+ builderColGenres: {
+ flex: 3,
+ },
+ builderColDecades: {
+ flex: 2,
+ },
+ builderLabelLarge: {
+ fontSize: 17,
+ fontWeight: '700',
+ marginBottom: 12,
+ },
+ builderLabelStacked: {
+ marginTop: 20,
+ },
+ builderChipCloud: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ marginTop: 2,
+ },
+ decadeCloud: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
/* Hero card */
heroOuter: {
@@ -1217,6 +1368,16 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginTop: 6,
},
+ jumpTitleLarge: {
+ fontSize: 14,
+ marginTop: 8,
+ },
+ /* Tablet: larger artwork laid out as a grid, aligned to the For You columns. */
+ jumpGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: BENTO_GAP,
+ },
/* Sheet */
sheetTitle: {
diff --git a/src/services/__tests__/fullLibraryDownloadService.test.ts b/src/services/__tests__/fullLibraryDownloadService.test.ts
new file mode 100644
index 0000000..a92e509
--- /dev/null
+++ b/src/services/__tests__/fullLibraryDownloadService.test.ts
@@ -0,0 +1,123 @@
+let mockAlbumsState: Array<{ id: string }> = [];
+let mockPlaylistsState: Array<{ id: string }> = [];
+let mockOffline = false;
+let mockReachable = true;
+
+const mockFetchAllAlbums = jest.fn().mockResolvedValue(undefined);
+const mockFetchAllPlaylists = jest.fn().mockResolvedValue(undefined);
+
+jest.mock('../../store/albumLibraryStore', () => ({
+ albumLibraryStore: {
+ getState: () => ({ albums: mockAlbumsState, fetchAllAlbums: mockFetchAllAlbums }),
+ },
+}));
+jest.mock('../../store/playlistLibraryStore', () => ({
+ playlistLibraryStore: {
+ getState: () => ({ playlists: mockPlaylistsState, fetchAllPlaylists: mockFetchAllPlaylists }),
+ },
+}));
+jest.mock('../../store/offlineModeStore', () => ({
+ offlineModeStore: { getState: () => ({ offlineMode: mockOffline }) },
+}));
+jest.mock('../../store/connectivityStore', () => ({
+ connectivityStore: { getState: () => ({ isServerReachable: mockReachable }) },
+}));
+
+const mockEnqueueAlbum = jest.fn();
+const mockEnqueuePlaylist = jest.fn();
+jest.mock('../musicCacheService', () => ({
+ enqueueAlbumDownload: (id: string) => mockEnqueueAlbum(id),
+ enqueuePlaylistDownload: (id: string) => mockEnqueuePlaylist(id),
+}));
+
+import { enqueueFullLibraryDownload } from '../fullLibraryDownloadService';
+import { fullLibraryDownloadStore } from '../../store/fullLibraryDownloadStore';
+
+const calls: string[] = [];
+
+beforeEach(() => {
+ mockAlbumsState = [{ id: 'a1' }, { id: 'a2' }];
+ mockPlaylistsState = [{ id: 'p1' }];
+ mockOffline = false;
+ mockReachable = true;
+ calls.length = 0;
+ mockEnqueueAlbum.mockReset();
+ mockEnqueuePlaylist.mockReset();
+ mockEnqueueAlbum.mockImplementation((id: string) => {
+ calls.push(`a:${id}`);
+ return Promise.resolve();
+ });
+ mockEnqueuePlaylist.mockImplementation((id: string) => {
+ calls.push(`p:${id}`);
+ return Promise.resolve();
+ });
+ mockFetchAllAlbums.mockClear();
+ mockFetchAllPlaylists.mockClear();
+ fullLibraryDownloadStore.getState().finish();
+});
+
+describe('enqueueFullLibraryDownload', () => {
+ it('enqueues every album then every playlist (albums first)', async () => {
+ await enqueueFullLibraryDownload();
+ expect(mockFetchAllAlbums).toHaveBeenCalledTimes(1);
+ expect(mockFetchAllPlaylists).toHaveBeenCalledTimes(1);
+ expect(calls).toEqual(['a:a1', 'a:a2', 'p:p1']);
+ expect(fullLibraryDownloadStore.getState().active).toBe(false);
+ });
+
+ it('bails out when offline (no fetch, no enqueue)', async () => {
+ mockOffline = true;
+ await enqueueFullLibraryDownload();
+ expect(mockFetchAllAlbums).not.toHaveBeenCalled();
+ expect(calls).toEqual([]);
+ });
+
+ it('bails out when the server is unreachable', async () => {
+ mockReachable = false;
+ await enqueueFullLibraryDownload();
+ expect(calls).toEqual([]);
+ });
+
+ it('does nothing if a run is already active', async () => {
+ fullLibraryDownloadStore.getState().start();
+ await enqueueFullLibraryDownload();
+ expect(mockFetchAllAlbums).not.toHaveBeenCalled();
+ expect(calls).toEqual([]);
+ });
+
+ it('continues past a rejected album enqueue and reports the failure', async () => {
+ mockEnqueueAlbum.mockImplementation((id: string) => {
+ calls.push(`a:${id}`);
+ return id === 'a1' ? Promise.reject(new Error('boom')) : Promise.resolve();
+ });
+ await enqueueFullLibraryDownload();
+ expect(calls).toEqual(['a:a1', 'a:a2', 'p:p1']);
+ // One album couldn't be queued — surfaced for the card, run still idle.
+ expect(fullLibraryDownloadStore.getState().error).toBeTruthy();
+ expect(fullLibraryDownloadStore.getState().active).toBe(false);
+ });
+
+ it('sets an error and does not queue when preparing fails', async () => {
+ mockFetchAllAlbums.mockRejectedValueOnce(new Error('offline mid-prepare'));
+ await enqueueFullLibraryDownload();
+ expect(calls).toEqual([]);
+ expect(fullLibraryDownloadStore.getState().error).toBeTruthy();
+ expect(fullLibraryDownloadStore.getState().active).toBe(false);
+ });
+
+ it('leaves no error on a clean run', async () => {
+ await enqueueFullLibraryDownload();
+ expect(fullLibraryDownloadStore.getState().error).toBeNull();
+ });
+
+ it('stops adding once cancelled mid-run', async () => {
+ mockEnqueueAlbum.mockImplementation((id: string) => {
+ calls.push(`a:${id}`);
+ fullLibraryDownloadStore.getState().cancel();
+ return Promise.resolve();
+ });
+ await enqueueFullLibraryDownload();
+ // First album enqueued; cancel halts the loop before a2 / playlists.
+ expect(calls).toEqual(['a:a1']);
+ });
+});
diff --git a/src/services/__tests__/imageCacheService.queue.test.ts b/src/services/__tests__/imageCacheService.queue.test.ts
index 445697e..52d7176 100644
--- a/src/services/__tests__/imageCacheService.queue.test.ts
+++ b/src/services/__tests__/imageCacheService.queue.test.ts
@@ -54,7 +54,10 @@ jest.mock('../../store/offlineModeStore', () => ({
const mockConnectivity = { isInternetReachable: true, isServerReachable: true };
jest.mock('../../store/connectivityStore', () => ({
- connectivityStore: { getState: () => mockConnectivity },
+ connectivityStore: {
+ getState: () => mockConnectivity,
+ subscribe: jest.fn(() => () => {}),
+ },
}));
jest.mock('../connectivityService', () => ({
diff --git a/src/services/__tests__/imageCacheService.test.ts b/src/services/__tests__/imageCacheService.test.ts
index d4a4f1a..3d185a4 100644
--- a/src/services/__tests__/imageCacheService.test.ts
+++ b/src/services/__tests__/imageCacheService.test.ts
@@ -134,6 +134,7 @@ const mockConnectivity = {
jest.mock('../../store/connectivityStore', () => ({
connectivityStore: {
getState: jest.fn(() => mockConnectivity),
+ subscribe: jest.fn(() => () => {}), // no-op unsubscribe
},
}));
@@ -284,6 +285,7 @@ import {
refreshCoverArt,
reconcileImageCache,
repairIncompleteImages,
+ prefetchCoverArt,
__resetRetryStateForTest,
} from '../imageCacheService';
@@ -682,6 +684,29 @@ describe('download pipeline — cacheAllSizes + processQueue', () => {
});
});
+describe('prefetchCoverArt — keys off entity ID, not the coverArt field', () => {
+ it('warms the cache for the album id, never the server coverArt value', async () => {
+ const { getCoverArtUrl: mockGetCoverArtUrl } = jest.requireMock(
+ '../subsonicService',
+ ) as { getCoverArtUrl: jest.Mock };
+ mockGetCoverArtUrl.mockReturnValue('https://example.com/cover.jpg');
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ headers: { get: () => 'image/jpeg' },
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
+ });
+
+ // id and coverArt deliberately differ (Navidrome `al-` style).
+ prefetchCoverArt([{ id: 'al-key', coverArt: 'cover-other' } as any]);
+ await new Promise((r) => setTimeout(r, 0));
+
+ const calledIds = mockGetCoverArtUrl.mock.calls.map((c) => c[0]);
+ expect(calledIds).toContain('al-key');
+ expect(calledIds).not.toContain('cover-other');
+ mockGetCoverArtUrl.mockReturnValue(null);
+ });
+});
+
describe('downloadSourceImage — response.ok === false', () => {
it('returns null and does not create files when server returns non-ok', async () => {
const id = 'not-ok';
diff --git a/src/services/__tests__/musicCacheService.test.ts b/src/services/__tests__/musicCacheService.test.ts
index 84cd593..334ac95 100644
--- a/src/services/__tests__/musicCacheService.test.ts
+++ b/src/services/__tests__/musicCacheService.test.ts
@@ -914,7 +914,7 @@ describe('enqueueAlbumDownload', () => {
expect(queue[0].totalSongs).toBe(2);
});
- it('caches album cover + track covers', async () => {
+ it('caches album cover + track covers by album ID (not coverArt field)', async () => {
mockCheckStorageLimit.mockReturnValue(true);
mockFetchAlbum.mockResolvedValue({
id: 'album-1',
@@ -923,7 +923,10 @@ describe('enqueueAlbumDownload', () => {
song: [makeChild('t1', { coverArt: 'tc' })],
});
await enqueueAlbumDownload('album-1');
- expect(ensureCached).toHaveBeenCalledWith('ac');
+ // Cover art keys off the album ID, never the server `coverArt` field
+ // (see src/utils/coverArtId.ts).
+ expect(ensureCached).toHaveBeenCalledWith('album-1');
+ expect(ensureCached).not.toHaveBeenCalledWith('ac');
expect(prefetchCoverArt).toHaveBeenCalled();
});
diff --git a/src/services/__tests__/playerService.test.ts b/src/services/__tests__/playerService.test.ts
index 7948f58..dde0acd 100644
--- a/src/services/__tests__/playerService.test.ts
+++ b/src/services/__tests__/playerService.test.ts
@@ -345,7 +345,7 @@ describe('playTrack', () => {
expect(mockTP.skip).toHaveBeenCalledWith(1);
expect(mockTP.play).toHaveBeenCalled();
// No "Starting playback" / "Now Playing" pill — those routine
- // acknowledgements were removed; the MiniPlayer + DownloadBanner
+ // acknowledgements were removed; the mini player + DownloadBanner
// chrome is the persistent confirmation.
expect(mockToastShow).not.toHaveBeenCalled();
expect(mockToastSucceed).not.toHaveBeenCalled();
@@ -1403,7 +1403,7 @@ describe('PlaybackEndedWithReason event handler', () => {
endedHandler({ reason: 'playedUntilEnd', track: 't1', position: 150 });
- // MiniPlayer and PlayerProgressBar read the same store — this write
+ // mini player and PlayerProgressBar read the same store — this write
// ensures both show 100% when a track finishes naturally.
expect(mockSetProgress).toHaveBeenCalledWith(200, 200, 200);
});
diff --git a/src/services/__tests__/tunedInService.test.ts b/src/services/__tests__/tunedInService.test.ts
index 518136d..7fe3883 100644
--- a/src/services/__tests__/tunedInService.test.ts
+++ b/src/services/__tests__/tunedInService.test.ts
@@ -712,8 +712,8 @@ describe('fetchCustomMix', () => {
it('fetches a single genre with decade filter', async () => {
mockGetRandomSongsFiltered.mockResolvedValue(songs);
- const result = await fetchCustomMix(['Rock'], 1990, 1999, true);
- expect(result).toEqual(songs);
+ const result = await fetchCustomMix(['Rock'], [{ fromYear: 1990, toYear: 1999 }], true);
+ expect(result).toEqual(expect.arrayContaining(songs));
expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({
size: 20,
genre: 'Rock',
@@ -724,8 +724,8 @@ describe('fetchCustomMix', () => {
it('fetches a single genre without decade filter', async () => {
mockGetRandomSongsFiltered.mockResolvedValue(songs);
- const result = await fetchCustomMix(['Rock'], undefined, undefined, true);
- expect(result).toEqual(songs);
+ const result = await fetchCustomMix(['Rock'], [], true);
+ expect(result).toEqual(expect.arrayContaining(songs));
expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({
size: 20,
genre: 'Rock',
@@ -739,8 +739,8 @@ describe('fetchCustomMix', () => {
// getRandomSongsFiltered with the year window and no genre.
it('fetches with era filter only when no genres are selected', async () => {
mockGetRandomSongsFiltered.mockResolvedValue(songs);
- const result = await fetchCustomMix([], 2000, 2009, true);
- expect(result).toEqual(songs);
+ const result = await fetchCustomMix([], [{ fromYear: 2000, toYear: 2009 }], true);
+ expect(result).toEqual(expect.arrayContaining(songs));
expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(1);
expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({
size: 20,
@@ -754,27 +754,27 @@ describe('fetchCustomMix', () => {
.mockResolvedValueOnce([makeSong({ id: 'a' })])
.mockResolvedValueOnce([makeSong({ id: 'b' })]);
- const result = await fetchCustomMix(['Rock', 'Jazz'], undefined, undefined, true);
+ const result = await fetchCustomMix(['Rock', 'Jazz'], [], true);
expect(result.length).toBe(2);
expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(2);
});
it('uses offline songs when not online', async () => {
mockGetOfflineSongsByGenre.mockReturnValue(songs);
- const result = await fetchCustomMix(['Rock'], undefined, undefined, false);
+ const result = await fetchCustomMix(['Rock'], [], false);
expect(result.length).toBeLessThanOrEqual(20);
expect(mockGetOfflineSongsByGenre).toHaveBeenCalledWith('Rock');
});
it('handles null API response gracefully', async () => {
mockGetRandomSongsFiltered.mockResolvedValue(null);
- const result = await fetchCustomMix(['Rock'], undefined, undefined, true);
+ const result = await fetchCustomMix(['Rock'], [], true);
expect(result).toEqual([]);
});
it('uses custom listLength for single genre', async () => {
mockGetRandomSongsFiltered.mockResolvedValue(songs);
- await fetchCustomMix(['Rock'], 1990, 1999, true, 50);
+ await fetchCustomMix(['Rock'], [{ fromYear: 1990, toYear: 1999 }], true, 50);
expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({
size: 50,
genre: 'Rock',
@@ -785,7 +785,7 @@ describe('fetchCustomMix', () => {
it('splits custom listLength across multiple genres', async () => {
mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]);
- await fetchCustomMix(['Rock', 'Jazz', 'Pop'], undefined, undefined, true, 50);
+ await fetchCustomMix(['Rock', 'Jazz', 'Pop'], [], true, 50);
// Math.ceil(50 / 3) = 17
expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith(
expect.objectContaining({ size: 17, genre: 'Rock' }),
@@ -795,7 +795,7 @@ describe('fetchCustomMix', () => {
it('uses custom listLength for offline slice', async () => {
const manySongs = Array.from({ length: 100 }, (_, i) => makeSong({ id: `s${i}` }));
mockGetOfflineSongsByGenre.mockReturnValue(manySongs);
- const result = await fetchCustomMix(['Rock'], undefined, undefined, false, 50);
+ const result = await fetchCustomMix(['Rock'], [], false, 50);
expect(result.length).toBe(50);
});
@@ -805,9 +805,52 @@ describe('fetchCustomMix', () => {
mockGetRandomSongsFiltered
.mockResolvedValueOnce(genreA)
.mockResolvedValueOnce(genreB);
- const result = await fetchCustomMix(['Rock', 'Jazz'], undefined, undefined, true, 15);
+ const result = await fetchCustomMix(['Rock', 'Jazz'], [], true, 15);
expect(result.length).toBe(15);
});
+
+ it('queries each selected decade separately (non-contiguous eras)', async () => {
+ mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]);
+ await fetchCustomMix(
+ ['Rock'],
+ [{ fromYear: 1970, toYear: 1979 }, { fromYear: 1990, toYear: 1999 }],
+ true,
+ );
+ // 1 genre × 2 decades = 2 separate queries (a single year window can't
+ // express 70s + 90s without also pulling in the 80s).
+ expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(2);
+ expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith(
+ expect.objectContaining({ genre: 'Rock', fromYear: 1970, toYear: 1979 }),
+ );
+ expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith(
+ expect.objectContaining({ genre: 'Rock', fromYear: 1990, toYear: 1999 }),
+ );
+ });
+
+ it('fans out across the genre × decade cross-product', async () => {
+ mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]);
+ await fetchCustomMix(
+ ['Rock', 'Jazz'],
+ [{ fromYear: 1980, toYear: 1989 }, { fromYear: 2000, toYear: 2009 }],
+ true,
+ );
+ // 2 genres × 2 decades = 4 combos
+ expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(4);
+ });
+
+ it('filters offline songs by selected decades client-side', async () => {
+ mockGetOfflineSongsByGenre.mockReturnValue([
+ makeSong({ id: 'old', year: 1975 }),
+ makeSong({ id: 'mid', year: 1985 }),
+ makeSong({ id: 'new', year: 1995 }),
+ ]);
+ const result = await fetchCustomMix(
+ ['Rock'],
+ [{ fromYear: 1970, toYear: 1979 }, { fromYear: 1990, toYear: 1999 }],
+ false,
+ );
+ expect(result.map((s) => s.id).sort()).toEqual(['new', 'old']); // 1985 excluded
+ });
});
/* ------------------------------------------------------------------ */
@@ -893,16 +936,33 @@ describe('getTimeGradient', () => {
/* ------------------------------------------------------------------ */
describe('DECADES', () => {
- it('has 7 entries starting with "Any"', () => {
- expect(DECADES).toHaveLength(7);
+ it('starts with "Any" (no era filter)', () => {
expect(DECADES[0].label).toBe('Any');
expect(DECADES[0].fromYear).toBeUndefined();
expect(DECADES[0].toYear).toBeUndefined();
});
- it('each decade has a 10-year range', () => {
- for (const decade of DECADES.slice(1)) {
+ it('named decades (50s–20s) span exactly 10 years', () => {
+ for (const decade of DECADES.filter((d) => /^\d0s$/.test(d.label))) {
expect(decade.toYear! - decade.fromYear!).toBe(9);
}
});
+
+ it('includes 50s and 60s', () => {
+ const labels = DECADES.map((d) => d.label);
+ expect(labels).toContain('50s');
+ expect(labels).toContain('60s');
+ });
+
+ it('has an "Earlier" shortcut for everything before the 50s', () => {
+ const earlier = DECADES.find((d) => d.label === 'Earlier');
+ expect(earlier).toBeDefined();
+ expect(earlier!.toYear).toBe(1949);
+ });
+
+ it('has a "Recent" shortcut covering the last several years', () => {
+ const recent = DECADES.find((d) => d.label === 'Recent');
+ expect(recent).toBeDefined();
+ expect(recent!.toYear! - recent!.fromYear!).toBeGreaterThanOrEqual(4);
+ });
});
diff --git a/src/services/fullLibraryDownloadService.ts b/src/services/fullLibraryDownloadService.ts
new file mode 100644
index 0000000..54713a2
--- /dev/null
+++ b/src/services/fullLibraryDownloadService.ts
@@ -0,0 +1,87 @@
+/**
+ * "Download Full Library" — a one-shot that queues every album then every
+ * playlist for offline download, reusing the standard download queue (which
+ * already handles concurrency, dedup, status, storage limits, retries, resume).
+ *
+ * Light by design: refresh the album/playlist lists, then loop-enqueue. Each
+ * `enqueueAlbumDownload` fetches the album's song list and dedups, so per-album
+ * metadata freshens inline and already-cached albums are skipped with no
+ * transfer. Playlists go last — their songs are usually already on disk from the
+ * albums, so they complete quickly. Re-running is safe (idempotent).
+ */
+
+import i18n from '../i18n/i18n';
+import { albumLibraryStore } from '../store/albumLibraryStore';
+import { connectivityStore } from '../store/connectivityStore';
+import { fullLibraryDownloadStore } from '../store/fullLibraryDownloadStore';
+import { offlineModeStore } from '../store/offlineModeStore';
+import { playlistLibraryStore } from '../store/playlistLibraryStore';
+import { enqueueAlbumDownload, enqueuePlaylistDownload } from './musicCacheService';
+
+/** True when the server is reachable and the user isn't in offline mode. */
+export function canDownloadFullLibrary(): boolean {
+ return (
+ !offlineModeStore.getState().offlineMode &&
+ connectivityStore.getState().isServerReachable
+ );
+}
+
+/**
+ * Refresh the library lists, then enqueue every album followed by every
+ * playlist. Fire-and-forget from the UI — it awaits internally and reports
+ * progress through `fullLibraryDownloadStore`. No-op if already running or
+ * offline.
+ */
+export async function enqueueFullLibraryDownload(): Promise {
+ const store = fullLibraryDownloadStore.getState();
+ if (store.active) return;
+ if (!canDownloadFullLibrary()) return;
+
+ store.start();
+ let failed = 0;
+ let total = 0;
+ try {
+ // Phase 1 — make sure we have the complete, current library lists so we
+ // don't miss anything added since the last sync. A failure here (e.g. the
+ // connection drops) aborts before queueing and is surfaced to the user.
+ fullLibraryDownloadStore.getState().setPhase('preparing');
+ await albumLibraryStore.getState().fetchAllAlbums();
+ await playlistLibraryStore.getState().fetchAllPlaylists();
+
+ const albums = albumLibraryStore.getState().albums;
+ const playlists = playlistLibraryStore.getState().playlists;
+ total = albums.length + playlists.length;
+ fullLibraryDownloadStore.getState().setTotals(albums.length, playlists.length);
+
+ // Phase 2 — enqueue. Sequential awaits keep a single album-detail fetch in
+ // flight at a time (avoids hundreds of concurrent getAlbum calls) and yield
+ // to keep the UI responsive. The queue starts draining after the first item.
+ // A single failed item is tolerated and counted; we report the tally at the
+ // end so a partial outage doesn't silently drop part of the library.
+ fullLibraryDownloadStore.getState().setPhase('queueing');
+
+ for (const album of albums) {
+ if (!fullLibraryDownloadStore.getState().active) return; // cancelled
+ await enqueueAlbumDownload(album.id).catch(() => { failed += 1; });
+ fullLibraryDownloadStore.getState().incAlbum();
+ }
+
+ // Playlists last — their songs are mostly already cached from the albums.
+ for (const playlist of playlists) {
+ if (!fullLibraryDownloadStore.getState().active) return; // cancelled
+ await enqueuePlaylistDownload(playlist.id).catch(() => { failed += 1; });
+ fullLibraryDownloadStore.getState().incPlaylist();
+ }
+
+ if (failed > 0) {
+ fullLibraryDownloadStore.getState().fail(
+ i18n.t('downloadFullLibraryPartial', { failed, total }),
+ );
+ }
+ } catch {
+ // Preparing failed (or an unexpected error) — couldn't queue the library.
+ fullLibraryDownloadStore.getState().fail(i18n.t('downloadFullLibraryFailed'));
+ } finally {
+ fullLibraryDownloadStore.getState().finish();
+ }
+}
diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts
index 3cdafd0..fbfda1f 100644
--- a/src/services/imageCacheService.ts
+++ b/src/services/imageCacheService.ts
@@ -71,7 +71,12 @@ import { logImageCache } from './imageCacheLogger';
import {
ensureCoverArtAuth,
getCoverArtUrl,
+ type AlbumID3,
+ type ArtistID3,
+ type Child,
+ type Playlist,
} from './subsonicService';
+import { coverArtIdForEntity } from '../utils/coverArtId';
// Sentinel cover-art IDs rendered from bundled assets via
// `CachedImage.tsx`, never downloaded. Inlined here (not imported)
@@ -222,6 +227,27 @@ function notifyImageCacheUpdate(coverArtId: string): void {
}
}
+/**
+ * Drop ALL remote-failed markers and notify every affected CachedImage so it
+ * re-derives its URI on the next render. Used by the coarse recovery paths
+ * (offline→online toggle, app foreground, server-reachable-again) so a cover
+ * that hit a transient remote error self-heals WITHOUT an app restart — even
+ * when `offlineMode` never flipped (a brief server blip while online).
+ */
+function clearFailedRemoteIds(reason: string): void {
+ if (failedRemoteIds.size === 0) return;
+ const ids = Array.from(failedRemoteIds);
+ failedRemoteIds.clear();
+ logImageCache(`clearFailedRemoteIds reason=${reason} count=${ids.length}`);
+ for (const id of ids) {
+ const listeners = cacheUpdateListeners.get(id);
+ if (!listeners) continue;
+ for (const listener of listeners) {
+ try { listener(); } catch { /* swallow */ }
+ }
+ }
+}
+
function uriCacheKey(coverArtId: string, size: number): string {
return `${coverArtId}:${size}`;
}
@@ -372,6 +398,11 @@ export function initImageCache(): void {
if (offlineModeStore.getState().offlineMode) return;
await awaitFirstPing();
if (offlineModeStore.getState().offlineMode) return;
+ // Foreground recovery: a remote load that failed while the app
+ // was backgrounded (or during a transient blip) stays in
+ // failedRemoteIds until something clears it. The offline→online
+ // toggle didn't fire if offlineMode never flipped, so clear here.
+ clearFailedRemoteIds('appstate-active');
await repairIncompleteImages('appstate-active');
})(),
'imageCache.appStateActive',
@@ -481,20 +512,8 @@ offlineModeStore.subscribe((state, prev) => {
if (state.offlineMode) return;
// Coming back online: drop every remote-failed marker so CachedImage
// instances get a fresh shot at the server URL while the repair pass
- // works in the background. We notify the listener set per id so any
- // mounted CachedImages re-derive immediately rather than waiting for
- // the next render.
- if (failedRemoteIds.size > 0) {
- const ids = Array.from(failedRemoteIds);
- failedRemoteIds.clear();
- for (const id of ids) {
- const listeners = cacheUpdateListeners.get(id);
- if (!listeners) continue;
- for (const listener of listeners) {
- try { listener(); } catch { /* swallow */ }
- }
- }
- }
+ // works in the background.
+ clearFailedRemoteIds('offline-online');
if (imageCacheStore.getState().incompleteCount <= 0) return;
// _layout.tsx restarts connectivity monitoring on offline→online; wait
// for the first post-resume ping so the repair pass acts on confirmed
@@ -509,6 +528,18 @@ offlineModeStore.subscribe((state, prev) => {
);
});
+// In-foreground transient blips: the connectivity layer flips
+// `isServerReachable` without `offlineMode` ever changing (a brief server
+// outage while the app stays foregrounded and online). When the server comes
+// back, drop remote-failed markers so covers recover without a restart or an
+// offline toggle — the user's "should recover when the server is available
+// again" requirement.
+connectivityStore.subscribe((state, prev) => {
+ if (!state.isServerReachable || prev.isServerReachable) return;
+ if (offlineModeStore.getState().offlineMode) return;
+ clearFailedRemoteIds('server-reachable');
+});
+
/**
* Heal drift between the `cached_images` table and the on-disk layout.
*
@@ -1677,16 +1708,22 @@ export async function clearImageCache(
}
/**
- * Proactively cache cover art for a list of entities (songs, albums, etc.).
- * Deduplicates by coverArt ID and skips entries already in cache.
+ * Proactively cache cover art for a list of entities (songs, albums,
+ * artists, playlists). Keys off the canonical entity ID via
+ * `coverArtIdForEntity` (NOT the server `coverArt` field) so the warmed
+ * file matches what the render side reads. Deduplicates by resolved ID
+ * and skips entries already in cache.
*/
-export function prefetchCoverArt(entities: Array<{ coverArt?: string }>): void {
+export function prefetchCoverArt(
+ entities: Array,
+): void {
const seen = new Set();
for (const entity of entities) {
- if (entity.coverArt && !seen.has(entity.coverArt)) {
- seen.add(entity.coverArt);
- if (!getCachedImageUri(entity.coverArt, 300)) {
- cacheAllSizes(entity.coverArt).catch(() => { /* non-critical */ });
+ const id = coverArtIdForEntity(entity);
+ if (id && !seen.has(id)) {
+ seen.add(id);
+ if (!getCachedImageUri(id, 300)) {
+ cacheAllSizes(id).catch(() => { /* non-critical */ });
}
}
}
diff --git a/src/services/musicCacheService.ts b/src/services/musicCacheService.ts
index 0ec0247..0224cde 100644
--- a/src/services/musicCacheService.ts
+++ b/src/services/musicCacheService.ts
@@ -58,6 +58,11 @@ import {
ensureCached,
prefetchCoverArt,
} from './imageCacheService';
+import {
+ coverArtIdForAlbum,
+ coverArtIdForPlaylist,
+ coverArtIdForSong,
+} from '../utils/coverArtId';
/* ------------------------------------------------------------------ */
/* Constants */
@@ -755,9 +760,10 @@ export async function enqueueAlbumDownload(albumId: string): Promise {
});
}
- if (album.coverArt) {
- ensureCached(album.coverArt).catch(() => { /* non-critical */ });
- }
+ // Cover art keys off the album ID, never the server `coverArt` field
+ // (see src/utils/coverArtId.ts) — so the warmed/stored key matches what
+ // the grid renders. The raw `coverArt` is retained in the song envelopes.
+ ensureCached(albumId).catch(() => { /* non-critical */ });
cacheTrackCoverArt(missingSongs);
musicCacheStore.getState().enqueueTopUp({
@@ -765,7 +771,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise {
type: 'album',
name: album.name,
artist: album.artist ?? album.displayArtist,
- coverArtId: album.coverArt,
+ coverArtId: albumId,
totalSongs: missingSongs.length,
songsJson: JSON.stringify(missingSongs),
});
@@ -774,9 +780,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise {
return;
}
- if (album.coverArt) {
- ensureCached(album.coverArt).catch(() => { /* non-critical */ });
- }
+ ensureCached(albumId).catch(() => { /* non-critical */ });
cacheTrackCoverArt(album.song);
musicCacheStore.getState().enqueue({
@@ -784,7 +788,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise {
type: 'album',
name: album.name,
artist: album.artist ?? album.displayArtist,
- coverArtId: album.coverArt,
+ coverArtId: albumId,
totalSongs: album.song.length,
songsJson: JSON.stringify(album.song),
});
@@ -802,16 +806,15 @@ export async function enqueuePlaylistDownload(playlistId: string): Promise
const playlist = await playlistDetailStore.getState().fetchPlaylist(playlistId);
if (!playlist?.entry?.length) return;
- if (playlist.coverArt) {
- ensureCached(playlist.coverArt).catch(() => { /* non-critical */ });
- }
+ // Cover art keys off the playlist ID (see src/utils/coverArtId.ts).
+ ensureCached(playlistId).catch(() => { /* non-critical */ });
cacheTrackCoverArt(playlist.entry);
musicCacheStore.getState().enqueue({
itemId: playlistId,
type: 'playlist',
name: playlist.name,
- coverArtId: playlist.coverArt,
+ coverArtId: playlistId,
totalSongs: playlist.entry.length,
songsJson: JSON.stringify(playlist.entry),
});
@@ -854,7 +857,7 @@ export async function enqueueSongDownload(song: Child): Promise {
type: 'song',
name: song.title ?? existing.title,
artist: song.artist ?? existing.artist,
- coverArtId: song.coverArt ?? existing.coverArt,
+ coverArtId: coverArtIdForSong(song),
expectedSongCount: 1,
parentAlbumId: song.albumId ?? existing.albumId,
lastSyncAt: Date.now(),
@@ -875,7 +878,7 @@ export async function enqueueSongDownload(song: Child): Promise {
type: 'song',
name: song.title ?? 'Unknown',
artist: song.artist,
- coverArtId: song.coverArt,
+ coverArtId: coverArtIdForSong(song),
totalSongs: 1,
songsJson: JSON.stringify([song]),
});
@@ -1053,7 +1056,8 @@ async function ensurePartialAlbumEdge(
type: 'album',
name: song.album ?? cachedAlbum?.album?.name ?? 'Unknown',
artist: song.artist ?? cachedAlbum?.album?.artist,
- coverArtId: song.coverArt ?? cachedAlbum?.album?.coverArt,
+ // Album item — cover art keys off the album ID (see coverArtId.ts).
+ coverArtId: albumId,
expectedSongCount,
parentAlbumId: undefined,
lastSyncAt: now,
diff --git a/src/services/playerHelpers.ts b/src/services/playerHelpers.ts
index 4ad3c8d..c00aa7a 100644
--- a/src/services/playerHelpers.ts
+++ b/src/services/playerHelpers.ts
@@ -14,6 +14,7 @@ import { offlineModeStore } from '../store/offlineModeStore';
import { playbackSettingsStore, type RepeatModeSetting } from '../store/playbackSettingsStore';
import { type PlaybackStatus } from '../store/playerStore';
import { resolveEffectiveFormat } from '../utils/effectiveFormat';
+import { coverArtIdForSong } from '../utils/coverArtId';
import { getCachedImageUri } from './imageCacheService';
import { getLocalTrackUri } from './musicCacheService';
import { getCoverArtUrl, getStreamUrl, type Child } from './subsonicService';
@@ -116,9 +117,10 @@ export function childToTrack(child: Child): Track | null {
// Cover-art lookup keys off the parent album's ID (see
// src/utils/coverArtId.ts) so every track in an album shares one
- // cached file — fixes the MiniPlayer / lock-screen placeholder
+ // cached file — fixes the mini player / lock-screen placeholder
// problem caused by Navidrome-style per-track coverArt variants.
- const coverArtId = child.albumId ?? child.id;
+ // `child.id` is always present, so the result is a defined string.
+ const coverArtId = coverArtIdForSong(child) ?? child.id;
const cachedArt = getCachedImageUri(coverArtId, 600);
const contentType = localUri ? mimeFromUri(localUri) : undefined;
// In offline mode drop any server-only artwork so RNTP's lock-screen
diff --git a/src/services/playerService.ts b/src/services/playerService.ts
index 2a8ca44..9b87be5 100644
--- a/src/services/playerService.ts
+++ b/src/services/playerService.ts
@@ -526,7 +526,7 @@ export async function initPlayer(): Promise {
// finishes (polling cadence), so without this write the store can be
// left at e.g. 150/200 when the track ends — a visible ~75% progress
// bar instead of a full one. Using the Subsonic metadata duration
- // (authoritative) keeps MiniPlayer and PlayerProgressBar in lockstep.
+ // (authoritative) keeps mini player and PlayerProgressBar in lockstep.
const endDuration = trackThatEnded.duration ?? 0;
if (endDuration > 0) {
playerStore.getState().setProgress(endDuration, endDuration, endDuration);
@@ -669,7 +669,7 @@ export async function initPlayer(): Promise {
// --- Restore persisted queue from previous session ---
//
// restorePersistedQueue() populates the Zustand store synchronously so the
- // MiniPlayer can render immediately. If it returns true, a previously
+ // mini player can render immediately. If it returns true, a previously
// active queue exists; kick off the async hydration sequence which loads
// tracks into RNTP in a muted, paused, seek-positioned state so the first
// user tap plays without negotiating any native-layer uncertainty.
@@ -735,7 +735,7 @@ async function syncStoreFromNative(): Promise {
* Restore the persisted queue from a previous session.
*
* Called once during initPlayer(). Populates the Zustand store and
- * module-level currentChildQueue so the MiniPlayer renders immediately.
+ * module-level currentChildQueue so the mini player renders immediately.
* Does NOT touch RNTP — that's the job of the async hydrateRestoredQueue()
* that initPlayer() kicks off next.
*
@@ -829,7 +829,7 @@ async function hydrateRestoredQueue(): Promise {
if (rnTracks.length === 0) {
// Nothing in the restored queue is playable right now (offline + no
// cached files). Surface a toast and clear the stale queue so the
- // MiniPlayer doesn't linger on an unplayable track. Call the
+ // mini player doesn't linger on an unplayable track. Call the
// internal helper — clearQueue() would deadlock awaiting us.
playbackToastStore.getState().fail(i18n.t('noOfflineTracksInQueue'));
await clearPlayerStateInternal();
@@ -1257,7 +1257,7 @@ async function clearPlayerStateInternal(): Promise {
* Stop playback, clear the queue, and reset all player state to defaults.
*
* Resets both the native RNTP player and the Zustand store so the UI
- * returns to its idle state (MiniPlayer hidden, no current track).
+ * returns to its idle state (mini player hidden, no current track).
*/
export async function clearQueue(): Promise {
// Wait for any in-flight cold-start hydration so its mid-sequence
diff --git a/src/services/tunedInService.ts b/src/services/tunedInService.ts
index 23eb637..e882c66 100644
--- a/src/services/tunedInService.ts
+++ b/src/services/tunedInService.ts
@@ -467,55 +467,69 @@ export async function fetchMixSongs(strategy: FetchStrategy, listLength = 20): P
/* Custom mix builder */
/* ------------------------------------------------------------------ */
+/** A selected era. Both bounds undefined means "any era". */
+export interface DecadeRange {
+ fromYear?: number;
+ toYear?: number;
+}
+
export async function fetchCustomMix(
genres: string[],
- fromYear?: number,
- toYear?: number,
+ decades: DecadeRange[],
isOnline = true,
listLength = 20,
): Promise {
+ // Only ranges with real bounds constrain the era; ignore "any" entries.
+ const ranges = decades.filter(
+ (d) => d.fromYear !== undefined && d.toYear !== undefined,
+ );
+
if (!isOnline) {
- const results: Child[] = [];
- for (const genre of genres) {
- const songs = getOfflineSongsByGenre(genre);
- results.push(...songs);
+ let pool: Child[] = [];
+ if (genres.length === 0) {
+ pool = [...getOfflineSongsAll()];
+ } else {
+ for (const genre of genres) pool.push(...getOfflineSongsByGenre(genre));
+ }
+ // Decades can be non-contiguous (e.g. 70s + 90s), so filter the offline
+ // pool client-side against any selected range rather than a single window.
+ if (ranges.length > 0) {
+ pool = pool.filter(
+ (s) =>
+ s.year != null &&
+ ranges.some((r) => s.year! >= r.fromYear! && s.year! <= r.toYear!),
+ );
}
- return shuffleArray(results).slice(0, listLength);
+ return shuffleArray(pool).slice(0, listLength);
}
- // Era-only (no genre): one Subsonic call with the year window.
- if (genres.length === 0) {
- const songs = await getRandomSongsFiltered({
- size: listLength,
- fromYear,
- toYear,
- });
- return songs ?? [];
- }
+ // Online: fan out across the genre × era cross-product. Each axis falls
+ // back to a single "any" slot so genre-only, era-only, and both work the
+ // same way. Non-contiguous decades each get their own server query.
+ const genreSlots: (string | undefined)[] = genres.length > 0 ? genres : [undefined];
+ const eraSlots: DecadeRange[] = ranges.length > 0 ? ranges : [{}];
- if (genres.length === 1) {
- const songs = await getRandomSongsFiltered({
- size: listLength,
- genre: genres[0],
- fromYear,
- toYear,
- });
- return songs ?? [];
+ const combos: Array<{ genre?: string } & DecadeRange> = [];
+ for (const genre of genreSlots) {
+ for (const era of eraSlots) {
+ combos.push({ genre, fromYear: era.fromYear, toYear: era.toYear });
+ }
}
- // Multiple genres: split evenly
- const perGenre = Math.ceil(listLength / genres.length);
- const results: Child[] = [];
- for (const genre of genres) {
- const songs = await getRandomSongsFiltered({
- size: perGenre,
- genre,
- fromYear,
- toYear,
- });
- if (songs) results.push(...songs);
- }
- return shuffleArray(results).slice(0, listLength);
+ const perCombo = Math.max(1, Math.ceil(listLength / combos.length));
+ const batches = await Promise.all(
+ combos.map((c) =>
+ getRandomSongsFiltered({
+ size: perCombo,
+ genre: c.genre,
+ fromYear: c.fromYear,
+ toYear: c.toYear,
+ })
+ .then((songs) => songs ?? [])
+ .catch(() => []),
+ ),
+ );
+ return shuffleArray(batches.flat()).slice(0, listLength);
}
/* ------------------------------------------------------------------ */
@@ -543,12 +557,44 @@ export function getTimeGradient(hour: number): [string, string] {
/* Decade definitions for the builder */
/* ------------------------------------------------------------------ */
-export const DECADES = [
+export interface BuilderDecade {
+ /** Stable identity + default display string. */
+ label: string;
+ /** i18n key for word labels (Earlier/Recent); numeric decades render `label`. */
+ i18nKey?: string;
+ /** Both undefined means "any era". */
+ fromYear?: number;
+ toYear?: number;
+}
+
+// "Recent" tracks the rolling last ~5 years, so its range is resolved at module
+// load from the current year rather than hard-coded.
+const CURRENT_YEAR = new Date().getFullYear();
+
+export const DECADES: BuilderDecade[] = [
{ label: 'Any', fromYear: undefined, toYear: undefined },
+ { label: 'Earlier', i18nKey: 'decadeEarlier', fromYear: 0, toYear: 1949 },
+ { label: '50s', fromYear: 1950, toYear: 1959 },
+ { label: '60s', fromYear: 1960, toYear: 1969 },
{ label: '70s', fromYear: 1970, toYear: 1979 },
{ label: '80s', fromYear: 1980, toYear: 1989 },
{ label: '90s', fromYear: 1990, toYear: 1999 },
{ label: '00s', fromYear: 2000, toYear: 2009 },
{ label: '10s', fromYear: 2010, toYear: 2019 },
{ label: '20s', fromYear: 2020, toYear: 2029 },
-] as const;
+ { label: 'Recent', i18nKey: 'decadeRecent', fromYear: CURRENT_YEAR - 4, toYear: CURRENT_YEAR + 1 },
+];
+
+/** Decades the builder offers as multi-select pills — drops the "Any" sentinel
+ (no decades selected already means "any era", mirroring genres). */
+export const SELECTABLE_DECADES: BuilderDecade[] = DECADES.filter(
+ (d) => d.fromYear !== undefined,
+);
+
+/** Map selected decade labels to the year ranges `fetchCustomMix` expects. */
+export function decadeRangesForLabels(labels: string[]): DecadeRange[] {
+ return SELECTABLE_DECADES.filter((d) => labels.includes(d.label)).map((d) => ({
+ fromYear: d.fromYear,
+ toYear: d.toYear,
+ }));
+}
diff --git a/src/store/__tests__/moreOptionsStore.test.ts b/src/store/__tests__/moreOptionsStore.test.ts
index b6a2af2..4b747fc 100644
--- a/src/store/__tests__/moreOptionsStore.test.ts
+++ b/src/store/__tests__/moreOptionsStore.test.ts
@@ -19,22 +19,22 @@ describe('moreOptionsStore', () => {
});
it('show with explicit source sets source', () => {
- moreOptionsStore.getState().show({ type: 'album', item: mockAlbum }, 'player');
- expect(moreOptionsStore.getState().source).toBe('player');
+ moreOptionsStore.getState().show({ type: 'album', item: mockAlbum }, 'player-phone-portrait');
+ expect(moreOptionsStore.getState().source).toBe('player-phone-portrait');
});
it('show with playerpanel source sets source', () => {
- moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'playerpanel');
- expect(moreOptionsStore.getState().source).toBe('playerpanel');
+ moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-tablet-splitview');
+ expect(moreOptionsStore.getState().source).toBe('player-tablet-splitview');
});
it('show with playerexpanded source sets source', () => {
- moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'playerexpanded');
- expect(moreOptionsStore.getState().source).toBe('playerexpanded');
+ moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-tablet-landscape');
+ expect(moreOptionsStore.getState().source).toBe('player-tablet-landscape');
});
it('hide resets all fields including source', () => {
- moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player');
+ moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-phone-portrait');
moreOptionsStore.getState().hide();
const state = moreOptionsStore.getState();
expect(state.visible).toBe(false);
@@ -48,7 +48,7 @@ describe('moreOptionsStore', () => {
// chained modal mounts AFTER the sheet's native Modal is fully gone.
describe('hideAndAwait + _signalCloseComplete', () => {
it('hideAndAwait resets state and waits for the signal to resolve', async () => {
- moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player');
+ moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-phone-portrait');
const promise = moreOptionsStore.getState().hideAndAwait();
// State is already cleared synchronously
diff --git a/src/store/albumDetailStore.ts b/src/store/albumDetailStore.ts
index 47682c2..44bc153 100644
--- a/src/store/albumDetailStore.ts
+++ b/src/store/albumDetailStore.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { ensureCached, prefetchCoverArt } from '../services/imageCacheService';
+import { coverArtIdForAlbum } from '../utils/coverArtId';
import {
ensureCoverArtAuth,
getAlbum,
@@ -86,7 +87,8 @@ export const albumDetailStore = create()((set, get) => ({
// Proactively cache cover art for new IDs so they survive offline.
// Skipped during bulk sync — see prefetchCovers contract above.
if (prefetchCovers) {
- if (data.coverArt) ensureCached(data.coverArt).catch(() => { /* non-critical */ });
+ const albumArtId = coverArtIdForAlbum(data);
+ if (albumArtId) ensureCached(albumArtId).catch(() => { /* non-critical */ });
if (data.song?.length) prefetchCoverArt(data.song);
}
}
diff --git a/src/store/favoritesStore.ts b/src/store/favoritesStore.ts
index c710fb1..66d4b53 100644
--- a/src/store/favoritesStore.ts
+++ b/src/store/favoritesStore.ts
@@ -6,6 +6,7 @@ import i18n from '../i18n/i18n';
import { kvStorage } from './persistence';
import { ensureCached, prefetchCoverArt } from '../services/imageCacheService';
+import { coverArtIdForAlbum, coverArtIdForArtist } from '../utils/coverArtId';
import {
ensureCoverArtAuth,
getStarred2,
@@ -94,10 +95,12 @@ export const favoritesStore = create()(
if (prefetchCovers) {
prefetchCoverArt(songs);
for (const a of albums) {
- if (a.coverArt) ensureCached(a.coverArt).catch(() => { /* non-critical */ });
+ const albumArtId = coverArtIdForAlbum(a);
+ if (albumArtId) ensureCached(albumArtId).catch(() => { /* non-critical */ });
}
for (const a of artists) {
- if (a.coverArt) ensureCached(a.coverArt).catch(() => { /* non-critical */ });
+ const artistArtId = coverArtIdForArtist(a);
+ if (artistArtId) ensureCached(artistArtId).catch(() => { /* non-critical */ });
}
}
} catch (e) {
diff --git a/src/store/fullLibraryDownloadStore.ts b/src/store/fullLibraryDownloadStore.ts
new file mode 100644
index 0000000..ad4ea8a
--- /dev/null
+++ b/src/store/fullLibraryDownloadStore.ts
@@ -0,0 +1,60 @@
+import { create } from 'zustand';
+
+/**
+ * Transient progress state for the "Download Full Library" one-shot. Tracks the
+ * prepare → queueing phases and how many albums/playlists have been added to the
+ * download queue so the settings card can show live progress + a Stop control.
+ *
+ * Deliberately NOT persisted: the action is idempotent (enqueue dedups), so a
+ * restart mid-run just means the user taps it again; the already-queued items
+ * keep downloading via the normal queue regardless.
+ */
+export type FullLibraryDownloadPhase = 'preparing' | 'queueing' | null;
+
+interface FullLibraryDownloadState {
+ active: boolean;
+ phase: FullLibraryDownloadPhase;
+ albumsTotal: number;
+ albumsQueued: number;
+ playlistsTotal: number;
+ playlistsQueued: number;
+ /** User-facing failure message from the last run, surfaced by the card. */
+ error: string | null;
+
+ start: () => void;
+ setPhase: (phase: FullLibraryDownloadPhase) => void;
+ setTotals: (albumsTotal: number, playlistsTotal: number) => void;
+ incAlbum: () => void;
+ incPlaylist: () => void;
+ /** Record a failure for the card to surface; doesn't stop the run. */
+ fail: (error: string) => void;
+ clearError: () => void;
+ /** Request the run stop adding more items (in-flight downloads continue). */
+ cancel: () => void;
+ /** Clear run progress (preserves `error` for the card to surface). */
+ finish: () => void;
+}
+
+const RUN_IDLE = {
+ active: false,
+ phase: null as FullLibraryDownloadPhase,
+ albumsTotal: 0,
+ albumsQueued: 0,
+ playlistsTotal: 0,
+ playlistsQueued: 0,
+};
+
+export const fullLibraryDownloadStore = create()((set) => ({
+ ...RUN_IDLE,
+ error: null,
+
+ start: () => set({ ...RUN_IDLE, error: null, active: true, phase: 'preparing' }),
+ setPhase: (phase) => set({ phase }),
+ setTotals: (albumsTotal, playlistsTotal) => set({ albumsTotal, playlistsTotal }),
+ incAlbum: () => set((s) => ({ albumsQueued: s.albumsQueued + 1 })),
+ incPlaylist: () => set((s) => ({ playlistsQueued: s.playlistsQueued + 1 })),
+ fail: (error) => set({ error }),
+ clearError: () => set({ error: null }),
+ cancel: () => set({ active: false }),
+ finish: () => set({ ...RUN_IDLE }),
+}));
diff --git a/src/store/moreOptionsStore.ts b/src/store/moreOptionsStore.ts
index 9162c62..13810f4 100644
--- a/src/store/moreOptionsStore.ts
+++ b/src/store/moreOptionsStore.ts
@@ -24,7 +24,12 @@ export type MoreOptionsEntity =
/* Store */
/* ------------------------------------------------------------------ */
-export type MoreOptionsSource = 'default' | 'player' | 'playerpanel' | 'playerexpanded';
+export type MoreOptionsSource =
+ | 'default'
+ | 'player-phone-portrait'
+ | 'player-tablet-portrait'
+ | 'player-tablet-splitview'
+ | 'player-tablet-landscape';
export interface MoreOptionsState {
visible: boolean;
diff --git a/src/store/playlistDetailStore.ts b/src/store/playlistDetailStore.ts
index ff42d99..68d13b0 100644
--- a/src/store/playlistDetailStore.ts
+++ b/src/store/playlistDetailStore.ts
@@ -4,6 +4,7 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { kvStorage } from './persistence';
import { ensureCached, prefetchCoverArt } from '../services/imageCacheService';
+import { coverArtIdForPlaylist } from '../utils/coverArtId';
import {
ensureCoverArtAuth,
getPlaylist,
@@ -67,7 +68,8 @@ export const playlistDetailStore = create()(
// Proactively cache cover art for new IDs so they survive offline.
// Skipped during bulk sync — see prefetchCovers contract above.
if (prefetchCovers) {
- if (data.coverArt) ensureCached(data.coverArt).catch(() => { /* non-critical */ });
+ const playlistArtId = coverArtIdForPlaylist(data);
+ if (playlistArtId) ensureCached(playlistArtId).catch(() => { /* non-critical */ });
if (data.entry?.length) prefetchCoverArt(data.entry);
}
}
diff --git a/src/utils/__tests__/coverArtId.test.ts b/src/utils/__tests__/coverArtId.test.ts
new file mode 100644
index 0000000..99742ad
--- /dev/null
+++ b/src/utils/__tests__/coverArtId.test.ts
@@ -0,0 +1,66 @@
+import {
+ coverArtIdForAlbum,
+ coverArtIdForArtist,
+ coverArtIdForEntity,
+ coverArtIdForPlaylist,
+ coverArtIdForSong,
+} from '../coverArtId';
+import {
+ type AlbumID3,
+ type ArtistID3,
+ type Child,
+ type Playlist,
+} from '../../services/subsonicService';
+
+/**
+ * The single rule: cover-art keys off the entity ID, NEVER the server
+ * `coverArt` field. These tests pin that — every helper must ignore a
+ * (deliberately different) `coverArt` value and return the ID.
+ */
+describe('coverArtId helpers', () => {
+ it('coverArtIdForAlbum returns the album id, ignoring coverArt', () => {
+ const album = { id: 'al-1', coverArt: 'cover-xyz' } as AlbumID3;
+ expect(coverArtIdForAlbum(album)).toBe('al-1');
+ });
+
+ it('coverArtIdForArtist returns the artist id, ignoring coverArt', () => {
+ const artist = { id: 'ar-1', coverArt: 'cover-xyz' } as ArtistID3;
+ expect(coverArtIdForArtist(artist)).toBe('ar-1');
+ });
+
+ it('coverArtIdForPlaylist returns the playlist id, ignoring coverArt', () => {
+ const playlist = { id: 'pl-1', coverArt: 'cover-xyz' } as Playlist;
+ expect(coverArtIdForPlaylist(playlist)).toBe('pl-1');
+ });
+
+ it('coverArtIdForSong returns the parent albumId, ignoring coverArt', () => {
+ const song = { id: 's-1', albumId: 'al-1', coverArt: 'mf-9' } as Child;
+ expect(coverArtIdForSong(song)).toBe('al-1');
+ });
+
+ it('coverArtIdForSong falls back to the song id when no albumId (orphan)', () => {
+ const song = { id: 's-1', coverArt: 'mf-9' } as Child;
+ expect(coverArtIdForSong(song)).toBe('s-1');
+ });
+
+ it('returns undefined when the entity has no usable id', () => {
+ expect(coverArtIdForAlbum({ coverArt: 'c' } as AlbumID3)).toBeUndefined();
+ expect(coverArtIdForSong({ coverArt: 'c' } as unknown as Child)).toBeUndefined();
+ });
+
+ describe('coverArtIdForEntity dispatch', () => {
+ it('treats an entity with albumId as a song (albumId wins)', () => {
+ const song = { id: 's-1', albumId: 'al-1', coverArt: 'mf-9' } as Child;
+ expect(coverArtIdForEntity(song)).toBe('al-1');
+ });
+
+ it('treats an entity without albumId as id-keyed (album/artist/playlist)', () => {
+ const album = { id: 'al-2', coverArt: 'al-cover' } as AlbumID3;
+ const artist = { id: 'ar-2', coverArt: 'ar-cover' } as ArtistID3;
+ const playlist = { id: 'pl-2', coverArt: 'pl-cover' } as Playlist;
+ expect(coverArtIdForEntity(album)).toBe('al-2');
+ expect(coverArtIdForEntity(artist)).toBe('ar-2');
+ expect(coverArtIdForEntity(playlist)).toBe('pl-2');
+ });
+ });
+});
diff --git a/src/utils/colors.ts b/src/utils/colors.ts
index e86f0f4..90c8171 100644
--- a/src/utils/colors.ts
+++ b/src/utils/colors.ts
@@ -4,7 +4,7 @@
* Palette extraction itself now lives in the native `expo-image-colors`
* module (see `useImagePalette`). These helpers remain for gradient-stop
* alpha composition and theme-mix blending in `GradientBackground`,
- * `ExpandedPlayerView`, and friends.
+ * `PlayerTabletLandscape`, and friends.
*/
/**