From ba3e1cea3f3a5ab6e9d2a2fb2ae7c0626ad7e278 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Thu, 14 May 2026 22:41:42 +0300 Subject: [PATCH 01/33] style(prettier): add prettier config and ignore files --- .prettierignore | 11 +++++++++++ .prettierrc | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d5fa83c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +dist +build +coverage +.expo +.idea +.vscode +package-lock.json +assets +android +ios \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..61d93d0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "printWidth": 230 +} From f511739151f8aaaf12fcaba93bdb57add69fccd5 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:20:58 +0300 Subject: [PATCH 02/33] feat: add prettier, camera support, and project updates - set up prettier code formatting with eslint and VSCode integration - add expo-camera for QR code scanning to connect to the AIMP server - add new npm scripts for development, building, and code checks - update project configurations including app permissions, EAS project ID, and TS config - refactor config files and README tables for better readability --- .vscode/extensions.json | 2 +- .vscode/settings.json | 20 ++++-- README.md | 78 ++++++++++++----------- app.json | 20 +++--- eslint.config.js | 4 +- package-lock.json | 136 ++++++++++++++++++++++++++++++++++++++++ package.json | 16 ++++- tsconfig.json | 4 +- tsconfig.node.json | 7 +++ 9 files changed, 228 insertions(+), 59 deletions(-) create mode 100644 tsconfig.node.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b7ed837..4feff83 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1 +1 @@ -{ "recommendations": ["expo.vscode-expo-tools"] } +{ "recommendations": ["expo.vscode-expo-tools", "esbenp.prettier-vscode"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index e2798e4..72d31f8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,19 @@ { + "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit", - "source.sortMembers": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } -} +} \ No newline at end of file diff --git a/README.md b/README.md index 1ea5575..85690cb 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ It includes real-time playback updates, playlist browsing, now playing controls, [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -| Home | Player | Extra Info | -| :---: | :---: | :---: | +| Home | Player | Extra Info | +| :--------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------: | | ![Home](https://github.com/user-attachments/assets/214523d4-9677-44c7-8fcb-57cfd67f07c2) | ![Player](https://github.com/user-attachments/assets/d94276e6-2820-4187-94c0-4fc3679947bd) | ![Extra Info](https://github.com/user-attachments/assets/11bf50d7-a6b7-4aa9-a03d-7c7edc37a9a2) | -| Settings | Playlist Details | -| :---: | :---: | +| Settings | Playlist Details | +| :------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------: | | ![Settings](https://github.com/user-attachments/assets/2813abbd-a7e6-4a0f-a463-0656fef92fd6) | ![Playlist Details](https://github.com/user-attachments/assets/c0dcf099-6cbc-4b21-8c16-ade8955dd2fc) | ## โœจ Features @@ -58,51 +58,51 @@ This app requires the **AIMP Web Control Plugin** running on your PC. The plugin #### ๐ŸŽต Track Information -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/track/info` | GET | Get current track metadata (title, artist, album, etc.) | -| `/track/cover` | GET | Get album artwork for current track | -| `/track/position` | GET | Get current playback position | -| `/track/position` | POST | Set playback position (seek) | -| `/track/duration` | GET | Get track duration | +| Endpoint | Method | Description | +| ----------------- | ------ | ------------------------------------------------------- | +| `/track/info` | GET | Get current track metadata (title, artist, album, etc.) | +| `/track/cover` | GET | Get album artwork for current track | +| `/track/position` | GET | Get current playback position | +| `/track/position` | POST | Set playback position (seek) | +| `/track/duration` | GET | Get track duration | #### โ–ถ๏ธ Player Controls -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/playerstate` | GET | Get player state (playing/paused/stopped) | -| `/playpause` | GET | Toggle play/pause | -| `/next` | GET | Skip to next track | -| `/previous` | GET | Go to previous track | +| Endpoint | Method | Description | +| -------------- | ------ | ----------------------------------------- | +| `/playerstate` | GET | Get player state (playing/paused/stopped) | +| `/playpause` | GET | Toggle play/pause | +| `/next` | GET | Skip to next track | +| `/previous` | GET | Go to previous track | #### ๐Ÿ”Š Audio Controls -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/volume` | GET | Get current volume level | -| `/volume` | POST | Set volume level | -| `/mute` | GET | Get mute state | -| `/mute` | POST | Toggle mute on/off | +| Endpoint | Method | Description | +| --------- | ------ | ------------------------ | +| `/volume` | GET | Get current volume level | +| `/volume` | POST | Set volume level | +| `/mute` | GET | Get mute state | +| `/mute` | POST | Toggle mute on/off | #### ๐Ÿ”€ Playback Modes -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/track/repeat` | GET | Get repeat mode | -| `/track/repeat` | POST | Set repeat mode | -| `/shuffle` | GET | Get shuffle state | -| `/shuffle` | POST | Toggle shuffle on/off | +| Endpoint | Method | Description | +| --------------- | ------ | --------------------- | +| `/track/repeat` | GET | Get repeat mode | +| `/track/repeat` | POST | Set repeat mode | +| `/shuffle` | GET | Get shuffle state | +| `/shuffle` | POST | Toggle shuffle on/off | #### ๐Ÿ“‹ Playlists -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/playlist` | GET | Get all playlists | -| `/playlist/current` | GET | Get currently active playlist | -| `/playlist/items` | GET | Get tracks from a specific playlist | -| `/playlist/info` | GET | Get basic playlist information | -| `/playlist/stats` | GET | Get playlist statistics | -| `/playlist/play` | GET | Play a specific track from playlist | +| Endpoint | Method | Description | +| ------------------- | ------ | ----------------------------------- | +| `/playlist` | GET | Get all playlists | +| `/playlist/current` | GET | Get currently active playlist | +| `/playlist/items` | GET | Get tracks from a specific playlist | +| `/playlist/info` | GET | Get basic playlist information | +| `/playlist/stats` | GET | Get playlist statistics | +| `/playlist/play` | GET | Play a specific track from playlist | #### ๐Ÿ”Œ WebSocket Events @@ -127,17 +127,20 @@ Connect to `ws://:3554` for real-time updates: ## ๐Ÿš€ Installation 1. Clone the repository: + ```bash git clone https://github.com/yourusername/aimp-remote.git cd aimp-remote ``` 2. Install dependencies: + ```bash npm install ``` 3. Start the app: + ```bash npm run start ``` @@ -244,6 +247,7 @@ Current communication is **LAN-oriented** and uses HTTP/WebSocket without encryp โš ๏ธ **Not recommended for production or internet-facing deployments** For non-local or production-grade scenarios, consider: + - Implementing HTTPS/WSS with valid certificates - Adding authentication (API keys, OAuth) - Implementing rate limiting diff --git a/app.json b/app.json index 97e6d0c..9dcb32d 100644 --- a/app.json +++ b/app.json @@ -3,14 +3,18 @@ "name": "AIMP Remote Control", "slug": "aimp-remote", "version": "1.1.0", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icons/adaptive-icon-color.png", "scheme": "aimpremote", "userInterfaceStyle": "automatic", "ios": { + "infoPlist": { + "NSCameraUsageDescription": "This app uses the camera to scan QR codes to connect to the AIMP server." + }, "supportsTablet": true }, "android": { + "permissions": ["CAMERA"], "adaptiveIcon": { "backgroundColor": "#363636", "foregroundImage": "./assets/icons/adaptive-icon-color.png", @@ -42,13 +46,7 @@ [ "expo-font", { - "fonts": [ - "./assets/fonts/MPLUS-Bold.ttf", - "./assets/fonts/MPLUS-ExtraBold.ttf", - "./assets/fonts/MPLUS-Regular.ttf", - "./assets/fonts/MPLUS-Medium.ttf", - "./assets/fonts/MPLUS-Thin.ttf" - ] + "fonts": ["./assets/fonts/MPLUS-Bold.ttf", "./assets/fonts/MPLUS-ExtraBold.ttf", "./assets/fonts/MPLUS-Regular.ttf", "./assets/fonts/MPLUS-Medium.ttf", "./assets/fonts/MPLUS-Thin.ttf"] } ], [ @@ -67,14 +65,12 @@ "extra": { "router": {}, "eas": { - "projectId": "cca8de19-da90-4dff-bdec-f261cd0abbda" + "projectId": "031e69da-7b3f-4e59-9000-d375c17424ec" } }, "runtimeVersion": { "policy": "appVersion" }, - "updates": { - "url": "https://u.expo.dev/cca8de19-da90-4dff-bdec-f261cd0abbda" - } + "owner": "mahmoudwalid" } } diff --git a/eslint.config.js b/eslint.config.js index 5025da6..776e415 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,12 @@ // https://docs.expo.dev/guides/using-eslint/ const { defineConfig } = require('eslint/config'); const expoConfig = require('eslint-config-expo/flat'); +const eslintPluginPrettierRecommended = require('eslint-plugin-prettier/recommended'); module.exports = defineConfig([ expoConfig, + eslintPluginPrettierRecommended, { - ignores: ['dist/*'], + ignores: ['dist/*', '.expo/*'], }, ]); diff --git a/package-lock.json b/package-lock.json index 4557aeb..68fd2e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "expo": "~54.0.33", "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", "expo-dev-client": "~6.0.20", "expo-font": "~14.0.11", @@ -39,6 +40,9 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.3", "typescript": "~5.9.2" } }, @@ -2557,6 +2561,19 @@ "node": ">=12.4.0" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -5787,6 +5804,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -5934,6 +5967,37 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -6254,6 +6318,26 @@ "node": ">=10" } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -6968,6 +7052,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -10217,6 +10308,35 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -11866,6 +11986,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", diff --git a/package.json b/package.json index 2caa0ad..b493d5d 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,21 @@ "version": "1.0.0", "scripts": { "start": "expo start", + "start:dev": "expo start --dev-client", "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", + "android-build": "eas build --profile development --platform android", + "android-build:local": "eas build --profile development --platform android --local", + "ios-build": "eas build --profile development --platform ios", + "ios-build:local": "eas build --profile development --platform ios --local", "ios": "expo run:ios", "web": "expo start --web", - "lint": "expo lint" + "lint": "expo lint", + "format": "prettier . --write", + "format:check": "prettier . --check", + "typecheck": "tsc --noEmit", + "check": "npm run typecheck && npm run lint && npm run format:check", + "check:fix": "npm run lint -- --fix && npm run format" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -19,6 +29,7 @@ "expo": "~54.0.33", "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", "expo-dev-client": "~6.0.20", "expo-font": "~14.0.11", @@ -42,6 +53,9 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.3", "typescript": "~5.9.2" }, "private": true diff --git a/tsconfig.json b/tsconfig.json index a8ab563..ec96c84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..ca11d57 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2020" + } +} From 89dc6f056099652f0311fe62efa906ad834da249 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:25:45 +0300 Subject: [PATCH 03/33] feat(scripts): add scripts to export project files to formatted markdown add generate-full-project-content.ts for full project scans, and generate-folder-content.ts for targeted directory scans, excluding common ignored paths and files, and limiting processed file size to 2MB --- scripts/generate-folder-content.ts | 96 ++++++++++++++++++++++++ scripts/generate-full-project-content.ts | 83 ++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 scripts/generate-folder-content.ts create mode 100644 scripts/generate-full-project-content.ts diff --git a/scripts/generate-folder-content.ts b/scripts/generate-folder-content.ts new file mode 100644 index 0000000..e1fcc3c --- /dev/null +++ b/scripts/generate-folder-content.ts @@ -0,0 +1,96 @@ +/// + +import { fileURLToPath } from 'url'; +import * as fs from 'fs'; +import * as path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// usage: +// npx ts-node ./scripts/generate-folder-content.ts ./src/modules + +const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build']; + +const IGNORE_FILES = ['.env', 'package-lock.json', '.gitignore']; + +const MAX_FILE_SIZE = 1024 * 1024 * 2; // 2MB + +let processedCount = 0; +function showLoader() { + process.stdout.write(`\r๐Ÿ“ฆ Processing files: ${processedCount}`); +} + +function getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] { + const files = fs.readdirSync(dirPath); + + files.forEach((file: string) => { + if (IGNORE_DIRS.includes(file)) return; + + const fullPath = path.join(dirPath, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + getAllFiles(fullPath, arrayOfFiles); + } else { + if (IGNORE_FILES.includes(file)) return; + if (stat.size > MAX_FILE_SIZE) return; + + arrayOfFiles.push(fullPath); + } + }); + + return arrayOfFiles; +} + +function createMarkdownContent(files: string[], basePath: string): string { + return files + .map((file) => { + processedCount++; + showLoader(); + + const relativePath = path.relative(basePath, file); + + let content: string; + try { + content = fs.readFileSync(file, 'utf-8'); + } catch { + content = '[Could not read file]'; + } + + return `${relativePath} +\`\`\` +${content} +\`\`\` +-----`; + }) + .join('\n'); +} + +// Main +const targetFolder = process.argv[2]; + +if (!targetFolder) { + console.error('โŒ Please provide a folder path.'); + process.exit(1); +} + +const resolvedPath = path.resolve(targetFolder); + +if (!fs.existsSync(resolvedPath)) { + console.error('โŒ Folder does not exist.'); + process.exit(1); +} + +const allFiles = getAllFiles(resolvedPath); + +console.log(`๐Ÿš€ Found ${allFiles.length} files in folder: ${targetFolder}\n`); + +const markdownContent = createMarkdownContent(allFiles, resolvedPath); + +const outputPath = path.join(__dirname, `folder-content-${path.basename(resolvedPath)}.md`); + +fs.writeFileSync(outputPath, markdownContent, 'utf-8'); + +console.log(`\n\nโœ… Done. Processed ${allFiles.length} files.`); +console.log(`๐Ÿ“„ Output: ${outputPath}`); diff --git a/scripts/generate-full-project-content.ts b/scripts/generate-full-project-content.ts new file mode 100644 index 0000000..e2dd14b --- /dev/null +++ b/scripts/generate-full-project-content.ts @@ -0,0 +1,83 @@ +/// + +import * as fs from 'fs'; +import * as path from 'path'; + +const __dirname = path.dirname(process.argv[1]); + +// to use the script => `npx ts-node --project tsconfig.node.json scripts/generate-full-project-content.ts` in terminal + +const IGNORE_DIRS = ['node_modules', '.git', '.expo', 'android', 'dist', 'build', 'scripts', 'postman-collections', 'blueprints', 'assets']; +const IGNORE_FILES = ['full-project-content.md', '.env', 'package-lock.json', 'eslint.config.mts', 'tsconfig.json', 'README.md', 'nodemon.json', '.gitignore', '.sentryclirc']; + +const MAX_FILE_SIZE = 1024 * 1024 * 2; // 2MB + +// ๐ŸŽฏ Loader +let processedCount = 0; +function showLoader() { + process.stdout.write(`\r๐Ÿ“ฆ Processing files: ${processedCount}`); +} + +function getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] { + const files = fs.readdirSync(dirPath); + + files.forEach((file: string) => { + if (IGNORE_DIRS.includes(file)) return; + + const fullPath = path.join(dirPath, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + getAllFiles(fullPath, arrayOfFiles); + } else { + if (IGNORE_FILES.includes(file)) return; + if (stat.size > MAX_FILE_SIZE) return; + + arrayOfFiles.push(fullPath); + } + }); + + return arrayOfFiles; +} + +function createMarkdownContent(files: string[], basePath: string): string { + return files + .map((file) => { + processedCount++; + showLoader(); + + const relativePath = path.relative(basePath, file); + + console.log(` => ๐Ÿ“„ ${relativePath} \n`); + + let content: string; + try { + content = fs.readFileSync(file, 'utf-8'); + } catch { + content = '[Could not read file]'; + } + + return `${relativePath} +\`\`\` +${content} +\`\`\` +-----`; + }) + .join('\n'); +} + +// Main +const folderPath = process.argv[2] || './'; + +const allFiles = getAllFiles(folderPath); + +console.log(`๐Ÿš€ Found ${allFiles.length} files. Starting...\n`); + +const markdownContent = createMarkdownContent(allFiles, folderPath); + +const outputPath = path.join(__dirname, 'full-project-content.md'); + +fs.writeFileSync(outputPath, markdownContent, 'utf-8'); + +console.log(`\n\nโœ… Done. Processed ${allFiles.length} files.`); +console.log(`๐Ÿ“„ Output: ${outputPath}`); From e4d01a8e6721540fd0bf9439d9d04c3f93877c36 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:39:58 +0300 Subject: [PATCH 04/33] feat(theme): add light theme and ThemeType restructure theme module to use unified Colors object, remove default export, fix trailing newline --- src/theme.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/theme.ts b/src/theme.ts index f248100..e0fe29a 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,5 +1,20 @@ -export const darkTheme = { - background: '#363636' +export const Colors = { + light: { + background: '#F5F5F5', + surface: '#FFFFFF', + text: '#121212', + textSecondary: '#666666', + border: '#E0E0E0', + primary: '#8B8B8B', // Default app color + }, + dark: { + background: '#121212', + surface: '#252525', + text: '#FFFFFF', + textSecondary: '#C6C6C6', + border: '#363636', + primary: '#8B8B8B', + }, }; -export default darkTheme \ No newline at end of file +export type ThemeType = 'light' | 'dark'; From 134453d192952de6eb5057d7de44cd9c4733bb04 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:43:32 +0300 Subject: [PATCH 05/33] feat(ui): add reusable skeleton loading component use react-native-reanimated for looping fade animation, accept custom dimensions, border radius and theme props --- src/components/ui/Skeleton.tsx | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/components/ui/Skeleton.tsx diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..cd260b5 --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -0,0 +1,35 @@ +import { Colors, ThemeType } from '@/theme'; +import { useEffect } from 'react'; +import { DimensionValue } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated'; + +interface SkeletonProps { + width: DimensionValue; + height: DimensionValue; + borderRadius?: number; + theme: ThemeType; +} + +export default function Skeleton({ width, height, borderRadius = 8, theme }: SkeletonProps) { + const opacity = useSharedValue(0.3); + + useEffect(() => { + opacity.value = withRepeat(withSequence(withTiming(0.7, { duration: 800 }), withTiming(0.3, { duration: 800 })), -1, true); + }, [opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + + ); +} From f46f0f08b57ac978440d74b7f9ac1bd08204bbcc Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:51:47 +0300 Subject: [PATCH 06/33] refactor(aimpLogo): clean up component code and add TS types add TypeScript prop typing using SvgProps, condense JSX syntax, use single quotes consistently and remove redundant SVG namespace attributes --- src/components/ui/aimpLogo.tsx | 40 +++++++--------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/src/components/ui/aimpLogo.tsx b/src/components/ui/aimpLogo.tsx index fa7d35a..80efd10 100644 --- a/src/components/ui/aimpLogo.tsx +++ b/src/components/ui/aimpLogo.tsx @@ -1,20 +1,8 @@ -import * as React from "react"; -import Svg, { - Defs, - RadialGradient, - Stop, - G, - Circle, - Path, - Text, -} from "react-native-svg"; -const SVGComponent = (props) => ( - +import * as React from 'react'; +import Svg, { Defs, RadialGradient, Stop, G, Circle, Path, Text, SvgProps } from 'react-native-svg'; + +const SVGComponent = (props: SvgProps) => ( + @@ -25,10 +13,7 @@ const SVGComponent = (props) => ( @@ -37,17 +22,8 @@ const SVGComponent = (props) => ( - - {"ENJOY THE MUSIC!"} + + {'ENJOY THE MUSIC!'} From 4504e178ae7132c292d4906e10941d056b027bdd Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 00:58:25 +0300 Subject: [PATCH 07/33] fix(types): correct type definitions and clean up code Fix PlaylistStatsProps artists and genres from object[] to string[] Standardize indentation for all type interface properties Add missing trailing newlines to all type definition files Add new PlaylistItemType interface for playlist items --- src/types/IPlaylistDetails.ts | 43 ++++++++++++++++++++--------------- src/types/ISettings.ts | 6 ++--- src/types/ISongInformation.ts | 18 +++++++-------- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/types/IPlaylistDetails.ts b/src/types/IPlaylistDetails.ts index c43fcd1..729281e 100644 --- a/src/types/IPlaylistDetails.ts +++ b/src/types/IPlaylistDetails.ts @@ -1,26 +1,33 @@ export interface PlaylistDetailsHeaderProps { - playlistInfo: PlaylistInfoProps; - playlistStats: PlaylistStatsProps; + playlistInfo: PlaylistInfoProps; + playlistStats: PlaylistStatsProps; } export interface PlaylistInfoProps { - duration: number; - id: string; - is_read_only: string; - item_count: number; - name: string; - playing_index: number; + duration: number; + id: string; + is_read_only: string; + item_count: number; + name: string; + playing_index: number; } export interface PlaylistStatsProps { - album_count: number; - artist_count: number; - artists: object[]; - avg_bitrate: number; - avg_rating: number; - genres: object[]; - total_play_count: number; - total_size_bytes: number; - tracks_never_played: number; - tracks_with_rating: number; + album_count: number; + artist_count: number; + artists: string[]; + avg_bitrate: number; + avg_rating: number; + genres: string[]; + total_play_count: number; + total_size_bytes: number; + tracks_never_played: number; + tracks_with_rating: number; +} + +export interface PlaylistItemType { + index: number | string; + title: string; + artist: string; + duration: number; } diff --git a/src/types/ISettings.ts b/src/types/ISettings.ts index 65fd8fe..beb3a64 100644 --- a/src/types/ISettings.ts +++ b/src/types/ISettings.ts @@ -1,4 +1,4 @@ export interface ServerSettings { - ip: string; - name: string; -} \ No newline at end of file + ip: string; + name: string; +} diff --git a/src/types/ISongInformation.ts b/src/types/ISongInformation.ts index f16afb3..257ac63 100644 --- a/src/types/ISongInformation.ts +++ b/src/types/ISongInformation.ts @@ -1,10 +1,10 @@ export interface SongInterface { - album: string; - artist: string; - bitrate: number; - genre: string; - play_count: number; - rating: number; - sample_rate: number; - title: string; -} \ No newline at end of file + album: string; + artist: string; + bitrate: number; + genre: string; + play_count: number; + rating: number; + sample_rate: number; + title: string; +} From 46297b08e507f81191d81b401d6d561078ab0f91 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:11:25 +0300 Subject: [PATCH 08/33] feat(appContext): add theme support to settings Add full theme support to the app settings context, including system theme detection via React Native's useColorScheme, persist theme preference to AsyncStorage, update context types to include theme state and setters, improve the useSettings hook to throw an error when used outside its provider, fix import quote consistency, indentation and missing newline at end of file, translate Spanish console errors to English, and update default server ip to empty string. --- src/context/appContext.tsx | 109 +++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/src/context/appContext.tsx b/src/context/appContext.tsx index f96a93a..7b839b0 100644 --- a/src/context/appContext.tsx +++ b/src/context/appContext.tsx @@ -1,67 +1,72 @@ -import { ServerSettings } from "@/types/ISettings"; +import { ThemeType } from '@/theme'; +import { ServerSettings } from '@/types/ISettings'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { createContext, ReactNode, useContext, useEffect, useState } from "react"; +import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import { useColorScheme } from 'react-native'; interface SettingsContextType { - server: ServerSettings; - setServer: React.Dispatch>; - appColor: string; - setAppColor: React.Dispatch>; - isLoaded: boolean; + server: ServerSettings; + setServer: React.Dispatch>; + appColor: string; + setAppColor: React.Dispatch>; + theme: ThemeType; + setTheme: React.Dispatch>; + isLoaded: boolean; } export const SettingsContext = createContext(undefined); export function SettingsProvider({ children }: { children: ReactNode }) { - const [server, setServer] = useState({ ip: '0.0.0.0', name: '' }); - const [appColor, setAppColor] = useState('#8B8B8B') - const [isLoaded, setIsLoaded] = useState(false); + const systemTheme = useColorScheme(); + const [server, setServer] = useState({ ip: '', name: '' }); + const [appColor, setAppColor] = useState('#8B8B8B'); + const [themePreference, setThemePreference] = useState('system'); + const [isLoaded, setIsLoaded] = useState(false); - useEffect(() => { - const loadSettings = async () => { - try { - const [storedServer, storedColor] = await Promise.all([ - AsyncStorage.getItem('settings'), - AsyncStorage.getItem('color') - ]); + const activeTheme: ThemeType = themePreference === 'system' ? systemTheme || 'dark' : themePreference; - if (storedServer) setServer(JSON.parse(storedServer)); - if (storedColor) setAppColor(storedColor); - } catch (e) { - console.error('Error cargando configuraciones:', e); - } finally { - setIsLoaded(true); - } - }; - loadSettings(); - }, []); + useEffect(() => { + const loadSettings = async () => { + try { + const [storedServer, storedColor, storedTheme] = await Promise.all([AsyncStorage.getItem('settings'), AsyncStorage.getItem('color'), AsyncStorage.getItem('theme')]); + if (storedServer) setServer(JSON.parse(storedServer)); + if (storedColor) setAppColor(storedColor); + if (storedTheme) setThemePreference(storedTheme as ThemeType | 'system'); + } catch (e) { + console.error('Error loading settings:', e); + } finally { + setIsLoaded(true); + } + }; + loadSettings(); + }, []); - useEffect(() => { - if (!isLoaded) return; + useEffect(() => { + if (!isLoaded) return; + AsyncStorage.setItem('settings', JSON.stringify(server)); + AsyncStorage.setItem('color', appColor); + AsyncStorage.setItem('theme', themePreference); + }, [server, appColor, themePreference, isLoaded]); - const saveSettings = async () => { - try { - await AsyncStorage.setItem('settings', JSON.stringify(server)); - await AsyncStorage.setItem('color', appColor); - } catch (e) { - console.error('Error guardando configuraciones:', e); - } - }; - - saveSettings(); - }, [server, appColor, isLoaded]); - - return ( - - {children} - - ) + return ( + + {children} + + ); } export function useSettings() { - const context = useContext(SettingsContext); - if (context === undefined) { - return null; - } - return context; -} \ No newline at end of file + const context = useContext(SettingsContext); + if (!context) throw new Error('useSettings must be used within a SettingsProvider'); + return context; +} From 833667b3b52aec2e3e1966845ece9f77a6e1c602 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:16:17 +0300 Subject: [PATCH 09/33] style(useAppState): fix quote style and trailing newline --- src/hooks/useAppState.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts index 19d4ba5..27f03f7 100644 --- a/src/hooks/useAppState.ts +++ b/src/hooks/useAppState.ts @@ -1,13 +1,13 @@ -import { useEffect, useRef, useState } from "react"; -import { AppState } from "react-native"; +import { useEffect, useRef, useState } from 'react'; +import { AppState } from 'react-native'; export const useAppState = () => { const appStateRef = useRef(AppState.currentState); const [appState, setAppState] = useState(appStateRef.current); useEffect(() => { - const subscription = AppState.addEventListener("change", (nextAppState) => { - if (appStateRef.current.match(/inactive|background/) && nextAppState === "active") { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') { } appStateRef.current = nextAppState; @@ -20,4 +20,4 @@ export const useAppState = () => { }, []); return { appVisible: appState }; -}; \ No newline at end of file +}; From df508e76e5b0349a86343eeca7858450004e6c59 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:18:01 +0300 Subject: [PATCH 10/33] refactor(useAIMP): add types and improve robustness - add console warning for websocket JSON parse errors - add typed AIMPState interface and use SongInterface for track data - add early exit if server IP is missing to prevent invalid WebSocket connections - add safe WebSocket ref cleanup and proper TypeScript types for the ref - standardize all string quotes to single quotes across the file - fix initial volumeState value to match the defined nullable type --- src/hooks/useAIMP.ts | 65 +++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/hooks/useAIMP.ts b/src/hooks/useAIMP.ts index acdc405..12b57d3 100644 --- a/src/hooks/useAIMP.ts +++ b/src/hooks/useAIMP.ts @@ -1,6 +1,18 @@ -import { useSettings } from "@/context/appContext"; -import { useEffect, useRef, useState } from "react"; -import { ToastAndroid } from "react-native"; +import { useSettings } from '@/context/appContext'; +import { SongInterface } from '@/types/ISongInformation'; +import { useEffect, useRef, useState } from 'react'; +import { ToastAndroid } from 'react-native'; + +export interface AIMPState { + muteState: boolean | null; + playerState: number | null; + position: number; + repeatState: boolean | null; + shuffleState: boolean | null; + status: 'connected' | 'disconnected'; + track: SongInterface & { playlist_id?: string; event?: string }; + volumeState: number | null; +} export const useAIMP = () => { const [aimpEvent, setAimpEvent] = useState({ @@ -9,23 +21,23 @@ export const useAIMP = () => { position: 0, repeatState: null, shuffleState: null, - status: "disconnected", + status: 'disconnected', track: { - album: "", - artist: "", + album: '', + artist: '', bitrate: 0, duration: 0, - event: "", - genre: "", + event: '', + genre: '', play_count: 0, - playlist_id: "", + playlist_id: '', rating: 0, sample_rate: 0, - title: "", + title: '', }, - volumeState: 0, + volumeState: null, }); - const ws = useRef(null); + const ws = useRef(null); const { server } = useSettings(); @@ -34,31 +46,40 @@ export const useAIMP = () => { }; useEffect(() => { + if (!server || !server.ip) return; + ws.current = new WebSocket(`ws://${server.ip}:3554`); - ws.current.onopen = () => setAimpEvent((prev) => ({ ...prev, status: "connected" })); + ws.current.onopen = () => setAimpEvent((prev) => ({ ...prev, status: 'connected' })); ws.current.onmessage = (e) => { try { const data = JSON.parse(e.data); setAimpEvent((prev) => ({ ...prev, - ...(data.event === "mute_changed" && { muteState: data.mute }), - ...(data.event === "player_state" && { playerState: data.state }), - ...(data.event === "position" && { position: data.position }), - ...(data.event === "repeat_changed" && { repeatState: data.repeat }), - ...(data.event === "shuffle_changed" && { shuffleState: data.shuffle }), - ...(data.event === "track_changed" && { track: data }), - ...(data.event === "volume_changed" && { volumeState: data.volume }), + ...(data.event === 'mute_changed' && { muteState: data.mute }), + ...(data.event === 'player_state' && { playerState: data.state }), + ...(data.event === 'position' && { position: data.position }), + ...(data.event === 'repeat_changed' && { repeatState: data.repeat }), + ...(data.event === 'shuffle_changed' && { + shuffleState: data.shuffle, + }), + ...(data.event === 'track_changed' && { track: data }), + ...(data.event === 'volume_changed' && { volumeState: data.volume }), })); } catch { showToast('Error parsing websocket'); + console.warn('Error parsing websocket'); } }; - ws.current.onerror = (e) => setAimpEvent((prev) => ({ ...prev, status: "disconnected" })); + ws.current.onerror = (e) => setAimpEvent((prev) => ({ ...prev, status: 'disconnected' })); - return () => ws.current.close(); + return () => { + if (ws.current) { + ws.current.close(); + } + }; }, [server]); return { aimpEvent }; From 75c2be5521f115135b2cd18bf970509dda181394 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:22:48 +0300 Subject: [PATCH 11/33] style(player/normalButton): format component code standardize import quotes to single quotes, fix indentation, clean up JSX and style formatting, add missing trailing newline --- src/components/player/normalButton.tsx | 75 ++++++++++++-------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/src/components/player/normalButton.tsx b/src/components/player/normalButton.tsx index 730c8a9..260ce9e 100644 --- a/src/components/player/normalButton.tsx +++ b/src/components/player/normalButton.tsx @@ -1,53 +1,44 @@ -import React from "react"; -import { StyleSheet, TouchableNativeFeedback, View, ViewStyle } from "react-native"; +import React from 'react'; +import { StyleSheet, TouchableNativeFeedback, View, ViewStyle } from 'react-native'; interface NormalButtonProps { - onPress: () => void; - rippleColor?: string; - containerStyle?: ViewStyle; - insideStyle?: ViewStyle; - IconSet?: React.ElementType; - iconName?: string; - iconSize?: number; - iconColor?: string; - TextElement?: React.ReactNode; + onPress: () => void; + rippleColor?: string; + containerStyle?: ViewStyle; + insideStyle?: ViewStyle; + IconSet?: React.ElementType; + iconName?: string; + iconSize?: number; + iconColor?: string; + TextElement?: React.ReactNode; } export default function NormalButton({ onPress, rippleColor = 'rgba(139, 139, 139, 0.5)', containerStyle, insideStyle, IconSet, iconName, iconSize, iconColor, TextElement }: NormalButtonProps) { - return ( - - - - {IconSet && - } - {TextElement} - - + return ( + + + + {IconSet && } + {TextElement} - ) + + + ); } const styles = StyleSheet.create({ - buttonNormalContainer: { - width: 48, - height: 48, + buttonNormalContainer: { + width: 48, + height: 48, - overflow: 'hidden', + overflow: 'hidden', - borderRadius: 50, - }, - buttonInside: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, -}) \ No newline at end of file + borderRadius: 50, + }, + buttonInside: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, +}); From 491a2c98517d67bbcb65089d8588e80317877bcd Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:24:07 +0300 Subject: [PATCH 12/33] style(ui/header): reformat and add optional chaining Reformat the entire header component to use consistent 2-space indentation, double quotes for strings, and proper semicolon placement. Add optional chaining to optional setter props to avoid runtime TypeErrors when they are not passed by parent components. --- src/components/ui/header.tsx | 293 ++++++++++++++++------------------- 1 file changed, 134 insertions(+), 159 deletions(-) diff --git a/src/components/ui/header.tsx b/src/components/ui/header.tsx index 087fa0d..b743dfc 100644 --- a/src/components/ui/header.tsx +++ b/src/components/ui/header.tsx @@ -1,171 +1,146 @@ -import { Ionicons } from '@expo/vector-icons' -import React from 'react' -import { StyleSheet, Text, TextInput, View } from 'react-native' -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' -import NormalButton from '../player/normalButton' +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, Text, TextInput, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import NormalButton from '../player/normalButton'; interface SearchBarProps { - hasSearchBar?: boolean; - searchBarVisible?: boolean; - searchValue?: string; - setSearchBarVisible?: (value: boolean) => void; - setSearchValue?: (value: string) => void; - leftSideOnPress: () => void; - LeftSideIconSet: React.ElementType; - iconName: string; - iconSize?: number; - iconColor?: string; - title?: string; + hasSearchBar?: boolean; + searchBarVisible?: boolean; + searchValue?: string; + setSearchBarVisible?: (value: boolean) => void; + setSearchValue?: (value: string) => void; + leftSideOnPress: () => void; + LeftSideIconSet: React.ElementType; + iconName: string; + iconSize?: number; + iconColor?: string; + title?: string; } export default function Header({ hasSearchBar = true, searchBarVisible, searchValue, setSearchBarVisible, setSearchValue, leftSideOnPress, LeftSideIconSet, iconName, iconSize, iconColor, title }: SearchBarProps) { - const transition = useSharedValue(0); - - const animatedSearchbar = useAnimatedStyle(() => { - return { - opacity: transition.value, - transform: [ - { scale: withTiming(transition.value === 0 ? 0.9 : 1) }, - ], - zIndex: transition.value > 0 ? 0 : -1, - }; - }); - - const animatedNormalHeader = useAnimatedStyle(() => { - return { - opacity: 1 - transition.value, - transform: [{ scale: 1 - (transition.value * 0.1) }], - zIndex: transition.value < 1 ? 0 : -1, - }; - }); - - const handleShowSearchbar = () => { - setSearchBarVisible(true); - transition.value = withTiming(1, { duration: 300 }); - }; + const transition = useSharedValue(0); - const handleCloseSearchbar = () => { - setSearchValue(''); - setSearchBarVisible(false); - transition.value = withTiming(0, { duration: 300 }); + const animatedSearchbar = useAnimatedStyle(() => { + return { + opacity: transition.value, + transform: [{ scale: withTiming(transition.value === 0 ? 0.9 : 1) }], + zIndex: transition.value > 0 ? 0 : -1, }; + }); - return ( - - {hasSearchBar ? - <> - - leftSideOnPress()} - IconSet={LeftSideIconSet} - iconName={iconName} - iconSize={iconSize} - iconColor={iconColor} - /> - handleShowSearchbar()} - IconSet={Ionicons} - iconName='search' - /> - - - - - - setSearchValue(newText)} - /> - handleCloseSearchbar()} - IconSet={Ionicons} - iconName='close' - /> - - : - - leftSideOnPress()} - IconSet={LeftSideIconSet} - iconName={iconName} - iconSize={iconSize} - iconColor={iconColor} - /> - {title} - } + const animatedNormalHeader = useAnimatedStyle(() => { + return { + opacity: 1 - transition.value, + transform: [{ scale: 1 - transition.value * 0.1 }], + zIndex: transition.value < 1 ? 0 : -1, + }; + }); + + const handleShowSearchbar = () => { + setSearchBarVisible?.(true); + transition.value = withTiming(1, { duration: 300 }); + }; + + const handleCloseSearchbar = () => { + setSearchValue?.(''); + setSearchBarVisible?.(false); + transition.value = withTiming(0, { duration: 300 }); + }; + + return ( + + {hasSearchBar ? ( + <> + + leftSideOnPress()} IconSet={LeftSideIconSet} iconName={iconName} iconSize={iconSize} iconColor={iconColor} /> + handleShowSearchbar()} IconSet={Ionicons} iconName="search" /> + + + + + + setSearchValue?.(newText)} + /> + handleCloseSearchbar()} IconSet={Ionicons} iconName="close" /> + + + ) : ( + + leftSideOnPress()} IconSet={LeftSideIconSet} iconName={iconName} iconSize={iconSize} iconColor={iconColor} /> + {title} - ) + )} + + ); } const styles = StyleSheet.create({ - header: { - position: 'relative', - - width: '100%', - height: 60, - paddingHorizontal: 20, - // backgroundColor: '#c6c6c6', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center' - }, - normalWrapper: { - position: 'absolute', - - width: '100%', - height: 50, - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - searchbarWrapper: { - position: 'absolute', - - width: '100%', - height: 40, - backgroundColor: '#363636', - paddingHorizontal: 20, - // paddingVertical: 5, - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - - borderRadius: 20, - }, - input: { - // backgroundColor: '#C6C6C6', - height: 50, - marginLeft: 10, - - flex: 1, - - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - title: { - color: '#FFF', - fontFamily: 'MPLUS-Bold', - fontSize: 24, - }, -}); \ No newline at end of file + header: { + position: 'relative', + + width: '100%', + height: 60, + paddingHorizontal: 20, + // backgroundColor: '#c6c6c6', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + normalWrapper: { + position: 'absolute', + + width: '100%', + height: 50, + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + searchbarWrapper: { + position: 'absolute', + + width: '100%', + height: 40, + backgroundColor: '#363636', + paddingHorizontal: 20, + // paddingVertical: 5, + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + + borderRadius: 20, + }, + input: { + // backgroundColor: '#C6C6C6', + height: 50, + marginLeft: 10, + + flex: 1, + + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 14, + }, + title: { + color: '#FFF', + fontFamily: 'MPLUS-Bold', + fontSize: 24, + }, +}); From 7cf0f4f66ef5ba3aa705ff589215c8dc0d24eb05 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:28:17 +0300 Subject: [PATCH 13/33] feat(connect): add AIMP connect screen with manual and QR scan provide two connection methods: manual IP entry and QR code scanning. handle both plain IP text and JSON formatted QR codes containing the server IP, manage camera permission requests for QR mode, save server connection details to the app settings, navigate back to the home screen after a successful connection, and include animated UI transitions for a smoother user experience. --- src/app/connect.tsx | 171 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/app/connect.tsx diff --git a/src/app/connect.tsx b/src/app/connect.tsx new file mode 100644 index 0000000..d2a0dc6 --- /dev/null +++ b/src/app/connect.tsx @@ -0,0 +1,171 @@ +import { useSettings } from '@/context/appContext'; +import { Colors } from '@/theme'; +import { Ionicons } from '@expo/vector-icons'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function ConnectScreen() { + const [permission, requestPermission] = useCameraPermissions(); + const [mode, setMode] = useState<'manual' | 'qr'>('manual'); + const [ipInput, setIpInput] = useState(''); + const { setServer, theme, appColor } = useSettings(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const currentColors = Colors[theme]; + + const handleConnect = (ip: string) => { + if (!ip) return; + setServer({ ip, name: 'AIMP Server' }); + router.replace('/'); + }; + + const handleBarcodeScanned = ({ data }: { data: string }) => { + // assume the QR code contains the IP directly or a JSON with IP + try { + const ip = data.includes('{') ? JSON.parse(data).ip : data; + handleConnect(ip); + } catch { + handleConnect(data); + } + }; + + if (!permission) return ; + + return ( + + + + Connect to AIMP + Scan QR or enter IP manually + + + + {mode === 'qr' ? ( + permission.granted ? ( + + + + + ) : ( + + Grant Camera Permission + + ) + ) : ( + + + handleConnect(ipInput)}> + Connect + + + )} + + + setMode('manual')}> + + + Manual IP + + + setMode('qr')}> + + + Scan QR + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 20, justifyContent: 'center' }, + header: { alignItems: 'center', marginBottom: 40 }, + title: { fontSize: 28, fontFamily: 'MPLUS-ExtraBold', marginTop: 10 }, + subtitle: { fontSize: 14, fontFamily: 'MPLUS-Regular', marginTop: 5 }, + content: { flex: 1, alignItems: 'center' }, + inputContainer: { width: '100%', gap: 15 }, + input: { + width: '100%', + height: 55, + borderRadius: 12, + paddingHorizontal: 20, + borderWidth: 1, + fontFamily: 'MPLUS-Regular', + }, + btn: { + width: '100%', + height: 55, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + btnText: { color: '#FFF', fontSize: 16, fontFamily: 'MPLUS-Bold' }, + cameraContainer: { + width: 250, + height: 250, + borderRadius: 20, + overflow: 'hidden', + position: 'relative', + }, + camera: { flex: 1 }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderWidth: 3, + borderRadius: 20, + backgroundColor: 'transparent', + }, + toggleContainer: { + flexDirection: 'row', + marginTop: 40, + backgroundColor: 'rgba(0,0,0,0.1)', + borderRadius: 15, + padding: 5, + }, + toggleBtn: { + flexDirection: 'row', + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 10, + alignItems: 'center', + gap: 8, + }, + toggleText: { fontFamily: 'MPLUS-Bold' }, +}); From 42bc4ba9057e468f18a6491259bdb904d8602fd0 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:31:48 +0300 Subject: [PATCH 14/33] style(songDetails): format code and standardize indentation Clean up JSX and styles formatting, fix inconsistent indentation across the component, and add missing trailing newline. --- src/app/(player)/songDetails.tsx | 238 ++++++++++++++++--------------- 1 file changed, 125 insertions(+), 113 deletions(-) diff --git a/src/app/(player)/songDetails.tsx b/src/app/(player)/songDetails.tsx index a796c31..0b0e532 100644 --- a/src/app/(player)/songDetails.tsx +++ b/src/app/(player)/songDetails.tsx @@ -6,128 +6,140 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati import Animated, { FadeIn } from 'react-native-reanimated'; export default function SongDetailsModal() { + const { song } = useLocalSearchParams<{ song: string }>(); - const { song } = useLocalSearchParams<{ song: string }>(); - - const songInfo = song ? (JSON.parse(song) as SongInterface) : null; - - if (!songInfo) { - return ( - - - - ) - } + const songInfo = song ? (JSON.parse(song) as SongInterface) : null; + if (!songInfo) { return ( - - - - - Song details - - - Album - {songInfo.album} - - - Genre - {songInfo.genre} - - - Artist - {songInfo.artist} - - - - - Play Count - - {songInfo.play_count} - {/* */} - - - - Rating - - - {[...Array(songInfo.rating)].map((_, index) => ())} - {[...Array(5 - songInfo.rating)].map((_, index) => ())} - - - - - - - Bitrate (kbps) - {songInfo.bitrate} - - - Sample Rate (Hz) - {songInfo.sample_rate} - + + + + ); + } + + return ( + + + + + Song details + + + Album + {songInfo.album} + + + Genre + {songInfo.genre} + + + Artist + {songInfo.artist} + + + + + Play Count + + {songInfo.play_count} + {/* */} + + + + Rating + + + {[...Array(songInfo.rating)].map((_, index) => ( + + ))} + {[...Array(5 - songInfo.rating)].map((_, index) => ( + + ))} - - ) -}; + + + + + + Bitrate (kbps) + {songInfo.bitrate} + + + Sample Rate (Hz) + {songInfo.sample_rate} + + + + ); +} const styles = StyleSheet.create({ - extraInfo: { - width: '100%', - height: '100%', - backgroundColor: 'rgba(12, 12, 12, 0.9)', - paddingHorizontal: 20, + extraInfo: { + width: '100%', + height: '100%', + backgroundColor: 'rgba(12, 12, 12, 0.9)', + paddingHorizontal: 20, - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: 20, - }, - extraInfoSection: { - flexDirection: 'column-reverse', - justifyContent: 'center', - gap: 5, - }, - extraInfoSong: { - width: '100%', - backgroundColor: '#252525', - padding: 20, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 20, + }, + extraInfoSection: { + flexDirection: 'column-reverse', + justifyContent: 'center', + gap: 5, + }, + extraInfoSong: { + width: '100%', + backgroundColor: '#252525', + padding: 20, - gap: 20, + gap: 20, - borderWidth: 0.5, - borderColor: '#8B8B8B', - borderRadius: 20, - }, - extraInfoPlay: { - width: '100%', + borderWidth: 0.5, + borderColor: '#8B8B8B', + borderRadius: 20, + }, + extraInfoPlay: { + width: '100%', - flexDirection: 'row', - gap: 20, - }, - extraInfoPlaySection: { - backgroundColor: '#252525', - padding: 20, + flexDirection: 'row', + gap: 20, + }, + extraInfoPlaySection: { + backgroundColor: '#252525', + padding: 20, - flex: 1, - flexDirection: 'column-reverse', - gap: 5, + flex: 1, + flexDirection: 'column-reverse', + gap: 5, - borderWidth: 0.5, - borderColor: '#8B8B8B', - borderRadius: 20, - }, - extraInfoTitle: { - color: '#8B8B8B', - fontFamily: 'MPLUS-Regular', - fontSize: 10, - }, - extraInfoText: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14 - }, -}); \ No newline at end of file + borderWidth: 0.5, + borderColor: '#8B8B8B', + borderRadius: 20, + }, + extraInfoTitle: { + color: '#8B8B8B', + fontFamily: 'MPLUS-Regular', + fontSize: 10, + }, + extraInfoText: { + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 14, + }, +}); From 0869d7002938fc9af1af6a7e98a764ea344d1df5 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:32:58 +0300 Subject: [PATCH 15/33] fix(soundWave): correct useEffect dependencies and format code Fix stale closure issues in the animation useEffect by including required dependencies. Reformat all code and JSX for improved readability, and add missing trailing newline to the file. --- src/components/player/soundWave.tsx | 211 ++++++++++++++++++++-------- 1 file changed, 154 insertions(+), 57 deletions(-) diff --git a/src/components/player/soundWave.tsx b/src/components/player/soundWave.tsx index 33429c7..2e64635 100644 --- a/src/components/player/soundWave.tsx +++ b/src/components/player/soundWave.tsx @@ -3,72 +3,169 @@ import { StyleSheet, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withDelay, withRepeat, withTiming } from 'react-native-reanimated'; interface SoundWaveProps { - wavesContainerGap: number; - waveWidth: number; - waveHeightOdd: number; - waveHeightEven: number; - waveBackground: string; - animate: boolean; + wavesContainerGap: number; + waveWidth: number; + waveHeightOdd: number; + waveHeightEven: number; + waveBackground: string; + animate: boolean; } export default function SoundWave({ wavesContainerGap, waveWidth, waveHeightOdd, waveHeightEven, waveBackground, animate }: SoundWaveProps) { - const heightOdd = useSharedValue(waveWidth); - const heightEven = useSharedValue(waveWidth) + const heightOdd = useSharedValue(waveWidth); + const heightEven = useSharedValue(waveWidth); - const animatedOdd = useAnimatedStyle(() => ({ - height: heightOdd.value - })); + const animatedOdd = useAnimatedStyle(() => ({ + height: heightOdd.value, + })); - const animatedEven = useAnimatedStyle(() => ({ - height: heightEven.value - })); + const animatedEven = useAnimatedStyle(() => ({ + height: heightEven.value, + })); - useEffect(() => { - heightOdd.value = withDelay(100, withRepeat( - withTiming(waveHeightOdd, { duration: 500 }), - -1, - true - )); - heightEven.value = withDelay(500, withRepeat( - withTiming(waveHeightEven, { duration: 500 }), - -1, - true - )); - }, []) - - if (!animate) { - return ( - - - - - - - - - - ) - } + useEffect(() => { + heightOdd.value = withDelay(100, withRepeat(withTiming(waveHeightOdd, { duration: 500 }), -1, true)); + heightEven.value = withDelay(500, withRepeat(withTiming(waveHeightEven, { duration: 500 }), -1, true)); + }, [heightEven, heightOdd, waveHeightEven, waveHeightOdd]); + if (!animate) { return ( - - - - - - - - - - ) + + + + + + + + + + ); + } + + return ( + + + + + + + + + + ); } const styles = StyleSheet.create({ - wavesContainer: { - width: '100%', + wavesContainer: { + width: '100%', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, -}); \ No newline at end of file + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, +}); From 427d3fc319b31bd689c9a97ad5f9bf9e153e8406 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:35:43 +0300 Subject: [PATCH 16/33] style(playlist-details-header): format component code fix indentation, standardize quote usage, correct useSettings hook placement, add missing trailing newline and improve overall readability without altering functionality --- .../playlist/playlistDetailsHeader.tsx | 311 +++++++++--------- 1 file changed, 155 insertions(+), 156 deletions(-) diff --git a/src/components/playlist/playlistDetailsHeader.tsx b/src/components/playlist/playlistDetailsHeader.tsx index c04a1ba..75f0b4f 100644 --- a/src/components/playlist/playlistDetailsHeader.tsx +++ b/src/components/playlist/playlistDetailsHeader.tsx @@ -5,171 +5,170 @@ import React from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; export default function PlaylistDetailsHeader({ playlistInfo, playlistStats }: PlaylistDetailsHeaderProps) { + const { appColor } = useSettings(); - const { appColor } = useSettings(); + function formatTime(totalSeconds: number): string { + if (totalSeconds <= 0) return '0 segundos'; - function formatTime(totalSeconds: number): string { - if (totalSeconds <= 0) return "0 segundos"; + const hours: number = Math.floor(totalSeconds / 3600); + const minutes: number = Math.floor((totalSeconds % 3600) / 60); + const seconds: number = Math.floor(totalSeconds % 60); - const hours: number = Math.floor(totalSeconds / 3600); - const minutes: number = Math.floor((totalSeconds % 3600) / 60); - const seconds: number = Math.floor(totalSeconds % 60); + const parts: string[] = []; - const parts: string[] = []; - - if (hours > 0) { - parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`); - } - - if (minutes > 0) { - parts.push(`${minutes} ${minutes === 1 ? 'min' : 'mins'}`); - } - - if (seconds > 0 && hours === 0) { - parts.push(`${seconds} ${'s'}`); - } - - return parts.join(" "); + if (hours > 0) { + parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`); } - function formatBytes(bytes: number, decimals: number = 2): string { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm)); + if (minutes > 0) { + parts.push(`${minutes} ${minutes === 1 ? 'min' : 'mins'}`); + } - return `${value} ${sizes[i]}`; + if (seconds > 0 && hours === 0) { + parts.push(`${seconds} ${'s'}`); } - const fixedDuration = formatTime(playlistInfo?.duration); - const fixedSize = formatBytes(playlistStats?.total_size_bytes) - - return ( - - - {playlistInfo?.name} - - - - - {playlistInfo?.item_count} - - - - {fixedDuration} - - - - {playlistStats?.total_play_count} times - - - - {playlistStats?.artist_count} - - - - {playlistStats?.album_count} - - - - {playlistStats?.genres.length} Genres - - - - - - - {fixedSize} - - - - {playlistStats?.avg_bitrate.toFixed(2)} kbps - - - - - {playlistStats?.avg_rating.toFixed(2)} avg - - + return parts.join(' '); + } + + function formatBytes(bytes: number, decimals: number = 2): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm)); + + return `${value} ${sizes[i]}`; + } + + const fixedDuration = formatTime(playlistInfo?.duration); + const fixedSize = formatBytes(playlistStats?.total_size_bytes); + + return ( + + + {playlistInfo?.name} + + + + + {playlistInfo?.item_count} + + + + {fixedDuration} + + + + {playlistStats?.total_play_count} times + + + + {playlistStats?.artist_count} + + + + {playlistStats?.album_count} + + + + {playlistStats?.genres.length} Genres + + + + + + + {fixedSize} + + + + {playlistStats?.avg_bitrate.toFixed(2)} kbps + + + + + {playlistStats?.avg_rating.toFixed(2)} avg - ) -}; + + + ); +} const styles = StyleSheet.create({ - playlistInfo: { - position: 'relative', - - width: '100%', - // backgroundColor: '#FFF', - marginBottom: 40, - paddingHorizontal: 20, - paddingVertical: 20, - - alignItems: 'flex-start', - justifyContent: 'flex-end', - gap: 40, - }, - gradientBox: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - - height: 250, - }, - info: { - height: 40, - // backgroundColor: '#FFF', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - }, - infoPill: { - backgroundColor: '#363636', - paddingHorizontal: 10, - paddingVertical: 5, - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - - borderRadius: 20, - - elevation: 2, - }, - infoText: { - color: '#C6C6C6', - fontFamily: 'MPLUS-Regular', - fontSize: 10, - }, - stats: { - width: '100%', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 5, - }, - statSeparator: { - width: 5, - height: 5, - backgroundColor: '#8B8B8B', - borderRadius: 5 - }, -}); \ No newline at end of file + playlistInfo: { + position: 'relative', + + width: '100%', + // backgroundColor: '#FFF', + marginBottom: 40, + paddingHorizontal: 20, + paddingVertical: 20, + + alignItems: 'flex-start', + justifyContent: 'flex-end', + gap: 40, + }, + gradientBox: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + + height: 250, + }, + info: { + height: 40, + // backgroundColor: '#FFF', + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + }, + infoPill: { + backgroundColor: '#363636', + paddingHorizontal: 10, + paddingVertical: 5, + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + + borderRadius: 20, + + elevation: 2, + }, + infoText: { + color: '#C6C6C6', + fontFamily: 'MPLUS-Regular', + fontSize: 10, + }, + stats: { + width: '100%', + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 5, + }, + statSeparator: { + width: 5, + height: 5, + backgroundColor: '#8B8B8B', + borderRadius: 5, + }, +}); From 8b8db78089c9d3f110456fb7a591e18b0f36c6d5 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:46:36 +0300 Subject: [PATCH 17/33] feat: add theme support and improve type safety - replace generic object/any props with proper PlaylistItemType for better type safety - replace hardcoded text and separator colors with theme-aware values from app context - standardize import quotes, format code and fix missing trailing newline --- src/components/playlist/playlistSongItem.tsx | 121 +++++++++++-------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/src/components/playlist/playlistSongItem.tsx b/src/components/playlist/playlistSongItem.tsx index c3655d1..e5c8b91 100644 --- a/src/components/playlist/playlistSongItem.tsx +++ b/src/components/playlist/playlistSongItem.tsx @@ -1,65 +1,82 @@ -import { memo } from "react"; -import { StyleSheet, Text, View } from "react-native"; -import NormalButton from "../player/normalButton"; +import { memo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import NormalButton from '../player/normalButton'; +import { PlaylistItemType } from '@/types/IPlaylistDetails'; +import { useSettings } from '@/context/appContext'; +import { Colors } from '@/theme'; interface PlaylistItems { - item: object | any; - onPress: () => void; + item: PlaylistItemType; + onPress: () => void; } -export const PlaylistItem = memo(function PlaylistItem({ item, onPress }: PlaylistItems) { +export const PlaylistItem = memo( + function PlaylistItem({ item, onPress }: PlaylistItems) { + const { theme } = useSettings(); + const currentTheme = Colors[theme]; + return ( - <> + <> - - {Number(item.index) + 1} - - {item.title} - {item.artist} - - - {new Date(item.duration * 1000).toISOString().slice(14, 19)} + rippleColor="rgba(139, 139, 139, 0.5)" + onPress={onPress} + containerStyle={{ width: '100%', height: 60 }} + insideStyle={{ + paddingHorizontal: 20, + gap: 20, + justifyContent: 'flex-start', + }} + TextElement={ + + + {Number(item.index) + 1} + + + {item.title} + + + {item.artist} + - } + + {new Date(item.duration * 1000).toISOString().slice(14, 19)} + + } /> - - + + ); -}, (prevProps, nextProps) => { + }, + (prevProps, nextProps) => { return prevProps.item.index === nextProps.item.index; -}); + }, +); const styles = StyleSheet.create({ - playlistItem: { - width: '100%', + playlistItem: { + width: '100%', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 40, - }, - leftSide: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 20, - }, - playlistText: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - separator:{ - width: '100%', - height: 1, - backgroundColor: '#252525', - marginTop: 10, - }, -}); \ No newline at end of file + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 40, + }, + leftSide: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 20, + }, + playlistText: { + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 14, + }, + separator: { + width: '100%', + height: 1, + backgroundColor: '#252525', + marginTop: 10, + }, +}); From c46919f637ddf10b9d617322649e9179b37ba4a9 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:48:26 +0300 Subject: [PATCH 18/33] style(drawerNavigation): format code for consistent styling standardize quote styles, enforce uniform indentation, clean up spacing and fix trailing file newline --- src/components/ui/drawerNavigation.tsx | 358 +++++++++++++------------ 1 file changed, 186 insertions(+), 172 deletions(-) diff --git a/src/components/ui/drawerNavigation.tsx b/src/components/ui/drawerNavigation.tsx index 17364c5..3efe14d 100644 --- a/src/components/ui/drawerNavigation.tsx +++ b/src/components/ui/drawerNavigation.tsx @@ -1,180 +1,194 @@ -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; -import { StyleSheet, Text, TouchableWithoutFeedback, View } from "react-native"; -import Animated, { SharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import NormalButton from "../player/normalButton"; -import SVGComponent from "./aimpLogo"; +import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'; +import Animated, { SharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import NormalButton from '../player/normalButton'; +import SVGComponent from './aimpLogo'; interface DrawerProps { - transition: SharedValue; - currentPlaylist?: string; + transition: SharedValue; + currentPlaylist?: string; } export function Drawer({ transition, currentPlaylist }: DrawerProps) { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const animatedDrawer = useAnimatedStyle(() => { - return { - opacity: withTiming(transition.value > 0 ? 1 : 0.2), - transform: [{ translateX: withTiming(transition.value ? 0 : '-100%') },], - zIndex: transition.value > 0 ? 4 : 1, - } - }) - - const drawerStyles = StyleSheet.create({ - drawerContainer: { - position: 'absolute', - top: 0, - left: 0, - - width: '80%', - height: '100%', - - flexDirection: 'row', - overflow: 'hidden', - - borderTopRightRadius: 20, - borderBottomRightRadius: 20, - }, - drawerWrapper: { - position: 'relative', - - width: '100%', - height: '100%', - backgroundColor: '#252525', - padding: 20, - - }, - header: { - width: '100%', - height: 200, - // backgroundColor: '#C6C6C6', - alignItems: 'center', - justifyContent: 'center', - }, - navigation: { - flex: 1, - // backgroundColor: '#C6C6C6', - - gap: 20, - }, - footer: { - position: 'absolute', - left: 20, - - width: '100%', - // backgroundColor: '#C6C6C6', - }, - text: { - color: '#FFF', - fontFamily: 'MPLUS-Bold', - fontSize: 14, - }, - }); - - return ( - - - - - For AIMP v5.40.2709 - App made by ReitanSora - - - Home} - onPress={() => { - transition.value = 0; - router.navigate('/'); - }} - /> - Now Playing} - onPress={() => { - transition.value = 0; - router.navigate('/(player)'); - }} - /> - Current Playlist} - onPress={() => { - transition.value = 0; - router.navigate(currentPlaylist ? - { - pathname: '/playlist/[id]', - params: { id: currentPlaylist } - } - : - { - pathname: '/' - } - ); - }} - /> - - - Settings} - onPress={() => { - transition.value = 0; - router.navigate({ - pathname: "/settings", - }); - }} - /> - - - - - ) + const router = useRouter(); + const insets = useSafeAreaInsets(); + const animatedDrawer = useAnimatedStyle(() => { + return { + opacity: withTiming(transition.value > 0 ? 1 : 0.2), + transform: [{ translateX: withTiming(transition.value ? 0 : '-100%') }], + zIndex: transition.value > 0 ? 4 : 1, + }; + }); + + const drawerStyles = StyleSheet.create({ + drawerContainer: { + position: 'absolute', + top: 0, + left: 0, + + width: '80%', + height: '100%', + + flexDirection: 'row', + overflow: 'hidden', + + borderTopRightRadius: 20, + borderBottomRightRadius: 20, + }, + drawerWrapper: { + position: 'relative', + + width: '100%', + height: '100%', + backgroundColor: '#252525', + padding: 20, + }, + header: { + width: '100%', + height: 200, + // backgroundColor: '#C6C6C6', + alignItems: 'center', + justifyContent: 'center', + }, + navigation: { + flex: 1, + // backgroundColor: '#C6C6C6', + + gap: 20, + }, + footer: { + position: 'absolute', + left: 20, + + width: '100%', + // backgroundColor: '#C6C6C6', + }, + text: { + color: '#FFF', + fontFamily: 'MPLUS-Bold', + fontSize: 14, + }, + }); + + return ( + + + + + For AIMP v5.40.2709 + App made by ReitanSora + + + Home} + onPress={() => { + transition.value = 0; + router.navigate('/'); + }} + /> + Now Playing} + onPress={() => { + transition.value = 0; + router.navigate('/(player)'); + }} + /> + Current Playlist} + onPress={() => { + transition.value = 0; + router.navigate( + currentPlaylist + ? { + pathname: '/playlist/[id]', + params: { id: currentPlaylist }, + } + : { + pathname: '/', + }, + ); + }} + /> + + + Settings} + onPress={() => { + transition.value = 0; + router.navigate({ + pathname: '/settings', + }); + }} + /> + + + + ); } export function DrawerBackground({ transition }: DrawerProps) { - const closeBackground = useAnimatedStyle(() => { - return { - opacity: withTiming(transition.value > 0 ? 0.8 : 0.2), - zIndex: transition.value > 0 ? 1 : -1, - } - }) - - const drawerBackgroundStyles = StyleSheet.create({ - background: { - position: 'absolute', - top: 0, - left: 0, - - width: '100%', - height: '100%', - backgroundColor: '#000', - } - }); - - return ( - - transition.value = 0}> - - - - ) -} \ No newline at end of file + const closeBackground = useAnimatedStyle(() => { + return { + opacity: withTiming(transition.value > 0 ? 0.8 : 0.2), + zIndex: transition.value > 0 ? 1 : -1, + }; + }); + + const drawerBackgroundStyles = StyleSheet.create({ + background: { + position: 'absolute', + top: 0, + left: 0, + + width: '100%', + height: '100%', + backgroundColor: '#000', + }, + }); + + return ( + + (transition.value = 0)}> + + + + ); +} From b48e3027ac9309223558021a20d3e7c09fe857cd Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:49:30 +0300 Subject: [PATCH 19/33] style(togglePlayer): format code to consistent style reformat entire file for consistent indentation, quotes, trailing commas, semicolons, and end-of-file newline --- src/components/playlist/togglePlayer.tsx | 364 ++++++++++++----------- 1 file changed, 185 insertions(+), 179 deletions(-) diff --git a/src/components/playlist/togglePlayer.tsx b/src/components/playlist/togglePlayer.tsx index 4194027..34b39e1 100644 --- a/src/components/playlist/togglePlayer.tsx +++ b/src/components/playlist/togglePlayer.tsx @@ -9,190 +9,196 @@ import NormalButton from '../player/normalButton'; import SoundWave from '../player/soundWave'; const defaultSong: SongInterface = { - album: 'Unknown', - artist: 'Unknown', - bitrate: 0, - genre: 'Unknown', - play_count: 0, - rating: 0, - sample_rate: 0, - title: 'Unknown' -} + album: 'Unknown', + artist: 'Unknown', + bitrate: 0, + genre: 'Unknown', + play_count: 0, + rating: 0, + sample_rate: 0, + title: 'Unknown', +}; export default function TogglePlayer() { - const [songInfo, setSongInfo] = useState(defaultSong); - const [playerState, setPlayerState] = useState('stop'); - const { aimpEvent } = useAIMP(); - const router = useRouter(); - const { server, appColor } = useSettings(); - - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); - }; - - const handlePause = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playpause`); - const data = await response.json(); - if (data) { - const status = playerState; - setPlayerState(status === 'play' ? 'pause' : 'play') - } - } catch { - showToast('Error toggle set play/pause'); - } + const [songInfo, setSongInfo] = useState(defaultSong); + const [playerState, setPlayerState] = useState('stop'); + const { aimpEvent } = useAIMP(); + const router = useRouter(); + const { server, appColor } = useSettings(); + + const showToast = (message: string) => { + ToastAndroid.show(message, ToastAndroid.SHORT); + }; + + const handlePause = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playpause`); + const data = await response.json(); + if (data) { + const status = playerState; + setPlayerState(status === 'play' ? 'pause' : 'play'); + } + } catch { + showToast('Error toggle set play/pause'); + } + }; + + useEffect(() => { + if (aimpEvent.playerState === null) return; + if (aimpEvent.playerState === 2) setPlayerState('play'); + if (aimpEvent.playerState === 1) setPlayerState('pause'); + }, [aimpEvent.playerState]); + + useEffect(() => { + if (aimpEvent.track.album !== '') { + setSongInfo({ + album: aimpEvent.track.album, + artist: aimpEvent.track.artist, + bitrate: Number(aimpEvent.track.bitrate), + genre: aimpEvent.track.genre, + play_count: 0, + rating: 0, + sample_rate: Number(aimpEvent.track.sample_rate), + title: aimpEvent.track.title, + }); + } + }, [aimpEvent.track]); + + useEffect(() => { + if (!server || !server.ip) return; + + const songInfo = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/track/info`); + const info = await response.json(); + + setSongInfo(info); + } catch { + showToast('Error toggle song info'); + } + }; + + const playerState = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playerstate`); + const data = await response.json(); + setPlayerState(data === 0 ? 'stop' : data === 1 ? 'pause' : 'play'); + } catch { + showToast('Error toggle player state'); + } }; - useEffect(() => { - if (aimpEvent.playerState === null) return; - if (aimpEvent.playerState === 2) setPlayerState('play'); - if (aimpEvent.playerState === 1) setPlayerState('pause'); - }, [aimpEvent.playerState]) - - useEffect(() => { - if (aimpEvent.track.album !== '') { - setSongInfo({ - album: aimpEvent.track.album, - artist: aimpEvent.track.artist, - bitrate: Number(aimpEvent.track.bitrate), - genre: aimpEvent.track.genre, - play_count: 0, - rating: 0, - sample_rate: Number(aimpEvent.track.sample_rate), - title: aimpEvent.track.title - }); - } - }, [aimpEvent.track]) - - useEffect(() => { - const songInfo = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/track/info`); - const info = await response.json(); - - setSongInfo(info); - } catch { - showToast('Error toggle song info'); - } - } - - const playerState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playerstate`); - const data = await response.json(); - setPlayerState(data === 0 ? 'stop' : (data === 1 ? 'pause' : 'play')) - } catch { - showToast('Error toggle player state'); - } - } - - songInfo(); - playerState(); - }, [server]) - - return ( - - router.navigate('/(player)')} - > - - - - - - - {songInfo.title} - {songInfo.artist} - - - - handlePause()} - IconSet={MaterialCommunityIcons} - iconName={playerState === 'play' ? 'pause' : 'play'} - iconSize={36} - /> - - - + songInfo(); + playerState(); + }, [server]); + + return ( + + router.navigate('/(player)')}> + + + + + + + + {songInfo.title} + + + {songInfo.artist} + + + + + handlePause()} IconSet={MaterialCommunityIcons} iconName={playerState === 'play' ? 'pause' : 'play'} iconSize={36} /> + - ) + + + ); } const styles = StyleSheet.create({ - playerToggle: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - - width: '100%', - height: 80, - backgroundColor: '#363636', - - overflow: 'hidden', - - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - - elevation: 5, - }, - toggleInside: { - width: '100%', - height: '100%', - padding: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - leftContentToggle: { - paddingRight: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: 20, - }, - soundWave: { - width: 60, - height: 60, - - alignItems: 'center', - justifyContent: 'center', - }, - songInfo: { - flex: 1, - alignItems: 'flex-start', - justifyContent: 'center', - }, - songControl: { - width: 50, - // backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - text: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - playState: { - position: 'absolute', - top: 10, - right: 10, - - width: 5, - height: 5, - backgroundColor: '#C6C6C6', - - alignItems: 'center', - justifyContent: 'center', - - borderRadius: 5, - } -}); \ No newline at end of file + playerToggle: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + + width: '100%', + height: 80, + backgroundColor: '#363636', + + overflow: 'hidden', + + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + + elevation: 5, + }, + toggleInside: { + width: '100%', + height: '100%', + padding: 20, + + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + leftContentToggle: { + paddingRight: 20, + + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 20, + }, + soundWave: { + width: 60, + height: 60, + + alignItems: 'center', + justifyContent: 'center', + }, + songInfo: { + flex: 1, + alignItems: 'flex-start', + justifyContent: 'center', + }, + songControl: { + width: 50, + // backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + text: { + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 14, + }, + playState: { + position: 'absolute', + top: 10, + right: 10, + + width: 5, + height: 5, + backgroundColor: '#C6C6C6', + + alignItems: 'center', + justifyContent: 'center', + + borderRadius: 5, + }, +}); From 2ff105e1cf153a79d312cd3040e180a37b525a8a Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:51:38 +0300 Subject: [PATCH 20/33] refactor(settings): add theme support and replace Toast - Replace Android-exclusive ToastAndroid with cross-platform Alert for save feedback - Update all hardcoded styling colors to use the app's theme system - Remove unused imports and commented code - Format component and style code for improved readability --- src/app/settings/index.tsx | 303 ++++++++++++------------------------- 1 file changed, 97 insertions(+), 206 deletions(-) diff --git a/src/app/settings/index.tsx b/src/app/settings/index.tsx index 6d42104..66fa8dc 100644 --- a/src/app/settings/index.tsx +++ b/src/app/settings/index.tsx @@ -3,218 +3,109 @@ import Header from '@/components/ui/header'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import React, { useState } from 'react'; -import { StyleSheet, Text, TextInput, ToastAndroid, View } from 'react-native'; +import { Alert, StyleSheet, Text, TextInput, View } from 'react-native'; import { GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { scheduleOnRN } from 'react-native-worklets'; import ColorPicker, { LuminanceSlider, Panel3, Preview } from 'reanimated-color-picker'; import { useSettings } from '../../context/appContext'; +import { Colors } from '@/theme'; export default function Settings() { - const { server, setServer, appColor, setAppColor } = useSettings(); - - const [serverIp, setServerIp] = useState(server.ip); - const [serverName, setServerName] = useState(server.name); - const router = useRouter(); - const insets = useSafeAreaInsets(); - - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); - }; - - const onSelectColor = ({ hex }) => { - 'worklet'; - // do something with the selected color. - scheduleOnRN(setAppColor, hex); - }; - - const handleSaveSettings = () => { - try { - setServer({ - ip: serverIp, - name: serverName - }) - showToast('Server saved!'); - } catch { - showToast('Error saving settings'); - } - }; - - return ( - -
router.back()} - hasSearchBar={false} - title='Settings' - /> - - - - - - - - Network - - - - IP Address - - setServerIp(newText)} - /> - - Name - - setServerName(newText)} - - /> - - - handleSaveSettings()} - IconSet={Ionicons} - iconName='save-outline' - /> - - - - - - - Details color - - - - {/* */} - - - {/* */} - - - - - - - ) -}; + const { server, setServer, appColor, setAppColor, theme } = useSettings(); + const currentTheme = Colors[theme]; + const [serverIp, setServerIp] = useState(server.ip); + const [serverName, setServerName] = useState(server.name); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const onSelectColor = ({ hex }: { hex: string }) => { + 'worklet'; + // do something with the selected color. + scheduleOnRN(setAppColor, hex); + }; + + const handleSaveSettings = () => { + try { + setServer({ + ip: serverIp, + name: serverName, + }); + Alert.alert('Success', 'Server settings saved successfully!'); + } catch { + Alert.alert('Error', 'Error saving settings'); + } + }; + + return ( + +
router.back()} hasSearchBar={false} title="Settings" /> + + + + + + + Network + + + + IP Address + + + + Name + + + + + + + + + + + + Details color + + + + + + + + + + + + ); +} const styles = StyleSheet.create({ - container: { - width: '100%', - flex: 1, - // backgroundColor: '#FFF', - - alignItems: 'center', - justifyContent: 'flex-start', - }, - content: { - width: '100%', - }, - section: { - width: '100%', - padding: 20, - - alignItems: 'center', - gap: 20, - }, - header: { - width: '100%', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 20, - }, - sectionTitle: { - color: '#FFF', - fontFamily: 'MPLUS-Bold', - fontSize: 14, - }, - sectionContent: { - width: '100%', - // backgroundColor: '#FFF', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 40 - }, - sectionInputs: { - width: '70%', - // backgroundColor: '#FFF', - - flexDirection: 'column', - gap: 5, - }, - inputWrapper: { - width: '100%', - height: 40, - backgroundColor: '#363636', - paddingHorizontal: 20, - marginBottom: 10, - // paddingVertical: 5, - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - - borderRadius: 20, - }, - input: { - // backgroundColor: '#C6C6C6', - flex: 1, - height: 50, - marginLeft: 10, - - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - sectionText: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - separator: { - width: '100%', - height: 0.5, - backgroundColor: '#8B8B8B', - }, - colorPicker: { - width: '100%', - - gap: 20, - }, -}); \ No newline at end of file + container: { width: '100%', flex: 1, alignItems: 'center', justifyContent: 'flex-start' }, + content: { width: '100%' }, + section: { width: '100%', padding: 20, alignItems: 'center', gap: 20 }, + header: { width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 20 }, + sectionTitle: { fontFamily: 'MPLUS-Bold', fontSize: 14 }, + sectionContent: { width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 40 }, + sectionInputs: { width: '70%', flexDirection: 'column', gap: 5 }, + inputWrapper: { width: '100%', height: 40, paddingHorizontal: 20, marginBottom: 10, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 20 }, + input: { flex: 1, height: 50, marginLeft: 10, fontFamily: 'MPLUS-Regular', fontSize: 14 }, + sectionText: { fontFamily: 'MPLUS-Regular', fontSize: 14 }, + separator: { width: '100%', height: 0.5 }, + colorPicker: { width: '100%', gap: 20 }, +}); From c0cb3c801954f62e3615a60900dbd02b2135e39c Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:52:56 +0300 Subject: [PATCH 21/33] refactor(playlist): clean up component and improve type safety - add proper PlaylistItemType typing for playlist items state - fix index type mismatch in play item onPress handler - wrap handlePlayItem and renderItem with useCallback for performance - add loading fallback for FlatList header and required Text import - clean up code formatting and standardize indentation --- src/app/playlist/[id].tsx | 258 +++++++++++++++++++------------------- 1 file changed, 126 insertions(+), 132 deletions(-) diff --git a/src/app/playlist/[id].tsx b/src/app/playlist/[id].tsx index 5261b52..b024257 100644 --- a/src/app/playlist/[id].tsx +++ b/src/app/playlist/[id].tsx @@ -4,150 +4,144 @@ import TogglePlayer from '@/components/playlist/togglePlayer'; import { Drawer, DrawerBackground } from '@/components/ui/drawerNavigation'; import Header from '@/components/ui/header'; import { useSettings } from '@/context/appContext'; -import { PlaylistInfoProps, PlaylistStatsProps } from '@/types/IPlaylistDetails'; +import { PlaylistInfoProps, PlaylistStatsProps, PlaylistItemType } from '@/types/IPlaylistDetails'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, FlatList, StyleSheet, Text, View } from 'react-native'; import { useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Playlist() { - const [searchbarVisible, setSearchbarVisible] = useState(false); - const [searchValue, setSearchValue] = useState(''); - const [playlistInfo, setPlaylistInfo] = useState(); - const [playlistStats, setPlaylistStats] = useState(); - const [playlistItems, setPlaylistItems] = useState() - const [currentPlaylist, setCurrentPlaylist] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const insets = useSafeAreaInsets(); - const transition = useSharedValue(0); - const { id } = useLocalSearchParams(); - const { server } = useSettings(); - - const filteredData = useMemo(() => { - if (!playlistItems) return []; - if (!searchValue.trim()) return playlistItems; - - return playlistItems.filter((song) => song.title?.toUpperCase().includes(searchValue.toUpperCase())); - }, [searchValue, playlistItems]) - - const handleShowDrawer = () => { - transition.value = withTiming(transition.value ? 0 : 1, { duration: 500 }); - } - - const handlePlayItem = async (index: string) => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist/play?id=${id}&index=${index}`); - await response.json(); - } catch (error) { - console.log('Error play item', error); - } + const [searchbarVisible, setSearchbarVisible] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [playlistInfo, setPlaylistInfo] = useState(); + const [playlistStats, setPlaylistStats] = useState(); + const [playlistItems, setPlaylistItems] = useState(); + const [currentPlaylist, setCurrentPlaylist] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const insets = useSafeAreaInsets(); + const transition = useSharedValue(0); + const { id } = useLocalSearchParams(); + const { server } = useSettings(); + + const filteredData = useMemo(() => { + if (!playlistItems) return []; + if (!searchValue.trim()) return playlistItems; + + return playlistItems.filter((song) => song.title?.toUpperCase().includes(searchValue.toUpperCase())); + }, [searchValue, playlistItems]); + + const handleShowDrawer = () => { + transition.value = withTiming(transition.value ? 0 : 1, { duration: 500 }); + }; + + const handlePlayItem = useCallback( + async (index: string) => { + try { + const response = await fetch(`http://${server.ip}:3553/playlist/play?id=${id}&index=${index}`); + await response.json(); + } catch (error) { + console.log('Error play item', error); + } + }, + [id, server.ip], + ); + + const renderItem = useCallback(({ item }: { item: PlaylistItemType }) => handlePlayItem(item.index.toString())} />, [handlePlayItem]); + + useEffect(() => { + const playlistInfo = async () => { + try { + const infoResponse = await fetch(`http://${server.ip}:3553/playlist/info?id=${id}`); + const infoData = await infoResponse.json(); + setPlaylistInfo(infoData); + + const statsResponse = await fetch(`http://${server.ip}:3553/playlist/stats?id=${id}`); + const statsData = await statsResponse.json(); + setPlaylistStats(statsData); + } catch (error) { + console.log('Error get playlist info', error); + } }; - const renderItem = useCallback(({ item }: object | any) => ( - handlePlayItem(item.index)} - /> - ), []); - - useEffect(() => { - - const playlistInfo = async () => { - try { - const infoResponse = await fetch(`http://${server.ip}:3553/playlist/info?id=${id}`); - const infoData = await infoResponse.json(); - setPlaylistInfo(infoData); - - const statsResponse = await fetch(`http://${server.ip}:3553/playlist/stats?id=${id}`); - const statsData = await statsResponse.json(); - setPlaylistStats(statsData); - } catch (error) { - console.log('Error get playlist info', error); - } - }; - - const playlistItems = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist/items?id=${id}`); - const data = await response.json(); - setPlaylistItems(data); - } catch (error) { - console.log('Error get playlist items', error); - } - }; - - const currentPlaylist = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist/current`); - const data = await response.json(); - setCurrentPlaylist(data.id); - } catch (error) { - console.log('Error get playlist current', error); - } - }; + const playlistItems = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playlist/items?id=${id}`); + const data = await response.json(); + setPlaylistItems(data); + } catch (error) { + console.log('Error get playlist items', error); + } + }; - playlistInfo(); - playlistItems(); - currentPlaylist(); - setIsLoading(false); - }, [id, server]) + const currentPlaylist = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playlist/current`); + const data = await response.json(); + setCurrentPlaylist(data.id); + } catch (error) { + console.log('Error get playlist current', error); + } + }; - return ( + playlistInfo(); + playlistItems(); + currentPlaylist(); + setIsLoading(false); + }, [id, server]); + + return ( + <> + {isLoading ? ( + + + + ) : ( <> - {isLoading ? - - - - : - <> - - - -
handleShowDrawer()} - title={'a'} - /> - item.index.toString()} - style={{ width: '100%', }} - contentContainerStyle={{ paddingBottom: 80 + insets.bottom, gap: 10 }} - initialNumToRender={10} - maxToRenderPerBatch={10} - windowSize={5} - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - - } - renderItem={renderItem} - /> - - - - - } + + + +
handleShowDrawer()} + title={'a'} + /> + item.index.toString()} + style={{ width: '100%' }} + contentContainerStyle={{ + paddingBottom: 80 + insets.bottom, + gap: 10, + }} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={5} + showsVerticalScrollIndicator={false} + ListHeaderComponent={playlistInfo && playlistStats ? : Loading...} + renderItem={renderItem} + /> + + - ) -}; + )} + + ); +} const styles = StyleSheet.create({ - container: { - width: '100%', - flex: 1, - // backgroundColor: '#FFF', - - alignItems: 'center', - justifyContent: 'flex-start', - }, -}); \ No newline at end of file + container: { + width: '100%', + flex: 1, + // backgroundColor: '#FFF', + + alignItems: 'center', + justifyContent: 'flex-start', + }, +}); From 2a21370e15b5b088d9e3b00b327766605b228349 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 02:56:35 +0300 Subject: [PATCH 22/33] style(player layout): format code for improved readability standardize indentation, add trailing semicolons, prettify JSX structure, and fix missing trailing newline in the file --- src/app/(player)/_layout.tsx | 25 +- src/app/(player)/index.tsx | 1113 ++++++++++++++++------------------ 2 files changed, 544 insertions(+), 594 deletions(-) diff --git a/src/app/(player)/_layout.tsx b/src/app/(player)/_layout.tsx index 188371e..2bd3efe 100644 --- a/src/app/(player)/_layout.tsx +++ b/src/app/(player)/_layout.tsx @@ -1,11 +1,18 @@ -import { Stack } from 'expo-router' -import React from 'react' +import { Stack } from 'expo-router'; +import React from 'react'; export default function PlayerLayout() { - return ( - - - - - ) -} \ No newline at end of file + return ( + + + + + ); +} diff --git a/src/app/(player)/index.tsx b/src/app/(player)/index.tsx index bac858c..6d850e5 100644 --- a/src/app/(player)/index.tsx +++ b/src/app/(player)/index.tsx @@ -9,606 +9,549 @@ import { Image, ImageBackground } from 'expo-image'; import { useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { Dimensions, StyleSheet, Text, ToastAndroid, View } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming -} from 'react-native-reanimated'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSettings } from '../../context/appContext'; const { height: screenHeight } = Dimensions.get('window'); const defaultSong: SongInterface = { - album: 'Unknown', - artist: 'Unknown', - bitrate: 0, - genre: 'Unknown', - play_count: 0, - rating: 0, - sample_rate: 0, - title: 'Unknown' -} + album: 'Unknown', + artist: 'Unknown', + bitrate: 0, + genre: 'Unknown', + play_count: 0, + rating: 0, + sample_rate: 0, + title: 'Unknown', +}; export default function Home() { - const [imageUri, setImageUri] = useState(); - const [songInfo, setSongInfo] = useState(defaultSong); - const [songDuration, setSongDuration] = useState(0); - const [repeatState, setRepeatState] = useState(false); - const [shuffleState, setShuffleState] = useState(false); - const [muteState, setMuteState] = useState(false); - const [volumeState, setVolumeState] = useState(0); - const [playerState, setPlayerState] = useState('stop'); - const [showSlider, setShowSlider] = useState(false); - const [songPosition, setSongPosition] = useState(0); - const transition = useSharedValue(0); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { server } = useSettings(); - - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); + const [imageUri, setImageUri] = useState(); + const [songInfo, setSongInfo] = useState(defaultSong); + const [songDuration, setSongDuration] = useState(0); + const [repeatState, setRepeatState] = useState(false); + const [shuffleState, setShuffleState] = useState(false); + const [muteState, setMuteState] = useState(false); + const [volumeState, setVolumeState] = useState(0); + const [playerState, setPlayerState] = useState('stop'); + const [showSlider, setShowSlider] = useState(false); + const [songPosition, setSongPosition] = useState(0); + const transition = useSharedValue(0); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { server } = useSettings(); + + const showToast = (message: string) => { + ToastAndroid.show(message, ToastAndroid.SHORT); + }; + + const { aimpEvent } = useAIMP(); + + const animatedSlider = useAnimatedStyle(() => { + return { + opacity: transition.value, + transform: [{ scale: withTiming(transition.value === 0 ? 0.9 : 1) }], + zIndex: transition.value > 0 ? 1 : -1, }; - - const { aimpEvent } = useAIMP(); - - const animatedSlider = useAnimatedStyle(() => { - return { - opacity: transition.value, - transform: [ - { scale: withTiming(transition.value === 0 ? 0.9 : 1) }, - ], - zIndex: transition.value > 0 ? 1 : -1, - }; - }); - const animatedButtons = useAnimatedStyle(() => { - return { - opacity: 1 - transition.value, - transform: [{ scale: 1 - (transition.value * 0.1) }], - zIndex: transition.value < 1 ? 1 : -1, - }; - }); - - const handleRepeatState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/track/repeat`, { - method: 'POST', - body: JSON.stringify({ repeat: repeatState ? 0 : 1 }) - }); - const data = await response.json(); - if (data) setRepeatState(!repeatState); - } catch { - showToast('Error set repeat state'); - } + }); + const animatedButtons = useAnimatedStyle(() => { + return { + opacity: 1 - transition.value, + transform: [{ scale: 1 - transition.value * 0.1 }], + zIndex: transition.value < 1 ? 1 : -1, + }; + }); + + const handleRepeatState = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/track/repeat`, { + method: 'POST', + body: JSON.stringify({ repeat: repeatState ? 0 : 1 }), + }); + const data = await response.json(); + if (data) setRepeatState(!repeatState); + } catch { + showToast('Error set repeat state'); } - - const handleShuffleState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/shuffle`, { - method: 'POST', - body: JSON.stringify({ shuffle: shuffleState ? 0 : 1 }) - }); - const data = await response.json(); - if (data) setShuffleState(!shuffleState); - } catch { - showToast('Error set shuffle state'); - } + }; + + const handleShuffleState = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/shuffle`, { + method: 'POST', + body: JSON.stringify({ shuffle: shuffleState ? 0 : 1 }), + }); + const data = await response.json(); + if (data) setShuffleState(!shuffleState); + } catch { + showToast('Error set shuffle state'); } - - const handleMuteState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/mute`, { - method: 'POST', - body: JSON.stringify({ mute: muteState ? 0 : 1 }) - }); - const data = await response.json(); - if (data) setMuteState(!muteState); - } catch { - showToast('Error set mute state'); - } + }; + + const handleMuteState = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/mute`, { + method: 'POST', + body: JSON.stringify({ mute: muteState ? 0 : 1 }), + }); + const data = await response.json(); + if (data) setMuteState(!muteState); + } catch { + showToast('Error set mute state'); } - - const handleVolumeState = async (volume: number) => { - try { - const response = await fetch(`http://${server.ip}:3553/volume`, { - method: 'POST', - body: JSON.stringify({ level: volume }) - }); - const data = await response.json(); - if (data) setVolumeState(volume); - if (volume === 0 && !muteState) handleMuteState(); - else if (volume > 0 && muteState) handleMuteState(); - } catch { - showToast('Error set volume'); - } + }; + + const handleVolumeState = async (volume: number) => { + try { + const response = await fetch(`http://${server.ip}:3553/volume`, { + method: 'POST', + body: JSON.stringify({ level: volume }), + }); + const data = await response.json(); + if (data) setVolumeState(volume); + if (volume === 0 && !muteState) handleMuteState(); + else if (volume > 0 && muteState) handleMuteState(); + } catch { + showToast('Error set volume'); } - - const handleNextTrack = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/next`); - await response.json(); - } catch { - showToast('Error next track'); - } - }; - - const handlePreviousTrack = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/previous`); - await response.json(); - } catch { - showToast('Error previous track'); - } - }; - - const handlePause = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playpause`); - const data = await response.json(); - if (data) { - const status = playerState; - setPlayerState(status === 'play' ? 'pause' : 'play') - } - } catch { - showToast('Error set play/pause state'); - } - }; - - const handleShowSlider = async () => { - setShowSlider(!showSlider); - transition.value = withTiming(showSlider ? 0 : 1, { duration: 250 }); - }; - - const handleSongPosition = async (position: number) => { - try { - const response = await fetch(`http://${server.ip}:3553/track/position`, { - method: 'POST', - body: JSON.stringify({ position: position }) - }) - const data = await response.json(); - if (data) setSongPosition(position) - } catch { - showToast('Error set song position'); - } + }; + + const handleNextTrack = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/next`); + await response.json(); + } catch { + showToast('Error next track'); + } + }; + + const handlePreviousTrack = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/previous`); + await response.json(); + } catch { + showToast('Error previous track'); + } + }; + + const handlePause = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playpause`); + const data = await response.json(); + if (data) { + const status = playerState; + setPlayerState(status === 'play' ? 'pause' : 'play'); + } + } catch { + showToast('Error set play/pause state'); + } + }; + + const handleShowSlider = async () => { + setShowSlider(!showSlider); + transition.value = withTiming(showSlider ? 0 : 1, { duration: 250 }); + }; + + const handleSongPosition = async (position: number) => { + try { + const response = await fetch(`http://${server.ip}:3553/track/position`, { + method: 'POST', + body: JSON.stringify({ position: position }), + }); + const data = await response.json(); + if (data) setSongPosition(position); + } catch { + showToast('Error set song position'); + } + }; + + useEffect(() => { + if (!server.ip) return; + + const fetchAllPlayerState = async () => { + try { + const requests = [ + fetch(`http://${server.ip}:3553/track/info`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/track/duration`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/track/repeat`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/shuffle`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/mute`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/volume`).then((res) => res.json()), + fetch(`http://${server.ip}:3553/playerstate`).then((res) => res.json()), + ]; + + const results = await Promise.allSettled(requests); + + if (results[0].status === 'fulfilled') setSongInfo(results[0].value); + if (results[1].status === 'fulfilled') setSongDuration(Number(results[1].value)); + if (results[2].status === 'fulfilled') setRepeatState(results[2].value === -1); + if (results[3].status === 'fulfilled') setShuffleState(results[3].value === -1); + if (results[4].status === 'fulfilled') setMuteState(results[4].value === 1); + if (results[5].status === 'fulfilled') setVolumeState(Number(results[5].value)); + if (results[6].status === 'fulfilled') setPlayerState(results[6].value === 0 ? 'stop' : results[6].value === 1 ? 'pause' : 'play'); + + setImageUri(`http://${server.ip}:3553/track/cover?t=${new Date().getTime()}`); + } catch (e) { + console.log('Error fetching states', e); + } }; - useEffect(() => { - if (aimpEvent.playerState === null) return; - if (aimpEvent.playerState === 2) setPlayerState('play'); - if (aimpEvent.playerState === 1) setPlayerState('pause'); - }, [aimpEvent.playerState]) - - useEffect(() => { - if (aimpEvent.repeatState === null) return; - if (aimpEvent.repeatState !== repeatState) setRepeatState(aimpEvent.repeatState); - }, [aimpEvent.repeatState, repeatState]) - - useEffect(() => { - if (aimpEvent.shuffleState === null) return; - if (aimpEvent.shuffleState !== shuffleState) setShuffleState(aimpEvent.shuffleState); - }, [aimpEvent.shuffleState]) - - useEffect(() => { - if (aimpEvent.muteState === null) return; - if (aimpEvent.muteState !== muteState) setMuteState(aimpEvent.muteState); - }, [aimpEvent.muteState, muteState]) - - useEffect(() => { - if (aimpEvent.position !== 0) setSongPosition(Number(aimpEvent.position)); - }, [aimpEvent.position]) - - useEffect(() => { - if (aimpEvent.track.album !== '') { - setSongInfo({ - album: aimpEvent.track.album, - artist: aimpEvent.track.artist, - bitrate: aimpEvent.track.bitrate, - genre: aimpEvent.track.genre, - play_count: aimpEvent.track.play_count, - rating: aimpEvent.track.rating, - sample_rate: aimpEvent.track.sample_rate, - title: aimpEvent.track.title - }); - } - }, [aimpEvent.track]) - - useEffect(() => { - if (Math.trunc(aimpEvent.track.duration * 1000) !== songDuration && aimpEvent.track.duration !== 0) { - setSongDuration(Math.trunc(aimpEvent.track.duration * 1000)) - } - }, [aimpEvent.track.duration, songDuration]) - - useEffect(() => { - const songCover = async () => { - try { - const timestamp = new Date().getTime(); - const url = `http://${server.ip}:3553/track/cover?t=${timestamp}`; - setImageUri(url); - } catch { - showToast('Error get actual song cover'); - } - }; - - if (aimpEvent.track.title) { - songCover(); - } - }, [aimpEvent.track.title, aimpEvent.track.artist, server]) - - useEffect(() => { - const songCover = async () => { - try { - const timestamp = new Date().getTime(); - const url = `http://${server.ip}:3553/track/cover?t=${timestamp}`; - setImageUri(url); - } catch { - showToast('Error get song cover'); - } - }; - - const songInfo = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/track/info`); - const info = await response.json(); + fetchAllPlayerState(); + }, [server]); + + useEffect(() => { + if (aimpEvent.playerState === null) return; + if (aimpEvent.playerState === 2) setPlayerState('play'); + if (aimpEvent.playerState === 1) setPlayerState('pause'); + }, [aimpEvent.playerState]); + + useEffect(() => { + if (aimpEvent.repeatState === null) return; + if (aimpEvent.repeatState !== repeatState) setRepeatState(aimpEvent.repeatState); + }, [aimpEvent.repeatState, repeatState]); + + useEffect(() => { + if (aimpEvent.shuffleState === null) return; + if (aimpEvent.shuffleState !== shuffleState) setShuffleState(aimpEvent.shuffleState); + }, [aimpEvent.shuffleState, shuffleState]); + + useEffect(() => { + if (aimpEvent.muteState === null) return; + if (aimpEvent.muteState !== muteState) setMuteState(aimpEvent.muteState); + }, [aimpEvent.muteState, muteState]); + + useEffect(() => { + if (aimpEvent.volumeState !== null) setVolumeState(aimpEvent.volumeState); + }, [aimpEvent.volumeState]); + + useEffect(() => { + if (aimpEvent.position !== 0) setSongPosition(Number(aimpEvent.position)); + }, [aimpEvent.position]); + + useEffect(() => { + if (aimpEvent.track.album !== '') { + setSongInfo({ + album: aimpEvent.track.album, + artist: aimpEvent.track.artist, + bitrate: aimpEvent.track.bitrate, + genre: aimpEvent.track.genre, + play_count: aimpEvent.track.play_count, + rating: aimpEvent.track.rating, + sample_rate: aimpEvent.track.sample_rate, + title: aimpEvent.track.title, + }); + setImageUri(`http://${server.ip}:3553/track/cover?t=${new Date().getTime()}`); + } + }, [aimpEvent.track, server.ip]); - setSongInfo(info); - } catch { - showToast('Error get song info'); - } - } - - const songDuration = async () => { - try { - const durationResponse = await fetch(`http://${server.ip}:3553/track/duration`) - const durationData = await durationResponse.json(); - const number = Number(durationData); - setSongDuration(number); - } catch { - showToast('Error get song duration'); - } - } - - const repeatState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/track/repeat`); - const data = await response.json(); - setRepeatState(data === -1 ? true : false) - } catch { - showToast('Error get repeat state'); - } - } - - const shuffleState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/shuffle`); - const data = await response.json(); - setShuffleState(data === -1 ? true : false) - } catch { - showToast('Error get shuffle state'); - } - } - - const muteState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/mute`); - const data = await response.json(); - setMuteState(data === 1 ? true : false); - } catch { - showToast('Error get mute state'); - } - } - - const volumeState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/volume`); - const data = await response.json(); - const number = Number(data); - setVolumeState(number); - } catch { - showToast('Error get volume state'); - } - } - - const playerState = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playerstate`); - const data = await response.json(); - setPlayerState(data === 0 ? 'stop' : (data === 1 ? 'pause' : 'play')) - } catch { - showToast('Error get player state'); + useEffect(() => { + if (Math.trunc(aimpEvent.track.duration * 1000) !== songDuration && aimpEvent.track.duration !== 0) { + setSongDuration(Math.trunc(aimpEvent.track.duration * 1000)); + } + }, [aimpEvent.track.duration, songDuration]); + + return ( + <> + + + + + + router.back()} IconSet={Ionicons} iconName="chevron-down" /> + + Playing from + {server.name} + + + router.navigate({ + pathname: '/(player)/songDetails', + params: { song: JSON.stringify(songInfo) }, + }) } - } - - songCover(); - songInfo(); - songDuration(); - repeatState(); - shuffleState(); - muteState(); - volumeState(); - playerState(); - }, [server]) - - return ( - <> - - - - - - router.back()} - IconSet={Ionicons} - iconName='chevron-down' - /> - - Playing from - {server.name} - - router.navigate({ pathname: '/(player)/songDetails', params: { song: JSON.stringify(songInfo) } })} - IconSet={MaterialCommunityIcons} - iconName='dots-vertical' - /> - - - - {imageUri && - } - - - {songInfo.title} - {/* {songInfo.album} */} - {songInfo.artist} - - - - - - - handleShowSlider()} - insideStyle={{ flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }} - TextElement={ - <> - - {volumeState} - - } - /> - - handleVolumeState(e)} - /> - - - handleRepeatState()} - IconSet={Ionicons} - iconName='repeat' - iconColor={repeatState ? "white" : "#8b8b8b"} - /> - handleShuffleState()} - IconSet={Ionicons} - iconName='shuffle' - iconColor={shuffleState ? "white" : "#8b8b8b"} - /> - handleMuteState()} - IconSet={Ionicons} - iconName='volume-mute-outline' - iconColor={muteState ? "white" : "#8b8b8b"} - /> - handleShowSlider()} - IconSet={Ionicons} - iconName='volume-medium-outline' - /> - - - - {new Date(songPosition).toISOString().slice(14, 19)} - handleSongPosition(e)} - /> - {new Date(songDuration).toISOString().slice(14, 19)} - - - handlePreviousTrack()} - IconSet={Ionicons} - iconName='play-skip-back' - /> - handlePause()} - containerStyle={{ width: 60, height: 60, borderColor: '#FFF', borderWidth: 2 }} - IconSet={MaterialCommunityIcons} - iconName={playerState === 'play' ? 'pause' : 'play'} - iconSize={36} - /> - handleNextTrack()} - IconSet={Ionicons} - iconName='play-skip-forward' - /> - - - - - - ) + IconSet={MaterialCommunityIcons} + iconName="dots-vertical" + /> + + + + {imageUri && } + + + + {songInfo.title} + + {/* {songInfo.album} */} + + {songInfo.artist} + + + + + + + + handleShowSlider()} + insideStyle={{ + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }} + TextElement={ + <> + + + {volumeState} + + + } + /> + + handleVolumeState(e)} + /> + + + handleRepeatState()} IconSet={Ionicons} iconName="repeat" iconColor={repeatState ? 'white' : '#8b8b8b'} /> + handleShuffleState()} IconSet={Ionicons} iconName="shuffle" iconColor={shuffleState ? 'white' : '#8b8b8b'} /> + handleMuteState()} IconSet={Ionicons} iconName="volume-mute-outline" iconColor={muteState ? 'white' : '#8b8b8b'} /> + handleShowSlider()} IconSet={Ionicons} iconName="volume-medium-outline" /> + + + + {new Date(songPosition).toISOString().slice(14, 19)} + handleSongPosition(e)} + /> + {new Date(songDuration).toISOString().slice(14, 19)} + + + handlePreviousTrack()} IconSet={Ionicons} iconName="play-skip-back" /> + handlePause()} + containerStyle={{ + width: 60, + height: 60, + borderColor: '#FFF', + borderWidth: 2, + }} + IconSet={MaterialCommunityIcons} + iconName={playerState === 'play' ? 'pause' : 'play'} + iconSize={36} + /> + handleNextTrack()} IconSet={Ionicons} iconName="play-skip-forward" /> + + + + + + ); } const styles = StyleSheet.create({ - container: { - position: 'relative', - - width: '100%', - height: '100%', - // backgroundColor: '#FFF', - }, - header: { - width: '100%', - height: 60, - paddingHorizontal: 20, - // backgroundColor: '#FFF', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerInfo: { - flex: 1, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - }, - headerTitle: { - color: '#C6C6C6', - fontFamily: "MPLUS-Regular", - fontSize: 10, - textAlign: 'center', - textTransform: 'uppercase' - }, - headerSubtitle: { - color: '#FFF', - fontFamily: "MPLUS-Regular", - fontSize: 12, - textAlign: 'center', - }, - content: { - height: 'auto', - // backgroundColor: '#363636', - paddingHorizontal: 20, - paddingVertical: 10, - - alignContent: 'flex-start', - justifyContent: 'flex-start', - gap: 20, - }, - songImage: { - width: '100%', - - alignItems: 'center', - justifyContent: 'flex-end', - overflow: 'hidden', - - borderRadius: 20, - - elevation: 5, - }, - image: { - width: '100%', - height: '100%', - }, - songInfo: { - // backgroundColor: '#c6c6c6', - paddingTop: 10, - - alignItems: 'center', - justifyContent: 'flex-start', - gap: 20, - }, - songTitle: { - color: '#FFF', - fontFamily: "MPLUS-ExtraBold", - fontSize: 24, - }, - songAlbum: { - color: '#8B8B8B', - fontFamily: "MPLUS", - fontSize: 14, - }, - songArtist: { - color: '#8B8B8B', - fontFamily: "MPLUS", - fontSize: 14, - }, - controls: { - // backgroundColor: '#FFF', - marginTop: 10, - paddingVertical: 20, - - flex: 1, - alignItems: 'center', - justifyContent: 'space-between', - gap: 20, - }, - playerExtraControls: { - position: 'relative', - - width: '100%', - height: 60, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - extraControlsWrapper: { - position: 'absolute', - - width: '100%', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - }, - playerSlider: { - position: 'relative', - - width: '100%', - height: 60, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - playerSliderTime: { - color: '#FFF', - fontFamily: 'MPLUS-Bold' - }, - playerBasicControls: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 40, - - zIndex: 20, - }, -}) \ No newline at end of file + container: { + position: 'relative', + + width: '100%', + height: '100%', + // backgroundColor: '#FFF', + }, + header: { + width: '100%', + height: 60, + paddingHorizontal: 20, + // backgroundColor: '#FFF', + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerInfo: { + flex: 1, + // backgroundColor: '#FFF', + paddingHorizontal: 20, + + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + color: '#C6C6C6', + fontFamily: 'MPLUS-Regular', + fontSize: 10, + textAlign: 'center', + textTransform: 'uppercase', + }, + headerSubtitle: { + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 12, + textAlign: 'center', + }, + content: { + height: 'auto', + // backgroundColor: '#363636', + paddingHorizontal: 20, + paddingVertical: 10, + + alignContent: 'flex-start', + justifyContent: 'flex-start', + gap: 20, + }, + songImage: { + width: '100%', + + alignItems: 'center', + justifyContent: 'flex-end', + overflow: 'hidden', + + borderRadius: 20, + + elevation: 5, + }, + image: { + width: '100%', + height: '100%', + }, + songInfo: { + // backgroundColor: '#c6c6c6', + paddingTop: 10, + + alignItems: 'center', + justifyContent: 'flex-start', + gap: 20, + }, + songTitle: { + color: '#FFF', + fontFamily: 'MPLUS-ExtraBold', + fontSize: 24, + }, + songAlbum: { + color: '#8B8B8B', + fontFamily: 'MPLUS', + fontSize: 14, + }, + songArtist: { + color: '#8B8B8B', + fontFamily: 'MPLUS', + fontSize: 14, + }, + controls: { + // backgroundColor: '#FFF', + marginTop: 10, + paddingVertical: 20, + + flex: 1, + alignItems: 'center', + justifyContent: 'space-between', + gap: 20, + }, + playerExtraControls: { + position: 'relative', + + width: '100%', + height: 60, + // backgroundColor: '#FFF', + paddingHorizontal: 20, + + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + extraControlsWrapper: { + position: 'absolute', + + width: '100%', + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + }, + playerSlider: { + position: 'relative', + + width: '100%', + height: 60, + // backgroundColor: '#FFF', + paddingHorizontal: 20, + + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + playerSliderTime: { + color: '#FFF', + fontFamily: 'MPLUS-Bold', + }, + playerBasicControls: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 40, + + zIndex: 20, + }, +}); From ccccf3f885a16ce4aebd5aa45093fd3de3c40532 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Fri, 15 May 2026 03:00:42 +0300 Subject: [PATCH 23/33] feat(app): add theme support, responsive layout, auto redirect and loading Add dynamic theming using app settings across all screens Add auto-redirect to connect screen when no server IP is configured Implement responsive playlist grid on home screen Add loading skeleton states during playlist data fetch Improve navigation animations, styling and error handling --- src/app/_layout.tsx | 97 +++++++----- src/app/index.tsx | 370 ++++++++++++++++++++++++-------------------- 2 files changed, 261 insertions(+), 206 deletions(-) diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 967dd97..2134187 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,54 +1,67 @@ -import { DefaultTheme, ThemeProvider } from "@react-navigation/native"; -import { Stack } from "expo-router"; +import { SettingsProvider, useSettings } from '@/context/appContext'; +import { Colors } from '@/theme'; +import { DefaultTheme, ThemeProvider } from '@react-navigation/native'; +import { Href, Stack, useRouter, useSegments } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; -import { useEffect } from "react"; -import { SettingsProvider, useSettings } from "../context/appContext"; +import { useEffect } from 'react'; SplashScreen.preventAutoHideAsync(); -SplashScreen.setOptions({ - duration: 400, - fade: true, -}); - function RootLayoutNav() { + const { isLoaded, server, theme } = useSettings(); + const router = useRouter(); + const segments = useSegments(); + const currentThemeColors = Colors[theme]; - const { isLoaded } = useSettings(); - - const MyTheme = { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: '#000' - }, - }; + const MyTheme = { + ...DefaultTheme, + dark: theme === 'dark', + colors: { + ...DefaultTheme.colors, + primary: currentThemeColors.primary, + background: currentThemeColors.background, + card: currentThemeColors.surface, + text: currentThemeColors.text, + border: currentThemeColors.border, + notification: currentThemeColors.primary, + }, + }; - useEffect(() => { - if (isLoaded) { - SplashScreen.hideAsync(); - } - }, [isLoaded]) + useEffect(() => { + if (!isLoaded) return; - if (!isLoaded) { - return null; + const isConnectScreen = String(segments[0]) === 'connect'; + if (!server.ip && !isConnectScreen) { + router.replace('/connect' as Href); } + SplashScreen.hideAsync(); + }, [isLoaded, server.ip, segments, router]); + + if (!isLoaded) return null; - return ( - - - - - - - - - ) -}; + return ( + + + + + + + + + + ); +} export default function HomeLayout() { - return ( - - - - ) -} \ No newline at end of file + return ( + + + + ); +} diff --git a/src/app/index.tsx b/src/app/index.tsx index f4e3d58..ba03f7a 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -4,184 +4,226 @@ import Header from '@/components/ui/header'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; -import { FlatList, StyleSheet, Text, ToastAndroid, TouchableNativeFeedback, View } from 'react-native'; +import { FlatList, StyleSheet, Text, ToastAndroid, TouchableNativeFeedback, useWindowDimensions, View } from 'react-native'; import { useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSettings } from '../context/appContext'; +import { Colors } from '@/theme'; +import Skeleton from '@/components/ui/Skeleton'; + +type Playlist = { + id: string; + itemCount: number; + name: string; +}; + +type EmptyPlaylistItem = { + empty: true; +}; + +type PlaylistItem = Playlist | EmptyPlaylistItem; export default function Home() { + const [playlists, setPlaylists] = useState([]); + const [currentPlaylist, setCurrentPlaylist] = useState(''); + const [searchbarVisible, setSearchbarVisible] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const transition = useSharedValue(0); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { server, appColor, theme } = useSettings(); + const currentThemeColors = Colors[theme]; + const { width } = useWindowDimensions(); + + const numColumns = width > 600 ? 3 : 2; + + const showToast = (message: string) => { + ToastAndroid.show(message, ToastAndroid.SHORT); + }; + + const formatData = (data: Playlist[], columns: number): PlaylistItem[] => { + const newData: PlaylistItem[] = [...data]; + + const numberOfFullRows = Math.floor(newData.length / columns); + + let numberOfElementsLastRow = newData.length - numberOfFullRows * columns; + + while (numberOfElementsLastRow !== columns && numberOfElementsLastRow !== 0) { + newData.push({ empty: true }); + numberOfElementsLastRow++; + } - const [playlists, setPlaylists] = useState(); - const [currentPlaylist, setCurrentPlaylist] = useState(''); - const [searchbarVisible, setSearchbarVisible] = useState(false); - const [searchValue, setSearchValue] = useState(''); - const transition = useSharedValue(0); - const insets = useSafeAreaInsets(); - const router = useRouter(); - const { server, appColor } = useSettings(); - - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); - }; + return newData; + }; - const formatData = (data: object[], columns: number) => { - const numberOfFullRows = Math.floor(data.length / columns); - let numberOfElementsLastRow = data.length - (numberOfFullRows * columns); + const filteredData = useMemo(() => { + if (!playlists) return []; + if (!searchValue.trim()) return playlists; - while (numberOfElementsLastRow !== columns && numberOfElementsLastRow !== 0) { - data.push({ empty: true }); - numberOfElementsLastRow++; - } + return playlists.filter((playlist) => playlist.name?.toUpperCase().includes(searchValue.toUpperCase())); + }, [searchValue, playlists]); - return data; - }; + const handleShowDrawer = () => { + transition.value = withTiming(transition.value ? 0 : 1, { duration: 500 }); + }; - const filteredData = useMemo(() => { - if (!playlists) return []; - if (!searchValue.trim()) return playlists; + useEffect(() => { + if (!server || !server.ip) return; - return playlists.filter((playlist) => playlist.name?.toUpperCase().includes(searchValue.toUpperCase())); - }, [searchValue, playlists]) + const playlistInfo = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playlist`); + const info = await response.json(); - const handleShowDrawer = () => { - transition.value = withTiming(transition.value ? 0 : 1, { duration: 500 }); - } + setPlaylists(info); + } catch (e) { + showToast('Error get playlist info'); + console.log('Error fetching playlists data', e); + } finally { + setIsLoading(false); + } + }; + + const currentPlaylist = async () => { + try { + const response = await fetch(`http://${server.ip}:3553/playlist/current`); + const data = await response.json(); + setCurrentPlaylist(data.id); + } catch (e) { + // showToast('Error get current playlist'); + console.log(e); + } + }; + + playlistInfo(); + currentPlaylist(); + }, [server]); - useEffect(() => { - const playlistInfo = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist`); - const info = await response.json(); - - setPlaylists(info); - } catch (e) { - // showToast('Error get playlist info'); - console.log(e) - } - }; - - const currentPlaylist = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist/current`); - const data = await response.json(); - setCurrentPlaylist(data.id); - } catch (e) { - // showToast('Error get current playlist'); - console.log(e) - } - }; - - playlistInfo(); - currentPlaylist(); - }, [server]) + const renderSkeletons = () => { return ( - <> - - - -
handleShowDrawer()} - /> - - {playlists && - item.id} - numColumns={2} - ListHeaderComponent={() => { - return ( - Playlists - ) - }} - columnWrapperStyle={{ gap: 20 }} - contentContainerStyle={{ width: '100%', gap: 20 }} - renderItem={({ item }) => { - if (item.empty) { - return () - } - return ( - - router.navigate({ pathname: '/playlist/[id]', params: { id: item.id } })} - > - - - - {item.name} - {item.itemCount} items - - {/* {item.id === currentPlaylist && - - } */} - - - - ) - }} - />} - - - - - ) + + {[...Array(6)].map((_, index) => ( + + + + ))} + + ); + }; + return ( + <> + + + +
handleShowDrawer()} + /> + + {isLoading ? ( + renderSkeletons() + ) : ( + ('id' in item ? item.id : `empty-${index}`)} + numColumns={numColumns} + key={numColumns} + ListHeaderComponent={() => { + return Playlists; + }} + columnWrapperStyle={{ gap: 20 }} + contentContainerStyle={{ width: '100%', gap: 20 }} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => { + if ('empty' in item) { + return ; + } + return ( + + + router.navigate({ + pathname: '/playlist/[id]', + params: { id: item.id }, + }) + } + > + + + + + {item.name} + + + {item.itemCount} items + + + {item.id === currentPlaylist && } + + + + ); + }} + /> + )} + + + + + ); } const styles = StyleSheet.create({ - container: { - width: '100%', - minHeight: '100%', - // backgroundColor: '#FFF', - - alignItems: 'center', - justifyContent: 'flex-start', - }, - //Content Styles - content: { - width: '100%', - padding: 20, - }, - playlistItem: { - flex: 1, - overflow: 'hidden', - - borderRadius: 20, - }, - playlistItemInside: { - - flex: 1, - padding: 20, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 20, - - borderRadius: 20, - }, - text: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - playState: { - position: 'absolute', - top: 10, - right: 10, - - width: 5, - height: 5, - backgroundColor: '#C6C6C6', - - alignItems: 'center', - justifyContent: 'center', - - borderRadius: 5, - } -}); \ No newline at end of file + container: { + width: '100%', + minHeight: '100%', + alignItems: 'center', + justifyContent: 'flex-start', + }, + content: { + width: '100%', + padding: 20, + flex: 1, + }, + playlistItem: { + flex: 1, + overflow: 'hidden', + borderRadius: 20, + minHeight: 80, + }, + playlistItemInside: { + flex: 1, + padding: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 15, + }, + text: { + color: '#FFF', + fontFamily: 'MPLUS-Regular', + fontSize: 14, + }, + playState: { + width: 8, + height: 8, + backgroundColor: '#FFFFFF', + borderRadius: 4, + }, +}); From d1cd707322beb0e704bfeca050df86a5d876074c Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:43:14 +0300 Subject: [PATCH 24/33] build: add remote build script and dev tooling Add `build-on-arch` npm script for cross-architecture remote builds Install required dev dependencies: ts-node and @types/node Standardize LF line endings across the repository via .gitattributes and editor settings Update Prettier configuration to enforce LF line endings Enable auto-format on save for VS Code Add "type": "module" to package.json for ES module support Add initial README for the remote build script Fix package.json script formatting --- .gitattributes | 1 + .prettierrc | 3 +- .vscode/settings.json | 5 +- package-lock.json | 173 ++++++++++++++++++++++++++++++++++++-- package.json | 6 +- scripts/builder/README.md | 0 6 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/builder/README.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 61d93d0..8c3d86e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "semi": true, "singleQuote": true, "bracketSpacing": true, - "printWidth": 230 + "printWidth": 230, + "endOfLine": "lf" } diff --git a/.vscode/settings.json b/.vscode/settings.json index 72d31f8..4e2cdf7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "editor.formatOnSave": false, + "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", "source.organizeImports": "never" @@ -15,5 +15,6 @@ }, "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "files.eol": "\n" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 68fd2e3..a4c6bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,12 +37,14 @@ "reanimated-color-picker": "^4.2.0" }, "devDependencies": { + "@types/node": "^25.8.0", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.3", + "ts-node": "^10.9.2", "typescript": "~5.9.2" } }, @@ -1537,6 +1539,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -3165,6 +3191,34 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3278,12 +3332,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { @@ -3966,6 +4020,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5072,6 +5139,13 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5339,6 +5413,16 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -9111,6 +9195,13 @@ "yallist": "^3.0.2" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12220,6 +12311,57 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12404,9 +12546,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -12612,6 +12754,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -13157,6 +13306,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index b493d5d..20761ac 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "aimp-remote", "main": "expo-router/entry", "version": "1.0.0", + "type": "module", "scripts": { "start": "expo start", "start:dev": "expo start --dev-client", @@ -18,7 +19,8 @@ "format:check": "prettier . --check", "typecheck": "tsc --noEmit", "check": "npm run typecheck && npm run lint && npm run format:check", - "check:fix": "npm run lint -- --fix && npm run format" + "check:fix": "npm run lint -- --fix && npm run format", + "build-on-arch": "ts-node scripts/builder/remote-build.ts" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -50,12 +52,14 @@ "reanimated-color-picker": "^4.2.0" }, "devDependencies": { + "@types/node": "^25.8.0", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.3", + "ts-node": "^10.9.2", "typescript": "~5.9.2" }, "private": true diff --git a/scripts/builder/README.md b/scripts/builder/README.md new file mode 100644 index 0000000..e69de29 From e8d43a33357940f09a0f40da219f0863fd5251fc Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:47:10 +0300 Subject: [PATCH 25/33] feat(builder): add remote expo build script This script syncs required project files to a pre-configured Arch Linux remote host via SCP, runs an optimized Android build remotely, then installs and launches the resulting APK via Waydroid. It handles SIGINT cleanup for stopping the running app and includes convenient log viewing commands. --- scripts/builder/remote-build.ts | 213 ++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 scripts/builder/remote-build.ts diff --git a/scripts/builder/remote-build.ts b/scripts/builder/remote-build.ts new file mode 100644 index 0000000..90a6aeb --- /dev/null +++ b/scripts/builder/remote-build.ts @@ -0,0 +1,213 @@ +import { execSync } from 'child_process'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'fs'; + +// Step 1: generate SSH key +// ssh-keygen -t ed25519 + +// Step 2: copy public key to remote machine +// cat $env:USERPROFILE\.ssh\id_ed25519.pub | ssh @ "cat >> ~/.ssh/authorized_keys" + +const ARCH_USER = 'mahmoud-ts'; +const ARCH_IP = '192.168.137.195'; + +const runStream = (cmd: string) => execSync(cmd, { stdio: 'inherit' }); +const runCapture = (cmd: string): string => execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' }).trim(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_PATH: string = resolve(__dirname, '../../'); + +const appJsonPath = resolve(PROJECT_PATH, 'app.json'); +const appJson = JSON.parse(readFileSync(appJsonPath, 'utf-8')); + +const PACKAGE_NAME = appJson.expo.android.package; +const PROJECT_SLUG = appJson.expo.slug || 'expo_app'; + +if (!PACKAGE_NAME) { + console.log('โŒ ERROR: "expo.android.package" is not defined in app.json!'); + process.exit(1); +} + +const REMOTE_DIR = `~/arch_build_space/${PROJECT_SLUG}`; +const REMOTE_ABS_DIR = `/home/${ARCH_USER}/arch_build_space/${PROJECT_SLUG}`; + +console.log(`๐Ÿš€ Starting Remote Build for: ${PROJECT_SLUG} (${PACKAGE_NAME})`); +console.log('๐Ÿš€ Creating remote directory...'); +runStream(`ssh ${ARCH_USER}@${ARCH_IP} "mkdir -p ${REMOTE_DIR}"`); + +const optionalFiles = ['package-lock.json', 'tsconfig.json', 'expo-env.d.ts']; +const requiredFiles = ['package.json', 'app.json', 'src', 'android', 'assets']; +const filesToSync = [...requiredFiles, ...optionalFiles.filter((f) => existsSync(resolve(PROJECT_PATH, f)))]; + +const scpPaths = filesToSync.map((file) => `"${PROJECT_PATH}/${file}"`).join(' '); + +console.log('๐Ÿ“ฆ Syncing project to Arch (Using SCP)...'); +runStream(`scp -r ${scpPaths} ${ARCH_USER}@${ARCH_IP}:${REMOTE_DIR}/`); + +console.log('๐Ÿ“ Generating remote build script...'); +const bashScript = `#!/bin/bash +set -e + +if [ -f ~/.bash_profile ]; then source ~/.bash_profile; fi +if [ -f ~/.profile ]; then source ~/.profile; fi +if [ -f ~/.bashrc ]; then source ~/.bashrc; fi + +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" + +export ANDROID_HOME=$HOME/Android/Sdk +export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools + +cd ${REMOTE_DIR} + +echo "โฌ‡๏ธ Installing Node dependencies..." +if ! command -v npm &> /dev/null; then + echo "โŒ ERROR: npm is still not found in PATH!" + exit 1 +fi + +npm install --legacy-peer-deps + +cd android + +echo "๐Ÿ”จ Building APK (Optimized for your i3 Laptop)..." +chmod +x gradlew + +./gradlew assembleDebug --stacktrace --max-workers=2 --parallel --build-cache -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m" -PreactNativeArchitectures=x86_64 +`; + +const tempScriptPath = resolve(PROJECT_PATH, 'temp-remote-build.sh'); +writeFileSync(tempScriptPath, bashScript); + +runStream(`scp "${tempScriptPath}" ${ARCH_USER}@${ARCH_IP}:${REMOTE_DIR}/`); +if (existsSync(tempScriptPath)) unlinkSync(tempScriptPath); + +console.log('โš™๏ธ Executing build on Arch Linux...'); +try { + runStream(`ssh ${ARCH_USER}@${ARCH_IP} "chmod +x ${REMOTE_DIR}/temp-remote-build.sh && bash ${REMOTE_DIR}/temp-remote-build.sh"`); +} catch (error) { + console.log('โŒ Build process failed on Arch Linux!', error); + process.exit(1); +} + +console.log('๐Ÿ” Locating generated APK...'); +const apkPath = `/home/${ARCH_USER}/arch_build_space/${PROJECT_SLUG}/android/app/build/outputs/apk/debug/app-debug.apk`; +console.log('๐Ÿ“ฆ APK Path:', apkPath); + +runStream(`ssh ${ARCH_USER}@${ARCH_IP} "if [ ! -f '${apkPath}' ]; then echo 'โŒ APK NOT FOUND: ${apkPath}'; exit 1; fi"`); + +console.log('๐Ÿ“ฒ Installing and launching on Waydroid...'); + +const launchScript = `#!/bin/bash +set -e + +USER_ID=$(id -u) +export XDG_RUNTIME_DIR=/run/user/$USER_ID +export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$USER_ID/bus +export WAYLAND_DISPLAY=wayland-0 + +APK_PATH="${apkPath}" +PACKAGE="${PACKAGE_NAME}" + +# โ”€โ”€ Step 1: Ensure container is RUNNING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "๐Ÿ” Checking Waydroid container state..." +CONTAINER_STATUS=$(waydroid status 2>/dev/null | grep "Container:" | awk '{print $2}' || echo "") +echo " Container status: \${CONTAINER_STATUS:-UNKNOWN}" + +if [ "$CONTAINER_STATUS" != "RUNNING" ]; then + echo "๐ŸงŠ Container not ready โ€” starting session..." + waydroid session stop 2>/dev/null || true + sleep 1 + + # nohup + disown: fully detaches from SSH, keeps session alive + nohup waydroid session start > /tmp/waydroid-session.log 2>&1 & + disown + + echo " Waiting for container to boot..." + for i in $(seq 1 25); do + sleep 2 + STATUS=$(waydroid status 2>/dev/null | grep "Container:" | awk '{print $2}' || echo "") + echo " [$i/25] Container: \${STATUS:-UNKNOWN}" + if [ "$STATUS" = "RUNNING" ]; then + echo "โœ… Container is RUNNING โ€” waiting for Android to fully boot..." + sleep 5 + break + fi + if [ "$i" = "25" ]; then + echo "โŒ Timed out waiting for container!" + exit 1 + fi + done +fi + +# โ”€โ”€ Step 2: Install APK (after container is ready) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "๐Ÿ“ฒ Installing APK..." +waydroid app install "$APK_PATH" + +# โ”€โ”€ Step 3: Clear logs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "๐Ÿงน Clearing old logs..." +waydroid shell -- logcat -c 2>/dev/null || true + +# โ”€โ”€ Step 4: Launch app โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "๐Ÿš€ Launching app..." +systemd-run \\ + --user \\ + --no-block \\ + -E WAYLAND_DISPLAY=wayland-0 \\ + -E XDG_RUNTIME_DIR=/run/user/$USER_ID \\ + -E DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$USER_ID/bus \\ + -E XDG_SESSION_TYPE=wayland \\ + waydroid app launch "$PACKAGE" + +sleep 2 + +echo "" +echo "๐ŸŸข ---------------------------------------" +echo "๐ŸŸข APP IS RUNNING NATIVELY ON ARCH LINUX!" +echo "๐ŸŸข ---------------------------------------" +echo "" +echo "๐Ÿ“œ To stream logs run:" +echo "sudo waydroid shell -- logcat | grep -i '$PACKAGE\\|AndroidRuntime\\|ReactNativeJS\\|FATAL'" +`; + +const tempLaunchPath = resolve(PROJECT_PATH, 'temp-remote-launch.sh'); +writeFileSync(tempLaunchPath, launchScript); + +runStream(`scp "${tempLaunchPath}" ${ARCH_USER}@${ARCH_IP}:${REMOTE_DIR}/`); +if (existsSync(tempLaunchPath)) unlinkSync(tempLaunchPath); + +process.on('SIGINT', () => { + console.log('\n๐Ÿ›‘ Stopping app on Arch Linux...'); + try { + execSync(`ssh ${ARCH_USER}@${ARCH_IP} "XDG_RUNTIME_DIR=/run/user/\\$(id -u) WAYLAND_DISPLAY=wayland-0 waydroid shell -- am force-stop ${PACKAGE_NAME} 2>/dev/null || true"`, { stdio: 'ignore' }); + } catch (error) { + console.error(error); + } + console.log('๐Ÿ‘‹ Goodbye!'); + process.exit(0); +}); + +try { + try { + runStream(`ssh ${ARCH_USER}@${ARCH_IP} "chmod +x ${REMOTE_DIR}/temp-remote-launch.sh && bash ${REMOTE_DIR}/temp-remote-launch.sh"`); + console.log('๐ŸŸข Remote launch script started successfully!'); + } catch (error) { + console.error('โŒ Launch failed:', error); + process.exit(1); + } + + console.log('๐ŸŸข Remote launch script started successfully!'); + console.log(`๐Ÿ“œ To watch logs:`); + + console.log(''); + console.log(`ssh ${ARCH_USER}@${ARCH_IP} "sudo waydroid shell -- logcat | grep -i '${PACKAGE_NAME}\\|AndroidRuntime\\|ReactNativeJS\\|FATAL'"`); + + console.log(''); + console.log('๐Ÿ›‘ Press Ctrl+C to stop the app.'); + + process.stdin.resume(); +} catch (error) { + console.error(error); +} From 34045a72ca9c6c682d5a6bc605440e4c2cb2ecff Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:49:08 +0300 Subject: [PATCH 26/33] refactor(useAIMP): optimize state updates & cleanup - add explicit TypeScript type to aimpEvent state - rewrite state update logic to only trigger re-renders when actual values change - add floor check for position updates to avoid spurious small position changes - remove unused ToastAndroid import and showToast helper function - simplify websocket error handler by removing unused event parameter - replace error toast with console.warn for parsing errors --- src/hooks/useAIMP.ts | 64 ++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/src/hooks/useAIMP.ts b/src/hooks/useAIMP.ts index 12b57d3..fb939d5 100644 --- a/src/hooks/useAIMP.ts +++ b/src/hooks/useAIMP.ts @@ -1,7 +1,6 @@ import { useSettings } from '@/context/appContext'; import { SongInterface } from '@/types/ISongInformation'; import { useEffect, useRef, useState } from 'react'; -import { ToastAndroid } from 'react-native'; export interface AIMPState { muteState: boolean | null; @@ -15,7 +14,7 @@ export interface AIMPState { } export const useAIMP = () => { - const [aimpEvent, setAimpEvent] = useState({ + const [aimpEvent, setAimpEvent] = useState({ muteState: null, playerState: null, position: 0, @@ -41,10 +40,6 @@ export const useAIMP = () => { const { server } = useSettings(); - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); - }; - useEffect(() => { if (!server || !server.ip) return; @@ -55,25 +50,54 @@ export const useAIMP = () => { ws.current.onmessage = (e) => { try { const data = JSON.parse(e.data); - setAimpEvent((prev) => ({ - ...prev, - ...(data.event === 'mute_changed' && { muteState: data.mute }), - ...(data.event === 'player_state' && { playerState: data.state }), - ...(data.event === 'position' && { position: data.position }), - ...(data.event === 'repeat_changed' && { repeatState: data.repeat }), - ...(data.event === 'shuffle_changed' && { - shuffleState: data.shuffle, - }), - ...(data.event === 'track_changed' && { track: data }), - ...(data.event === 'volume_changed' && { volumeState: data.volume }), - })); + + setAimpEvent((prev) => { + let changed = false; + const next = { ...prev }; + + if (data.event === 'mute_changed' && prev.muteState !== data.mute) { + next.muteState = data.mute; + changed = true; + } + if (data.event === 'player_state' && prev.playerState !== data.state) { + next.playerState = data.state; + changed = true; + } + + if (data.event === 'position') { + const newPos = Math.floor(data.position); + const oldPos = Math.floor(prev.position); + if (newPos !== oldPos) { + next.position = data.position; + changed = true; + } + } + + if (data.event === 'repeat_changed' && prev.repeatState !== data.repeat) { + next.repeatState = data.repeat; + changed = true; + } + if (data.event === 'shuffle_changed' && prev.shuffleState !== data.shuffle) { + next.shuffleState = data.shuffle; + changed = true; + } + if (data.event === 'track_changed') { + next.track = data; + changed = true; + } + if (data.event === 'volume_changed' && prev.volumeState !== data.volume) { + next.volumeState = data.volume; + changed = true; + } + + return changed ? next : prev; + }); } catch { - showToast('Error parsing websocket'); console.warn('Error parsing websocket'); } }; - ws.current.onerror = (e) => setAimpEvent((prev) => ({ ...prev, status: 'disconnected' })); + ws.current.onerror = () => setAimpEvent((prev) => ({ ...prev, status: 'disconnected' })); return () => { if (ws.current) { From d4a692b0f4f3f14a6568fff20c9c6f3b979318f6 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:51:13 +0300 Subject: [PATCH 27/33] feat(utils): add formatTimeHelper utility function converts millisecond inputs to padded time strings, returns 00:00 for invalid or zero values, and formats output as either MM:SS or HH:MM:SS based on total duration --- src/utils/helpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/utils/helpers.ts diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..30df4b8 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,13 @@ +const formatTimeHelper = (millis: number) => { + if (!millis || isNaN(millis)) return '00:00'; + const totalSeconds = Math.floor(millis / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const m = minutes.toString().padStart(2, '0'); + const s = seconds.toString().padStart(2, '0'); + return hours > 0 ? `${hours}:${m}:${s}` : `${m}:${s}`; +}; + +export { formatTimeHelper }; From 84030c5991c4f25a3f1f5db0df8fd3bec2e6c8c5 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:53:43 +0300 Subject: [PATCH 28/33] feat(types): add optional name, filename, duration update PlaylistItemType and SongInterface interfaces to include the new optional fields --- src/types/IPlaylistDetails.ts | 2 ++ src/types/ISongInformation.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/types/IPlaylistDetails.ts b/src/types/IPlaylistDetails.ts index 729281e..9183c84 100644 --- a/src/types/IPlaylistDetails.ts +++ b/src/types/IPlaylistDetails.ts @@ -30,4 +30,6 @@ export interface PlaylistItemType { title: string; artist: string; duration: number; + name?: string; + filename?: string; } diff --git a/src/types/ISongInformation.ts b/src/types/ISongInformation.ts index 257ac63..01658da 100644 --- a/src/types/ISongInformation.ts +++ b/src/types/ISongInformation.ts @@ -7,4 +7,7 @@ export interface SongInterface { rating: number; sample_rate: number; title: string; + name?: string; + filename?: string; + duration?: number; } From bdf83bdf8ae7a59de38063ac1f4d990741a8feaa Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:56:39 +0300 Subject: [PATCH 29/33] feat(ui): add offline connection failed screen render a full-screen connection failure view with themed styling, animated fade-in entrance, and a retry button to trigger reconnection attempts. include helpful hints about ensuring AIMP and the Web Control Plugin are running on the host computer. --- src/components/ui/OfflineScreen.tsx | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/components/ui/OfflineScreen.tsx diff --git a/src/components/ui/OfflineScreen.tsx b/src/components/ui/OfflineScreen.tsx new file mode 100644 index 0000000..931ba42 --- /dev/null +++ b/src/components/ui/OfflineScreen.tsx @@ -0,0 +1,89 @@ +import { Colors, ThemeType } from '@/theme'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInDown } from 'react-native-reanimated'; + +interface OfflineScreenProps { + theme: ThemeType; + appColor: string; + onRetry: () => void; +} + +export default function OfflineScreen({ theme, appColor, onRetry }: OfflineScreenProps) { + const currentTheme = Colors[theme]; + + return ( + + + + + + + Connection Failed + Did you forget to open AIMP on your PC? + Make sure AIMP and the Web Control Plugin are running on your computer. + + + Retry Connection + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + content: { + alignItems: 'center', + justifyContent: 'center', + maxWidth: 400, + }, + iconContainer: { + position: 'relative', + marginBottom: 20, + }, + errorIcon: { + position: 'absolute', + bottom: -5, + right: -10, + backgroundColor: '#fff', + borderRadius: 20, + }, + title: { + fontSize: 26, + fontFamily: 'MPLUS-ExtraBold', + marginBottom: 10, + }, + subtitle: { + fontSize: 16, + fontFamily: 'MPLUS-Bold', + textAlign: 'center', + marginBottom: 10, + }, + hint: { + fontSize: 12, + fontFamily: 'MPLUS-Regular', + textAlign: 'center', + marginBottom: 30, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 30, + paddingVertical: 15, + borderRadius: 30, + elevation: 3, + }, + btnText: { + color: '#FFF', + fontFamily: 'MPLUS-Bold', + fontSize: 16, + }, +}); From 875f5d63a93b8d09a6b664db9af688c79f49cf4b Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:57:57 +0300 Subject: [PATCH 30/33] fix(playlist): add metadata fallbacks & clean code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fallback display title that uses title โ†’ name โ†’ cleaned filename, defaults to 'Unknown' - Show 'Unknown Artist' when artist metadata is missing - Replace manual duration formatting with formatTimeHelper utility - Condense style definitions in playlistSongItem --- src/components/playlist/playlistSongItem.tsx | 39 +++++--------------- src/components/playlist/togglePlayer.tsx | 6 ++- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/components/playlist/playlistSongItem.tsx b/src/components/playlist/playlistSongItem.tsx index e5c8b91..e5620a5 100644 --- a/src/components/playlist/playlistSongItem.tsx +++ b/src/components/playlist/playlistSongItem.tsx @@ -4,6 +4,7 @@ import NormalButton from '../player/normalButton'; import { PlaylistItemType } from '@/types/IPlaylistDetails'; import { useSettings } from '@/context/appContext'; import { Colors } from '@/theme'; +import { formatTimeHelper } from '@/utils/helpers'; interface PlaylistItems { item: PlaylistItemType; @@ -15,6 +16,8 @@ export const PlaylistItem = memo( const { theme } = useSettings(); const currentTheme = Colors[theme]; + const displayTitle = item.title || item.name || (item.filename ? item.filename.split('\\').pop()?.split('/').pop() : 'Unknown'); + return ( <> {Number(item.index) + 1} - {item.title} + {displayTitle} - {item.artist} + {item.artist || 'Unknown Artist'} - {new Date(item.duration * 1000).toISOString().slice(14, 19)} + {formatTimeHelper(item.duration * 1000)} } /> @@ -53,30 +56,8 @@ export const PlaylistItem = memo( ); const styles = StyleSheet.create({ - playlistItem: { - width: '100%', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 40, - }, - leftSide: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 20, - }, - playlistText: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - separator: { - width: '100%', - height: 1, - backgroundColor: '#252525', - marginTop: 10, - }, + playlistItem: { width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 40 }, + leftSide: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20 }, + playlistText: { fontFamily: 'MPLUS-Regular', fontSize: 14 }, + separator: { width: '100%', height: 1, marginTop: 10 }, }); diff --git a/src/components/playlist/togglePlayer.tsx b/src/components/playlist/togglePlayer.tsx index 34b39e1..176395a 100644 --- a/src/components/playlist/togglePlayer.tsx +++ b/src/components/playlist/togglePlayer.tsx @@ -92,6 +92,8 @@ export default function TogglePlayer() { playerState(); }, [server]); + const displayTitle = songInfo.title || songInfo.name || (songInfo.filename ? songInfo.filename.split('\\').pop()?.split('/').pop() : 'Unknown'); + return ( router.navigate('/(player)')}> @@ -102,7 +104,7 @@ export default function TogglePlayer() { - {songInfo.title} + {displayTitle} - {songInfo.artist} + {songInfo.artist || 'Unknown Artist'} From 7dcb35366275799ab6b49d22e2ea51dbdbe00b29 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Wed, 20 May 2026 23:58:52 +0300 Subject: [PATCH 31/33] fix(playlist): improve search with fallback titles extend search matching to use song.name, and extract base filename from path as last resort for display title --- src/app/playlist/[id].tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/playlist/[id].tsx b/src/app/playlist/[id].tsx index b024257..377c044 100644 --- a/src/app/playlist/[id].tsx +++ b/src/app/playlist/[id].tsx @@ -29,7 +29,10 @@ export default function Playlist() { if (!playlistItems) return []; if (!searchValue.trim()) return playlistItems; - return playlistItems.filter((song) => song.title?.toUpperCase().includes(searchValue.toUpperCase())); + return playlistItems.filter((song) => { + const displayTitle = song.title || song.name || (song.filename ? song.filename.split('\\').pop()?.split('/').pop() : 'Unknown'); + return displayTitle?.toUpperCase().includes(searchValue.toUpperCase()); + }); }, [searchValue, playlistItems]); const handleShowDrawer = () => { From 91196288498606563fe79c2741979dbb48d51d7b Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Thu, 21 May 2026 00:00:42 +0300 Subject: [PATCH 32/33] refactor(player): clean up UI, fix time handling and improve error logging - simplify unused fetch response handling - fix unit mismatches for song position and duration tracking - update sliders to use onSlidingComplete to reduce excessive API calls - improve song title display with fallback to filename or unknown - condense inline styles and remove dead code - add consistent console.warn error logging - use formatTimeHelper for standardized time formatting --- src/app/(player)/index.tsx | 282 ++++++++----------------------------- 1 file changed, 55 insertions(+), 227 deletions(-) diff --git a/src/app/(player)/index.tsx b/src/app/(player)/index.tsx index 6d850e5..9915e8f 100644 --- a/src/app/(player)/index.tsx +++ b/src/app/(player)/index.tsx @@ -12,7 +12,10 @@ import { Dimensions, StyleSheet, Text, ToastAndroid, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSettings } from '../../context/appContext'; +import { formatTimeHelper } from '@/utils/helpers'; + const { height: screenHeight } = Dimensions.get('window'); + const defaultSong: SongInterface = { album: 'Unknown', artist: 'Unknown', @@ -84,6 +87,7 @@ export default function Home() { if (data) setShuffleState(!shuffleState); } catch { showToast('Error set shuffle state'); + console.warn('Error set shuffle state'); } }; @@ -97,6 +101,7 @@ export default function Home() { if (data) setMuteState(!muteState); } catch { showToast('Error set mute state'); + console.warn('Error set mute state'); } }; @@ -112,24 +117,25 @@ export default function Home() { else if (volume > 0 && muteState) handleMuteState(); } catch { showToast('Error set volume'); + console.warn('Error set volume'); } }; const handleNextTrack = async () => { try { - const response = await fetch(`http://${server.ip}:3553/next`); - await response.json(); + await fetch(`http://${server.ip}:3553/next`); } catch { showToast('Error next track'); + console.warn('Error next track'); } }; const handlePreviousTrack = async () => { try { - const response = await fetch(`http://${server.ip}:3553/previous`); - await response.json(); + await fetch(`http://${server.ip}:3553/previous`); } catch { showToast('Error previous track'); + console.warn('Error previous track'); } }; @@ -143,6 +149,7 @@ export default function Home() { } } catch { showToast('Error set play/pause state'); + console.warn('Error set play/pause state'); } }; @@ -151,16 +158,16 @@ export default function Home() { transition.value = withTiming(showSlider ? 0 : 1, { duration: 250 }); }; - const handleSongPosition = async (position: number) => { + const handleSongPosition = async (positionMillis: number) => { try { - const response = await fetch(`http://${server.ip}:3553/track/position`, { + setSongPosition(positionMillis); + await fetch(`http://${server.ip}:3553/track/position`, { method: 'POST', - body: JSON.stringify({ position: position }), + body: JSON.stringify({ position: positionMillis / 1000 }), }); - const data = await response.json(); - if (data) setSongPosition(position); } catch { showToast('Error set song position'); + console.warn('Error set song position'); } }; @@ -182,7 +189,7 @@ export default function Home() { const results = await Promise.allSettled(requests); if (results[0].status === 'fulfilled') setSongInfo(results[0].value); - if (results[1].status === 'fulfilled') setSongDuration(Number(results[1].value)); + if (results[1].status === 'fulfilled') setSongDuration(Number(results[1].value) * 1000); if (results[2].status === 'fulfilled') setRepeatState(results[2].value === -1); if (results[3].status === 'fulfilled') setShuffleState(results[3].value === -1); if (results[4].status === 'fulfilled') setMuteState(results[4].value === 1); @@ -191,7 +198,7 @@ export default function Home() { setImageUri(`http://${server.ip}:3553/track/cover?t=${new Date().getTime()}`); } catch (e) { - console.log('Error fetching states', e); + console.warn('Error fetching states', e); } }; @@ -224,11 +231,11 @@ export default function Home() { }, [aimpEvent.volumeState]); useEffect(() => { - if (aimpEvent.position !== 0) setSongPosition(Number(aimpEvent.position)); + if (aimpEvent.position !== 0) setSongPosition(Math.trunc(Number(aimpEvent.position) * 1000)); }, [aimpEvent.position]); useEffect(() => { - if (aimpEvent.track.album !== '') { + if (aimpEvent.track.title || aimpEvent.track.filename) { setSongInfo({ album: aimpEvent.track.album, artist: aimpEvent.track.artist, @@ -238,48 +245,26 @@ export default function Home() { rating: aimpEvent.track.rating, sample_rate: aimpEvent.track.sample_rate, title: aimpEvent.track.title, + name: aimpEvent.track.name, + filename: aimpEvent.track.filename, }); setImageUri(`http://${server.ip}:3553/track/cover?t=${new Date().getTime()}`); } }, [aimpEvent.track, server.ip]); useEffect(() => { - if (Math.trunc(aimpEvent.track.duration * 1000) !== songDuration && aimpEvent.track.duration !== 0) { + if (aimpEvent.track.duration && Math.trunc(aimpEvent.track.duration * 1000) !== songDuration) { setSongDuration(Math.trunc(aimpEvent.track.duration * 1000)); } }, [aimpEvent.track.duration, songDuration]); + const displayTitle = songInfo.title || songInfo.name || (songInfo.filename ? songInfo.filename.split('\\').pop()?.split('/').pop() : 'Unknown'); + return ( <> - - + + router.back()} IconSet={Ionicons} iconName="chevron-down" /> @@ -304,11 +289,10 @@ export default function Home() { - {songInfo.title} + {displayTitle} - {/* {songInfo.album} */} - {songInfo.artist} + {songInfo.artist || 'Unknown Artist'} @@ -318,23 +302,11 @@ export default function Home() { handleShowSlider()} - insideStyle={{ - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - }} + insideStyle={{ flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }} TextElement={ <> - - {volumeState} - + {volumeState} } /> @@ -348,7 +320,7 @@ export default function Home() { maximumTrackTintColor="#C6C6C6" thumbTintColor="#FFFFFF" value={volumeState} - onValueChange={(e) => handleVolumeState(e)} + onSlidingComplete={(e) => handleVolumeState(e)} /> @@ -359,31 +331,26 @@ export default function Home() { - {new Date(songPosition).toISOString().slice(14, 19)} + {formatTimeHelper(songPosition)} handleSongPosition(e)} + onSlidingComplete={(e) => handleSongPosition(e)} /> - {new Date(songDuration).toISOString().slice(14, 19)} + {formatTimeHelper(songDuration)} handlePreviousTrack()} IconSet={Ionicons} iconName="play-skip-back" /> handlePause()} - containerStyle={{ - width: 60, - height: 60, - borderColor: '#FFF', - borderWidth: 2, - }} + containerStyle={{ width: 60, height: 60, borderColor: '#FFF', borderWidth: 2 }} IconSet={MaterialCommunityIcons} iconName={playerState === 'play' ? 'pause' : 'play'} iconSize={36} @@ -392,166 +359,27 @@ export default function Home() { - + ); } const styles = StyleSheet.create({ - container: { - position: 'relative', - - width: '100%', - height: '100%', - // backgroundColor: '#FFF', - }, - header: { - width: '100%', - height: 60, - paddingHorizontal: 20, - // backgroundColor: '#FFF', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerInfo: { - flex: 1, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - }, - headerTitle: { - color: '#C6C6C6', - fontFamily: 'MPLUS-Regular', - fontSize: 10, - textAlign: 'center', - textTransform: 'uppercase', - }, - headerSubtitle: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 12, - textAlign: 'center', - }, - content: { - height: 'auto', - // backgroundColor: '#363636', - paddingHorizontal: 20, - paddingVertical: 10, - - alignContent: 'flex-start', - justifyContent: 'flex-start', - gap: 20, - }, - songImage: { - width: '100%', - - alignItems: 'center', - justifyContent: 'flex-end', - overflow: 'hidden', - - borderRadius: 20, - - elevation: 5, - }, - image: { - width: '100%', - height: '100%', - }, - songInfo: { - // backgroundColor: '#c6c6c6', - paddingTop: 10, - - alignItems: 'center', - justifyContent: 'flex-start', - gap: 20, - }, - songTitle: { - color: '#FFF', - fontFamily: 'MPLUS-ExtraBold', - fontSize: 24, - }, - songAlbum: { - color: '#8B8B8B', - fontFamily: 'MPLUS', - fontSize: 14, - }, - songArtist: { - color: '#8B8B8B', - fontFamily: 'MPLUS', - fontSize: 14, - }, - controls: { - // backgroundColor: '#FFF', - marginTop: 10, - paddingVertical: 20, - - flex: 1, - alignItems: 'center', - justifyContent: 'space-between', - gap: 20, - }, - playerExtraControls: { - position: 'relative', - - width: '100%', - height: 60, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - extraControlsWrapper: { - position: 'absolute', - - width: '100%', - - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - }, - playerSlider: { - position: 'relative', - - width: '100%', - height: 60, - // backgroundColor: '#FFF', - paddingHorizontal: 20, - - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - playerSliderTime: { - color: '#FFF', - fontFamily: 'MPLUS-Bold', - }, - playerBasicControls: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 40, - - zIndex: 20, - }, + container: { position: 'relative', width: '100%', height: '100%' }, + header: { width: '100%', height: 60, paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + headerInfo: { flex: 1, paddingHorizontal: 20, flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }, + headerTitle: { color: '#C6C6C6', fontFamily: 'MPLUS-Regular', fontSize: 10, textAlign: 'center', textTransform: 'uppercase' }, + headerSubtitle: { color: '#FFF', fontFamily: 'MPLUS-Regular', fontSize: 12, textAlign: 'center' }, + content: { height: 'auto', paddingHorizontal: 20, paddingVertical: 10, alignContent: 'flex-start', justifyContent: 'flex-start', gap: 20 }, + songImage: { width: '100%', alignItems: 'center', justifyContent: 'flex-end', overflow: 'hidden', borderRadius: 20, elevation: 5 }, + image: { width: '100%', height: '100%' }, + songInfo: { paddingTop: 10, alignItems: 'center', justifyContent: 'flex-start', gap: 20 }, + songTitle: { color: '#FFF', fontFamily: 'MPLUS-ExtraBold', fontSize: 24 }, + songArtist: { color: '#8B8B8B', fontFamily: 'MPLUS', fontSize: 14 }, + controls: { marginTop: 10, paddingVertical: 20, flex: 1, alignItems: 'center', justifyContent: 'space-between', gap: 20 }, + playerExtraControls: { position: 'relative', width: '100%', height: 60, paddingHorizontal: 20, flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, + extraControlsWrapper: { position: 'absolute', width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + playerSlider: { position: 'relative', width: '100%', height: 60, paddingHorizontal: 20, flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, + playerSliderTime: { color: '#FFF', fontFamily: 'MPLUS-Bold' }, + playerBasicControls: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 40, zIndex: 20 }, }); From 644e824503e1ccc62a5a394c7a30a619be42d775 Mon Sep 17 00:00:00 2001 From: Mahmoud_Walid Date: Thu, 21 May 2026 00:02:39 +0300 Subject: [PATCH 33/33] feat(home): add offline support and refactor data fetching Combine parallel playlist API calls into a single Promise.all to reduce network overhead. Add offline state tracking to detect network failures, render the OfflineScreen component when offline, and include a retry button for data fetching. Remove unused ToastAndroid import and showToast helper function. Conditionally hide the TogglePlayer component when offline. Clean up redundant style definitions and inline JSX to improve code readability. --- src/app/index.tsx | 133 ++++++++++++++-------------------------------- 1 file changed, 40 insertions(+), 93 deletions(-) diff --git a/src/app/index.tsx b/src/app/index.tsx index ba03f7a..ad548ea 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -3,13 +3,14 @@ import { Drawer, DrawerBackground } from '@/components/ui/drawerNavigation'; import Header from '@/components/ui/header'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; -import { FlatList, StyleSheet, Text, ToastAndroid, TouchableNativeFeedback, useWindowDimensions, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FlatList, StyleSheet, Text, TouchableNativeFeedback, useWindowDimensions, View } from 'react-native'; import { useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSettings } from '../context/appContext'; import { Colors } from '@/theme'; import Skeleton from '@/components/ui/Skeleton'; +import OfflineScreen from '@/components/ui/OfflineScreen'; type Playlist = { id: string; @@ -29,6 +30,7 @@ export default function Home() { const [searchbarVisible, setSearchbarVisible] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isLoading, setIsLoading] = useState(true); + const [isOffline, setIsOffline] = useState(false); const transition = useSharedValue(0); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -38,10 +40,6 @@ export default function Home() { const numColumns = width > 600 ? 3 : 2; - const showToast = (message: string) => { - ToastAndroid.show(message, ToastAndroid.SHORT); - }; - const formatData = (data: Playlist[], columns: number): PlaylistItem[] => { const newData: PlaylistItem[] = [...data]; @@ -68,38 +66,33 @@ export default function Home() { transition.value = withTiming(transition.value ? 0 : 1, { duration: 500 }); }; - useEffect(() => { + const fetchData = useCallback(async () => { if (!server || !server.ip) return; + setIsLoading(true); + setIsOffline(false); + + try { + const [playlistResponse, currentPlaylistResponse] = await Promise.all([fetch(`http://${server.ip}:3553/playlist`), fetch(`http://${server.ip}:3553/playlist/current`)]); + + if (!playlistResponse.ok || !currentPlaylistResponse.ok) throw new Error('Server response error'); + + const info = await playlistResponse.json(); + const currentData = await currentPlaylistResponse.json(); - const playlistInfo = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist`); - const info = await response.json(); - - setPlaylists(info); - } catch (e) { - showToast('Error get playlist info'); - console.log('Error fetching playlists data', e); - } finally { - setIsLoading(false); - } - }; - - const currentPlaylist = async () => { - try { - const response = await fetch(`http://${server.ip}:3553/playlist/current`); - const data = await response.json(); - setCurrentPlaylist(data.id); - } catch (e) { - // showToast('Error get current playlist'); - console.log(e); - } - }; - - playlistInfo(); - currentPlaylist(); + setPlaylists(info); + setCurrentPlaylist(currentData.id); + } catch (e) { + console.warn('Error fetching playlists data', e); + setIsOffline(true); + } finally { + setIsLoading(false); + } }, [server]); + useEffect(() => { + fetchData(); + }, [fetchData]); + const renderSkeletons = () => { return ( @@ -125,8 +118,11 @@ export default function Home() { iconName="menu" leftSideOnPress={() => handleShowDrawer()} /> + - {isLoading ? ( + {isOffline ? ( + + ) : isLoading ? ( renderSkeletons() ) : ( - - router.navigate({ - pathname: '/playlist/[id]', - params: { id: item.id }, - }) - } - > + router.navigate({ pathname: '/playlist/[id]', params: { id: item.id } })}> {item.name} - - {item.itemCount} items - + {item.itemCount} items {item.id === currentPlaylist && } @@ -184,46 +161,16 @@ export default function Home() { )} - + {!isOffline && } ); } const styles = StyleSheet.create({ - container: { - width: '100%', - minHeight: '100%', - alignItems: 'center', - justifyContent: 'flex-start', - }, - content: { - width: '100%', - padding: 20, - flex: 1, - }, - playlistItem: { - flex: 1, - overflow: 'hidden', - borderRadius: 20, - minHeight: 80, - }, - playlistItemInside: { - flex: 1, - padding: 20, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 15, - }, - text: { - color: '#FFF', - fontFamily: 'MPLUS-Regular', - fontSize: 14, - }, - playState: { - width: 8, - height: 8, - backgroundColor: '#FFFFFF', - borderRadius: 4, - }, + container: { width: '100%', minHeight: '100%', alignItems: 'center', justifyContent: 'flex-start' }, + content: { width: '100%', padding: 20, flex: 1 }, + playlistItem: { flex: 1, overflow: 'hidden', borderRadius: 20, minHeight: 80 }, + playlistItemInside: { flex: 1, padding: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 15 }, + text: { color: '#FFF', fontFamily: 'MPLUS-Regular', fontSize: 14 }, + playState: { width: 8, height: 8, backgroundColor: '#FFFFFF', borderRadius: 4 }, });