Compare commits
18 Commits
3ba290031d
...
d02df35b1a
| Author | SHA1 | Date | |
|---|---|---|---|
| d02df35b1a | |||
| 826748075c | |||
| 65ff6c4b0c | |||
| 4e75eb4a5f | |||
| 87d82147a3 | |||
| 0a6603d917 | |||
| c96ddfac0e | |||
| fa679656e3 | |||
| 24b267f2bf | |||
| 6e8b5ab66c | |||
| 96b737c646 | |||
| 6d80645563 | |||
| 5f4a45ed4e | |||
| 071f7e9279 | |||
| 6594d2adf7 | |||
| 9ef7f21b10 | |||
| b176729599 | |||
| 814e1d27fb |
@@ -4,8 +4,11 @@
|
|||||||
# Node/Expo caches and build artifacts
|
# Node/Expo caches and build artifacts
|
||||||
node_modules/
|
node_modules/
|
||||||
.expo/
|
.expo/
|
||||||
|
.vercel/
|
||||||
|
.env*
|
||||||
dist/
|
dist/
|
||||||
web-build/
|
web-build/
|
||||||
|
builds/
|
||||||
npm-debug.*
|
npm-debug.*
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
|
||||||
@@ -30,4 +33,3 @@ ios/Pods/
|
|||||||
# Do NOT ignore native project roots here
|
# Do NOT ignore native project roots here
|
||||||
# android/
|
# android/
|
||||||
# ios/
|
# ios/
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,5 @@ dist/
|
|||||||
|
|
||||||
# @end expo-cli
|
# @end expo-cli
|
||||||
*.sh
|
*.sh
|
||||||
|
.vercel
|
||||||
|
.env*
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "2026.04.08"
|
versionName "1.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="splashscreen_background">#000</color>
|
<color name="splashscreen_background">#000000</color>
|
||||||
<color name="iconBackground">#ffffff</color>
|
<color name="iconBackground">#ffffff</color>
|
||||||
<color name="colorPrimary">#023c69</color>
|
<color name="colorPrimary">#023c69</color>
|
||||||
<color name="colorPrimaryDark">#000</color>
|
<color name="colorPrimaryDark">#000000</color>
|
||||||
<color name="activityBackground">#0F0C14</color>
|
<color name="activityBackground">#0F0C14</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
<string name="app_name">MusicLand</string>
|
<string name="app_name">MusicLand</string>
|
||||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
|
<string name="expo_runtime_version">1.0.0</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<item name="android:editTextStyle">@style/ResetEditText</item>
|
<item name="android:editTextStyle">@style/ResetEditText</item>
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="android:statusBarColor">#000</item>
|
<item name="android:statusBarColor">#000000</item>
|
||||||
<item name="android:windowBackground">@color/activityBackground</item>
|
<item name="android:windowBackground">@color/activityBackground</item>
|
||||||
</style>
|
</style>
|
||||||
<style name="ResetEditText" parent="@android:style/Widget.EditText">
|
<style name="ResetEditText" parent="@android:style/Widget.EditText">
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
|
||||||
|
const getCommitId = () => {
|
||||||
|
const easCommitId = process.env.EAS_BUILD_GIT_COMMIT_HASH?.trim()
|
||||||
|
if (easCommitId) {
|
||||||
|
return easCommitId.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return execFileSync('git', ['rev-parse', '--short=8', 'HEAD'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ({ config }) => ({
|
||||||
|
...config,
|
||||||
|
extra: {
|
||||||
|
...config.extra,
|
||||||
|
commitId: getCommitId(),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
"scheme": [
|
"scheme": [
|
||||||
"musicland",
|
"musicland",
|
||||||
"com.googleusercontent.apps.305598753437-qck73beo37mi04qlautgafbpdl3ccaoh",
|
"com.googleusercontent.apps.305598753437-qck73beo37mi04qlautgafbpdl3ccaoh",
|
||||||
"com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk"
|
"com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un"
|
||||||
],
|
],
|
||||||
"deeplinks": [
|
"deeplinks": [
|
||||||
"musicland://"
|
"musicland://"
|
||||||
],
|
],
|
||||||
"version": "2026.04.08",
|
"version": "1.0",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"backgroundColor": "#0F0C14",
|
"backgroundColor": "#0F0C14",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
@@ -19,14 +19,18 @@
|
|||||||
"iosDisplayInForeground": true
|
"iosDisplayInForeground": true
|
||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/5a57617e-a8b2-41d6-8441-9bda538764a1",
|
||||||
"fallbackToCacheTimeout": 0
|
"fallbackToCacheTimeout": 0
|
||||||
},
|
},
|
||||||
|
"runtimeVersion": "1.0",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash.png",
|
"image": "./assets/splash.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#000"
|
"backgroundColor": "#000000"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
"appleTeamId": "SD7SD54VR9",
|
||||||
|
"buildNumber": "6",
|
||||||
"googleServicesFile": "./config/GoogleService-Info.plist",
|
"googleServicesFile": "./config/GoogleService-Info.plist",
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
"associatedDomains": [
|
"associatedDomains": [
|
||||||
"applinks:musicland-one.vercel.app"
|
"applinks:musicland-one.vercel.app"
|
||||||
],
|
],
|
||||||
"bundleIdentifier": "com.omedis.musicland.ai",
|
"bundleIdentifier": "Ai.musicland",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"audio"
|
"audio"
|
||||||
@@ -58,12 +62,11 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"entitlements": {
|
"entitlements": {
|
||||||
"aps-environment": "development",
|
"aps-environment": "production",
|
||||||
"com.apple.developer.applesignin": [
|
"com.apple.developer.applesignin": [
|
||||||
"Default"
|
"Default"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"appleTeamId": "UH4Z9T6UXN"
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"softwareKeyboardLayoutMode": "resize",
|
"softwareKeyboardLayoutMode": "resize",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-store-review": "~8.0.1",
|
"expo-store-review": "~8.0.1",
|
||||||
"expo-system-ui": "~4.0.9",
|
"expo-system-ui": "~4.0.9",
|
||||||
|
"expo-updates": "~0.27.5",
|
||||||
"expo-video": "~2.0.6",
|
"expo-video": "~2.0.6",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
"firebase": "~9.20.0",
|
"firebase": "~9.20.0",
|
||||||
@@ -884,7 +885,7 @@
|
|||||||
|
|
||||||
"archive-type": ["archive-type@4.0.0", "", { "dependencies": { "file-type": "^4.2.0" } }, "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA=="],
|
"archive-type": ["archive-type@4.0.0", "", { "dependencies": { "file-type": "^4.2.0" } }, "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA=="],
|
||||||
|
|
||||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
"arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
@@ -1398,8 +1399,12 @@
|
|||||||
|
|
||||||
"expo-store-review": ["expo-store-review@8.0.1", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-eJoZbsAsOXpyl1CEXauiMqs3z++8Qf1OMDUBEGmRj9hew+w9JbS3wvFOZpc0UXbm/NYn5YwiLfK7epQjLLKz4A=="],
|
"expo-store-review": ["expo-store-review@8.0.1", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-eJoZbsAsOXpyl1CEXauiMqs3z++8Qf1OMDUBEGmRj9hew+w9JbS3wvFOZpc0UXbm/NYn5YwiLfK7epQjLLKz4A=="],
|
||||||
|
|
||||||
|
"expo-structured-headers": ["expo-structured-headers@4.0.0", "", {}, "sha512-uPiwZjWq3AdFGgY52+I2nGPrNa6izxAglymPXHUZLekZW290GqIUOk7MBNDD4sg4JwUbSi3gdxEurpEvuq+FSg=="],
|
||||||
|
|
||||||
"expo-system-ui": ["expo-system-ui@4.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.76.8", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-hqBc0EWeK/BTB8i4H84vqNjje8GgxhapYrcWdg5qriaRA/u+bNNxhmpZXdAjFuhonOP4SmAbF+gjoJJWsTrhUg=="],
|
"expo-system-ui": ["expo-system-ui@4.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.76.8", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-hqBc0EWeK/BTB8i4H84vqNjje8GgxhapYrcWdg5qriaRA/u+bNNxhmpZXdAjFuhonOP4SmAbF+gjoJJWsTrhUg=="],
|
||||||
|
|
||||||
|
"expo-updates": ["expo-updates@0.27.5", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~10.0.11", "@expo/config-plugins": "~9.0.17", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "expo-eas-client": "~0.13.3", "expo-manifests": "~0.15.8", "expo-structured-headers": "~4.0.0", "expo-updates-interface": "~1.0.0", "fast-glob": "^3.3.2", "fbemitter": "^3.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-Y771xWW+jRT/pgAwE/BYHEZ4NrY/aSDyqbUABkbzyobQsS03usTwGZb7nadT4f1+x9NbKHxanWtsZeEmWeOx8w=="],
|
||||||
|
|
||||||
"expo-updates-interface": ["expo-updates-interface@1.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ=="],
|
"expo-updates-interface": ["expo-updates-interface@1.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ=="],
|
||||||
|
|
||||||
"expo-video": ["expo-video@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-oDorKwjreHT596oY6ZfktbDa3VF+Sq7gVsTbnQ4QVqGYL13mIO0s+h4y9xMyG/cDWyFpiRc4UT8KkPdxL0wViQ=="],
|
"expo-video": ["expo-video@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-oDorKwjreHT596oY6ZfktbDa3VF+Sq7gVsTbnQ4QVqGYL13mIO0s+h4y9xMyG/cDWyFpiRc4UT8KkPdxL0wViQ=="],
|
||||||
@@ -2806,6 +2811,8 @@
|
|||||||
|
|
||||||
"@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
"@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
|
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"@expo/cli/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
"@expo/cli/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||||
|
|
||||||
"@expo/cli/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
"@expo/cli/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
@@ -2832,6 +2839,8 @@
|
|||||||
|
|
||||||
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
|
|
||||||
|
"@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"@expo/fingerprint/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"@expo/fingerprint/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"@expo/image-utils/fs-extra": ["fs-extra@9.0.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^1.0.0" } }, "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g=="],
|
"@expo/image-utils/fs-extra": ["fs-extra@9.0.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^1.0.0" } }, "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g=="],
|
||||||
@@ -3242,6 +3251,8 @@
|
|||||||
|
|
||||||
"svgo/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
|
"svgo/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
|
||||||
|
|
||||||
|
"tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
"tailwindcss/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"tailwindcss/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CLIENT_ID</key>
|
<key>CLIENT_ID</key>
|
||||||
<string>305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com</string>
|
<string>305598753437-820ot12hdlpfkvhb33lj56jp4752p3un.apps.googleusercontent.com</string>
|
||||||
<key>REVERSED_CLIENT_ID</key>
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
<string>com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk</string>
|
<string>com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un</string>
|
||||||
<key>ANDROID_CLIENT_ID</key>
|
<key>ANDROID_CLIENT_ID</key>
|
||||||
<string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string>
|
<string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string>
|
||||||
<key>API_KEY</key>
|
<key>API_KEY</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>PLIST_VERSION</key>
|
<key>PLIST_VERSION</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>BUNDLE_ID</key>
|
<key>BUNDLE_ID</key>
|
||||||
<string>com.omedis.musicland.ai</string>
|
<string>Ai.musicland</string>
|
||||||
<key>PROJECT_ID</key>
|
<key>PROJECT_ID</key>
|
||||||
<string>musicland-d33f9</string>
|
<string>musicland-d33f9</string>
|
||||||
<key>STORAGE_BUCKET</key>
|
<key>STORAGE_BUCKET</key>
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
<key>IS_SIGNIN_ENABLED</key>
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
<true></true>
|
<true></true>
|
||||||
<key>GOOGLE_APP_ID</key>
|
<key>GOOGLE_APP_ID</key>
|
||||||
<string>1:305598753437:ios:7942793c4093dedff28274</string>
|
<string>1:305598753437:ios:4a6410dbe02ece31f28274</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -44,3 +44,234 @@ No actionable P0, P1, or P2 visual mismatch remains.
|
|||||||
The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above.
|
The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above.
|
||||||
|
|
||||||
final result: passed
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design QA — Recentrage du fond My Library mobile
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth : `/Users/minuit/musicland/src/assets/UI/libraryBgWeb.png`
|
||||||
|
- Capture avant correction : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_4d679451-52ae-4b7f-bad0-09b9187cf4f4.jpg`
|
||||||
|
- Capture après correction : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_68b9e20d-4f9b-450e-9436-99fa2392e278.jpg`
|
||||||
|
- Comparaison cible/implémentation : `/private/tmp/musicland-design-qa/library-centering-target-implementation.png`
|
||||||
|
- Comparaison avant/après : `/private/tmp/musicland-design-qa/library-centering-before-after.png`
|
||||||
|
- Source : 1920 × 1080 px.
|
||||||
|
- Implémentation : 368 × 800 px sur iPhone 17 Pro Simulator, iOS 26.5.
|
||||||
|
- Normalisation : crop vertical de 497 × 1080 px centré sur le point focal réel de la pièce, puis ramené à 368 × 800 px pour la comparaison.
|
||||||
|
- État : My Library, utilisateur connecté, locale française, données réelles chargées.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- Aucun écart P0, P1 ou P2 ne subsiste.
|
||||||
|
- Fonts and typography : les textes de l’interface et la typographie intégrée au décor restent inchangés et nets.
|
||||||
|
- Spacing and layout rhythm : le néon `WRITING SPACE`, le logo `MUSICLAND`, la fenêtre centrale et les fauteuils partagent maintenant le même axe visuel que l’écran.
|
||||||
|
- Colors and visual tokens : aucun filtre, changement de palette ou variation d’opacité n’a été ajouté.
|
||||||
|
- Image quality and asset fidelity : l’asset web 1920 × 1080 est réutilisé directement ; seul son cadrage natif est ajusté, sans recompression ni baisse de qualité.
|
||||||
|
- Copy and content : aucun texte applicatif n’a été modifié.
|
||||||
|
|
||||||
|
## Full-view Comparison Evidence
|
||||||
|
|
||||||
|
- Avant : le cadrage géométrique de l’image plaçait le point focal de la composition trop à gauche et coupait le début de `WRITING SPACE`.
|
||||||
|
- Après : le néon, le logo et le centre de la pièce sont équilibrés autour de l’axe du téléphone, tandis que le contenu et la barre de navigation restent inchangés.
|
||||||
|
|
||||||
|
## Focused-region Comparison Evidence
|
||||||
|
|
||||||
|
- La comparaison cible/implémentation montre le même crop autour du néon, de la fenêtre et des fauteuils. Aucun détail important n’est étiré ou reconstruit.
|
||||||
|
|
||||||
|
## Comparison History
|
||||||
|
|
||||||
|
1. P2 — point focal du fond décalé sur mobile.
|
||||||
|
2. Correction — ajout d’un style de fond optionnel au composant `Page` et cadrage My Library à 140 % de largeur, ancré à gauche pour recentrer le point focal.
|
||||||
|
3. Post-fix — capture iPhone 17 Pro et comparaison normalisée : aucun P0/P1/P2 restant.
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [x] Préserver l’asset web original.
|
||||||
|
- [x] Recentrer uniquement le cadrage mobile de My Library.
|
||||||
|
- [x] Reconstruire et relancer l’application iOS.
|
||||||
|
- [x] Vérifier visuellement l’écran authentifié.
|
||||||
|
|
||||||
|
## Follow-up Polish
|
||||||
|
|
||||||
|
- Aucun nécessaire.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design QA — Home, Playback et My Library (parité web/mobile)
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth — carte d’accueil : `/Users/minuit/musicland/src/assets/UI/musiclandStreaming.png`
|
||||||
|
- Source visual truth — fond Playback web : `/Users/minuit/musicland/src/assets/UI/playbackBG2.png`
|
||||||
|
- Source visual truth — fond My Library web : `/Users/minuit/musicland/src/assets/UI/libraryBgWeb.png`
|
||||||
|
- Capture d’implémentation — accueil mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_af967a03-10cb-447c-87e6-e04ff9564781.jpg`
|
||||||
|
- Capture d’implémentation — Playback mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_04b867f4-fd08-45e0-9227-9424f5c278b6.jpg`
|
||||||
|
- Capture d’implémentation — My Library mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_8ad858ba-7b4a-4e12-bd9b-f4df6e5c4459.jpg`
|
||||||
|
- Comparaison ciblée — carte Streaming : `/private/tmp/musicland-design-qa/home-card-compare.png`
|
||||||
|
- Comparaison ciblée — carte Playback : `/private/tmp/musicland-design-qa/playback-card-compare.png`
|
||||||
|
- Comparaison plein écran — fond Playback : `/private/tmp/musicland-design-qa/playback-background-compare.png`
|
||||||
|
- Comparaison plein écran — fond My Library : `/private/tmp/musicland-design-qa/library-background-compare.png`
|
||||||
|
- Viewport d’implémentation : iPhone 17 Pro Simulator, capture 368 × 800 px, densité normalisée par l’outil de capture.
|
||||||
|
- Sources : carte Streaming 1920 × 1080 px, carte Playback 1920 × 1080 px, fond Playback 2048 × 2048 px, fond My Library 1920 × 1080 px.
|
||||||
|
- État : utilisateur connecté, locale française, accueil défilé jusqu’à la carte Streaming, onglets Playback et My Library chargés avec données.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- Aucun écart P0, P1 ou P2 exploitable ne subsiste.
|
||||||
|
- Fonts and typography: la typographie intégrée aux cartes Streaming et Playback est conservée sans reconstruction. Les textes d’interface gardent la hiérarchie et les retours à la ligne existants.
|
||||||
|
- Spacing and layout rhythm: la carte Streaming conserve le ratio 16:9 et les rayons existants. La carte Playback reste alignée au centre ; le léger zoom interne retire uniquement les contours blancs encodés au bord de l’image.
|
||||||
|
- Colors and visual tokens: les arrière-plans mobiles réutilisent exactement les assets web, sans recoloration ni filtre supplémentaire.
|
||||||
|
- Image quality and asset fidelity: les images originales sont réutilisées à leur résolution source. Aucun nouvel asset compressé ou approximatif n’a été créé.
|
||||||
|
- Copy and content: aucun contenu textuel métier n’a été modifié par cette passe.
|
||||||
|
|
||||||
|
## Full-view Comparison Evidence
|
||||||
|
|
||||||
|
- Playback : la comparaison place le crop vertical de `playbackBG2.png` à gauche et le rendu mobile à droite. Les projecteurs, le plateau, le mur d’écrans et la perspective correspondent ; les éléments d’interface restent lisibles par-dessus.
|
||||||
|
- My Library : la comparaison place le crop vertical de `libraryBgWeb.png` à gauche et le rendu mobile à droite. Le néon Writing Space, les écrans, les fauteuils et la palette correspondent au fond web attendu.
|
||||||
|
- Accueil : le rendu mobile montre la carte `STREAMING MUSICLAND` à la place de l’ancienne carte Club après l’étape 4.
|
||||||
|
|
||||||
|
## Focused-region Comparison Evidence
|
||||||
|
|
||||||
|
- Carte Streaming : comparaison source/rendu à dimensions égales, avec le même logo, la même onde, le même cadrage et les mêmes couleurs.
|
||||||
|
- Carte Playback : comparaison source/rendu ciblée sur le bord de l’image. Les contours blancs visibles dans la source aux coins sont absents du rendu grâce au crop interne, sans perte perceptible sur le logo.
|
||||||
|
|
||||||
|
## Comparison History
|
||||||
|
|
||||||
|
1. Passe initiale après correction : aucune différence P0/P1/P2 ; aucune itération corrective supplémentaire requise.
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [x] Remplacer la carte Club de l’accueil par la carte Streaming partagée sur web et mobile.
|
||||||
|
- [x] Utiliser le fond Playback web sur mobile.
|
||||||
|
- [x] Masquer les contours blancs de la carte Playback sans dégrader l’asset.
|
||||||
|
- [x] Utiliser le fond My Library web sur mobile.
|
||||||
|
- [x] Construire le bundle web.
|
||||||
|
- [x] Construire et lancer l’app iOS dans le simulateur.
|
||||||
|
- [x] Vérifier visuellement les trois écrans dans l’état authentifié.
|
||||||
|
|
||||||
|
## Follow-up Polish
|
||||||
|
|
||||||
|
- Aucun nécessaire pour cette correction ciblée.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design QA — Home Step 3 Card
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth: `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/codex-clipboard-b493e809-ad60-4136-998c-a6a8fec6c27e.png`
|
||||||
|
- Shared implementation asset: `/Users/minuit/musicland/src/assets/icons/video.png`
|
||||||
|
- Mobile implementation screenshot: `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_ad2bf22d-a0e4-44ad-a161-76e096792cb0.jpg`
|
||||||
|
- Web implementation screenshot: `/private/tmp/musicland-step3-web-full.png`
|
||||||
|
- Focused source/web comparison: `/private/tmp/musicland-step3-reference-vs-web.png`
|
||||||
|
- Source pixels: 1328 × 748 RGBA.
|
||||||
|
- Mobile viewport: 368 × 800 pixels on the iPhone 17 Pro simulator. The card is rendered by the production `StageCard` mobile variant with `contentFit="contain"`.
|
||||||
|
- Web capture viewport: 1280 × 720 pixels. The shared asset was served by Expo Web from `src/assets/icons/video.png`.
|
||||||
|
- Browser console: no errors on the standalone shared-asset render. The app landing page still reports pre-existing Stripe provider `internal` errors, unrelated to the card asset.
|
||||||
|
- Density normalization: the focused comparison scales the source proportionally to 720 px high and places it beside the 1280 × 720 web capture. The source image data and the repository asset have identical SHA-256 hashes.
|
||||||
|
- State: authenticated Home menu, French locale, scrolled to the locked Step 3 card.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- No actionable P0, P1, or P2 mismatches.
|
||||||
|
- Fonts and typography: the supplied `VIDEO / MUSICLAND` typography remains embedded in the raster asset and is reproduced exactly. The Step 3 label and localized description continue to use the existing Home card typography.
|
||||||
|
- Spacing and layout rhythm: the existing mobile and web `StageCard` layouts are unchanged. `contentFit="contain"` preserves the supplied aspect ratio without stretching or cropping.
|
||||||
|
- Colors and visual tokens: the dark navy panel, blue/magenta waveform, white lettering, and neon suit accents match the source asset exactly.
|
||||||
|
- Image quality and asset fidelity: the supplied RGBA PNG is used directly; no reconstruction, approximation, recompression, or generated replacement was introduced. The gray area visible behind transparency in the standalone browser image view is browser canvas chrome, not part of the app card.
|
||||||
|
- Copy and content: Step 3 keeps the localized Mr Benaï text in French and English. Step 4 keeps its localized Video-space text.
|
||||||
|
|
||||||
|
## Full-view Comparison
|
||||||
|
|
||||||
|
- The mobile Home capture confirms the new Mr Benaï visual appears in the Step 3 slot with the intended subject, waveform, logo, proportions, and rounded card edge.
|
||||||
|
- The web asset capture confirms Expo Web resolves the same 1328 × 748 shared source used by native.
|
||||||
|
|
||||||
|
## Focused-region Comparison
|
||||||
|
|
||||||
|
- The side-by-side source/web comparison shows identical subject placement, waveform geometry, typography, colors, and transparency boundaries. No focused correction was needed.
|
||||||
|
|
||||||
|
## Comparison History
|
||||||
|
|
||||||
|
- Pass 1: no P0/P1/P2 findings; no corrective iteration required.
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [x] Replace the shared Step 3 card asset with the supplied PNG.
|
||||||
|
- [x] Preserve the existing mobile and web card layouts.
|
||||||
|
- [x] Preserve localized Step 3 and Step 4 copy.
|
||||||
|
- [x] Verify the card on the mobile Home menu.
|
||||||
|
- [x] Verify the shared asset through the local Expo Web server.
|
||||||
|
|
||||||
|
## Follow-up Polish
|
||||||
|
|
||||||
|
- None required for this asset replacement.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design QA — Fonds Library et Playback web/mobile
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth Library : `/Users/minuit/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/7E14186C-23EE-4687-A68F-19B0F8E453B4/PHOTO-2026-08-27-15-22-58.jpg`
|
||||||
|
- Source visual truth Playback : `/Users/minuit/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/6C989EEC-09A2-4A2B-A014-33F05E6107AC/PHOTO-2026-08-27-15-22-58.jpg`
|
||||||
|
- Assets intégrés : `/Users/minuit/musicland/src/assets/UI/libraryMainBackground.jpg` et `/Users/minuit/musicland/src/assets/UI/playbackMainBackground.jpg`
|
||||||
|
- Capture mobile Library : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_4bb9262f-e642-483b-a7cf-368fa1e1f6c5.jpg`
|
||||||
|
- Capture mobile Playback : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_e8f9391d-85c6-45d5-aff0-6f414714b274.jpg`
|
||||||
|
- Rendu Browser du bundle web Library : `/private/tmp/musicland-design-qa/library-web-render.png`
|
||||||
|
- Rendu Browser du bundle web Playback : `/private/tmp/musicland-design-qa/playback-web-render.png`
|
||||||
|
- Comparaison Library mobile : `/private/tmp/musicland-design-qa/library-mobile-target-implementation.png`
|
||||||
|
- Comparaison Playback mobile : `/private/tmp/musicland-design-qa/playback-mobile-target-implementation.png`
|
||||||
|
- Comparaison Library web : `/private/tmp/musicland-design-qa/library-web-target-implementation.png`
|
||||||
|
- Comparaison Playback web : `/private/tmp/musicland-design-qa/playback-web-target-implementation.png`
|
||||||
|
- Viewport mobile : iPhone 17 Pro Simulator, iOS 26.5, capture 368 × 800 px.
|
||||||
|
- Viewport web : 1280 × 720 px dans le navigateur intégré.
|
||||||
|
- Source Library : 1280 × 724 px ; source Playback : 1280 × 714 px.
|
||||||
|
- Normalisation mobile : crop `cover` centré de 333 × 724 px pour Library et 328 × 714 px pour Playback, puis mise à l’échelle 368 × 800 px.
|
||||||
|
- État mobile : utilisateur connecté, locale française, onglets Library et Playback avec données réelles.
|
||||||
|
- État web : bundle Expo exporté et assets chargés depuis le serveur local. La session du navigateur intégré est sur l’inscription, donc les pages authentifiées complètes n’y ont pas été ouvertes.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- Aucun écart P0, P1 ou P2 sur les deux fonds.
|
||||||
|
- Fonts and typography : aucune typographie n’est recréée dans les fonds ; les textes et logos d’interface restent inchangés et lisibles.
|
||||||
|
- Spacing and layout rhythm : les deux points focaux sont centrés en portrait. Le bureau central de Library et la plateforme centrale de Playback restent alignés avec l’axe de l’écran.
|
||||||
|
- Colors and visual tokens : les palettes cyan, bleu nuit et magenta des sources sont conservées sans filtre ni recoloration.
|
||||||
|
- Image quality and asset fidelity : les fichiers fournis ont été copiés octet pour octet. Les SHA-256 des sources et des assets intégrés sont identiques. Aucun redimensionnement permanent ni recompression n’a été appliqué.
|
||||||
|
- Copy and content : aucun contenu applicatif n’a été modifié.
|
||||||
|
|
||||||
|
## Full-view Comparison Evidence
|
||||||
|
|
||||||
|
- Library mobile : le crop vertical rendu correspond au crop `cover` centré de la source ; la fenêtre, la guitare et la table restent sur le même axe.
|
||||||
|
- Playback mobile : le plafond néon, l’écran central et la plateforme correspondent au crop centré de la source.
|
||||||
|
- Web : les deux comparaisons côte à côte montrent que les fichiers exportés par Expo sont visuellement identiques aux sources fournies.
|
||||||
|
|
||||||
|
## Focused-region Comparison Evidence
|
||||||
|
|
||||||
|
- Les zones centrales critiques sont lisibles dans les comparaisons mobiles : fenêtre/bureau pour Library, hologramme/plateforme pour Playback.
|
||||||
|
- Les comparaisons web à 1280 px vérifient également les bords, la netteté et l’absence de halo ou de compression supplémentaire.
|
||||||
|
|
||||||
|
## Comparison History
|
||||||
|
|
||||||
|
1. Passe initiale après remplacement : aucune différence P0/P1/P2 ; aucune correction visuelle supplémentaire requise.
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [x] Copier les deux sources sans conversion.
|
||||||
|
- [x] Utiliser l’image 1 uniquement pour les écrans Library web et mobile.
|
||||||
|
- [x] Utiliser l’image 2 pour l’écran Playback partagé web et mobile.
|
||||||
|
- [x] Construire le bundle web et vérifier les deux assets dans le navigateur intégré.
|
||||||
|
- [x] Construire et lancer l’app iOS.
|
||||||
|
- [x] Ouvrir et capturer les deux onglets sur le simulateur.
|
||||||
|
- [x] Vérifier les erreurs console web : aucune erreur liée à cette modification ; seulement deux avertissements préexistants Stripe HTTP et Animated web.
|
||||||
|
|
||||||
|
## Follow-up Polish
|
||||||
|
|
||||||
|
- Aucun nécessaire pour ce remplacement d’assets.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|||||||
@@ -6,23 +6,35 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"preview": {
|
"preview": {
|
||||||
|
"channel": "preview",
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"simulator": true
|
"simulator": true
|
||||||
}
|
},
|
||||||
|
"channel": "preview"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"channel": "production",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "app-bundle",
|
"buildType": "app-bundle",
|
||||||
"autoIncrement": "versionCode",
|
"autoIncrement": "versionCode",
|
||||||
"resourceClass": "large"
|
"resourceClass": "large"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
"image": "latest",
|
||||||
"buildConfiguration": "Release",
|
"buildConfiguration": "Release",
|
||||||
"autoIncrement": "buildNumber"
|
"autoIncrement": "buildNumber"
|
||||||
|
},
|
||||||
|
"channel": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascAppId": "6796655773"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { setTimeout } = require('timers/promises')
|
|||||||
// changer sans déploiement et pointer temporairement vers un modèle saturé.
|
// changer sans déploiement et pointer temporairement vers un modèle saturé.
|
||||||
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
|
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
|
||||||
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
|
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
|
||||||
|
const MODEL_REQUEST_TIMEOUT_MS = 35000
|
||||||
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
|
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
|
||||||
|
|
||||||
const getErrorStatus = (error) => {
|
const getErrorStatus = (error) => {
|
||||||
@@ -21,8 +22,14 @@ const canTryFallbackModel = (error) => {
|
|||||||
const status = getErrorStatus(error)
|
const status = getErrorStatus(error)
|
||||||
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
|
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
|
||||||
|
|
||||||
|
if (error?.name === 'AbortError' || error?.name === 'TimeoutError' || error?.code === 'ABORT_ERR') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const message = String(error?.message || '').toLowerCase()
|
const message = String(error?.message || '').toLowerCase()
|
||||||
return (
|
return (
|
||||||
|
message.includes('abort') ||
|
||||||
|
message.includes('timeout') ||
|
||||||
message.includes('high demand') ||
|
message.includes('high demand') ||
|
||||||
message.includes('overloaded') ||
|
message.includes('overloaded') ||
|
||||||
message.includes('temporarily unavailable') ||
|
message.includes('temporarily unavailable') ||
|
||||||
@@ -41,6 +48,7 @@ const generateWithModelFallback = async ({ ai, modelNames, label, request }) =>
|
|||||||
const response = await ai.generate({
|
const response = await ai.generate({
|
||||||
...request,
|
...request,
|
||||||
model: googleAI.model(modelName),
|
model: googleAI.model(modelName),
|
||||||
|
abortSignal: AbortSignal.timeout(MODEL_REQUEST_TIMEOUT_MS),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response?.output) {
|
if (!response?.output) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { sendNotification } = require('./notifications')
|
|||||||
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||||
const COVER_OPTION_ID = 'musicland-playbacker'
|
const COVER_OPTION_ID = 'musicland-playbacker'
|
||||||
const COVER_SIZE = 1024
|
const COVER_SIZE = 1024
|
||||||
const PROFILE_SIZE = 228
|
const PROFILE_SIZE = 320
|
||||||
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
|
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
|
||||||
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||||
|
|
||||||
@@ -32,40 +32,56 @@ const normalizeArtistName = (value) => {
|
|||||||
return trimmedValue || 'MusicLand'
|
return trimmedValue || 'MusicLand'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getArtistFontSize = (artistName) => {
|
const normalizeSongTitle = (value) => {
|
||||||
if (artistName.length <= 16) return 52
|
if (typeof value !== 'string') {
|
||||||
if (artistName.length <= 24) return 44
|
return 'Sans titre'
|
||||||
|
}
|
||||||
|
const trimmedValue = value.trim()
|
||||||
|
return trimmedValue || 'Sans titre'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitleFontSize = (songTitle) => {
|
||||||
|
if (songTitle.length <= 16) return 38
|
||||||
|
if (songTitle.length <= 24) return 32
|
||||||
|
return 26
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsernameFontSize = (username) => {
|
||||||
|
if (username.length <= 16) return 52
|
||||||
|
if (username.length <= 24) return 44
|
||||||
return 36
|
return 36
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTextOverlay = (artistName) => {
|
const buildTextOverlay = ({ songTitle, username }) => {
|
||||||
const safeArtistName = escapeSvgText(artistName)
|
const safeSongTitle = escapeSvgText(songTitle)
|
||||||
const artistFontSize = getArtistFontSize(artistName)
|
const safeUsername = escapeSvgText(username)
|
||||||
|
const titleFontSize = getTitleFontSize(songTitle)
|
||||||
|
const usernameFontSize = getUsernameFontSize(username)
|
||||||
|
|
||||||
return Buffer.from(`
|
return Buffer.from(`
|
||||||
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
||||||
<text
|
<text
|
||||||
x="512"
|
x="512"
|
||||||
y="824"
|
y="170"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
font-family="${FONT_FAMILY}"
|
font-family="${FONT_FAMILY}"
|
||||||
font-size="${artistFontSize}"
|
font-size="${titleFontSize}"
|
||||||
font-weight="400"
|
font-weight="400"
|
||||||
letter-spacing="3"
|
letter-spacing="2"
|
||||||
fill="#FFFFFF"
|
fill="#FFFFFF"
|
||||||
>${safeArtistName}</text>
|
>${safeSongTitle}</text>
|
||||||
<text
|
<text
|
||||||
x="512"
|
x="512"
|
||||||
y="963"
|
y="963"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
font-family="${FONT_FAMILY}"
|
font-family="${FONT_FAMILY}"
|
||||||
font-size="54"
|
font-size="${usernameFontSize}"
|
||||||
font-weight="400"
|
font-weight="400"
|
||||||
letter-spacing="5"
|
letter-spacing="4"
|
||||||
fill="#FFFFFF"
|
fill="#FFFFFF"
|
||||||
>PLAYBACKER</text>
|
>${safeUsername}</text>
|
||||||
</svg>`)
|
</svg>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,26 +106,28 @@ const buildCircularMask = (size) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCoverIdentity(project) {
|
async function loadCoverIdentity(project) {
|
||||||
const artistName = normalizeArtistName(project.userName)
|
const username = normalizeArtistName(project.userName)
|
||||||
|
const songTitle = normalizeSongTitle(project.title)
|
||||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { artistName, profilePictureURL: null }
|
return { songTitle, username, profilePictureURL: project.coverCenterImageURL || null }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userSnapshot = await refList.users.doc(userId).get()
|
const userSnapshot = await refList.users.doc(userId).get()
|
||||||
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
||||||
return {
|
return {
|
||||||
artistName: normalizeArtistName(project.userName || userData?.userName),
|
songTitle,
|
||||||
profilePictureURL: userData?.profilePictureURL || null,
|
username: normalizeArtistName(project.userName || userData?.userName),
|
||||||
|
profilePictureURL: project.coverCenterImageURL || userData?.profilePictureURL || null,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[Cover] Unable to load profile picture', {
|
logger.warn('[Cover] Unable to load profile picture', {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
})
|
})
|
||||||
return { artistName, profilePictureURL: null }
|
return { songTitle, username, profilePictureURL: project.coverCenterImageURL || null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +187,16 @@ async function prepareMusiclandCover(project) {
|
|||||||
throw new Error('Utilisateur du projet introuvable')
|
throw new Error('Utilisateur du projet introuvable')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { artistName, profilePictureURL } = await loadCoverIdentity(project)
|
const { songTitle, username, profilePictureURL } = await loadCoverIdentity(project)
|
||||||
const profileOverlay = await buildProfileOverlay(profilePictureURL)
|
const profileOverlay = await buildProfileOverlay(profilePictureURL)
|
||||||
const overlays = [{ input: buildTextOverlay(artistName), left: 0, top: 0 }]
|
const overlays = [{ input: buildTextOverlay({ songTitle, username }), left: 0, top: 0 }]
|
||||||
|
|
||||||
if (profileOverlay) {
|
if (profileOverlay) {
|
||||||
overlays.unshift({ input: profileOverlay, left: 398, top: 454 })
|
overlays.unshift({
|
||||||
|
input: profileOverlay,
|
||||||
|
left: (COVER_SIZE - PROFILE_SIZE) / 2,
|
||||||
|
top: 568 - PROFILE_SIZE / 2,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverBuffer = await sharp(TEMPLATE_PATH)
|
const coverBuffer = await sharp(TEMPLATE_PATH)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const STRUCTURE_ALIASES = {
|
|||||||
|
|
||||||
const LYRICS_FUNCTION_OPTIONS = {
|
const LYRICS_FUNCTION_OPTIONS = {
|
||||||
region: REGION,
|
region: REGION,
|
||||||
|
timeoutSeconds: 180,
|
||||||
cors: true,
|
cors: true,
|
||||||
invoker: 'public',
|
invoker: 'public',
|
||||||
secrets: [GEMINI_API_KEY],
|
secrets: [GEMINI_API_KEY],
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-store-review": "~8.0.1",
|
"expo-store-review": "~8.0.1",
|
||||||
"expo-system-ui": "~4.0.9",
|
"expo-system-ui": "~4.0.9",
|
||||||
|
"expo-updates": "~0.27.5",
|
||||||
"expo-video": "~2.0.6",
|
"expo-video": "~2.0.6",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
"firebase": "~9.20.0",
|
"firebase": "~9.20.0",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"applinks": {
|
||||||
|
"apps": [],
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"appID": "SD7SD54VR9.Ai.musicland",
|
||||||
|
"paths": [
|
||||||
|
"/music/*",
|
||||||
|
"/musics/*",
|
||||||
|
"/playback/*",
|
||||||
|
"/playbacks/*",
|
||||||
|
"/task/*",
|
||||||
|
"/tasks/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"webcredentials": {
|
||||||
|
"apps": ["SD7SD54VR9.Ai.musicland"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 634 KiB |
@@ -220,17 +220,19 @@ export const background = {
|
|||||||
productionBG2,
|
productionBG2,
|
||||||
playbackBG,
|
playbackBG,
|
||||||
playbackBG2,
|
playbackBG2,
|
||||||
|
playbackMainBackground: require('./UI/playbackMainBackground.jpg'),
|
||||||
libraryBG,
|
libraryBG,
|
||||||
writingMobileBackground,
|
writingMobileBackground,
|
||||||
libraryBgWeb: require('./UI/libraryBgWeb.png'),
|
libraryBgWeb: require('./UI/libraryBgWeb.png'),
|
||||||
|
libraryMainBackground: require('./UI/libraryMainBackground.jpg'),
|
||||||
libraryBG2,
|
libraryBG2,
|
||||||
libraryBG2Web: require('./UI/libraryBG2Web.png'),
|
libraryBG2Web: require('./UI/libraryBG2Web.png'),
|
||||||
profileBG,
|
profileBG,
|
||||||
profileBgWeb: require('./UI/profileBgWeb.png'),
|
profileBgWeb: require('./UI/profileBgWeb.png'),
|
||||||
hitParadeBG,
|
hitParadeBG,
|
||||||
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
|
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
|
||||||
playbackWeb: require('./UI/playbackWeb.png'),
|
playbackWeb: require('./UI/videoSpaceBackground.jpg'),
|
||||||
playbackMobile: require('./UI/playbackMobile.png'),
|
playbackMobile: require('./UI/videoSpaceBackground.jpg'),
|
||||||
homeBG,
|
homeBG,
|
||||||
homeBGWeb: require('./UI/homeBGWeb.png'),
|
homeBGWeb: require('./UI/homeBGWeb.png'),
|
||||||
loginBgWeb: require('./UI/loginBgWeb.png'),
|
loginBgWeb: require('./UI/loginBgWeb.png'),
|
||||||
@@ -251,6 +253,11 @@ export const videos = {
|
|||||||
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
||||||
club: require('./video/club.mp4'),
|
club: require('./video/club.mp4'),
|
||||||
entryJingle: require('./video/entry-jingle.mp4'),
|
entryJingle: require('./video/entry-jingle.mp4'),
|
||||||
|
languageBackground: require('./video/language-background-web.mp4'),
|
||||||
|
writingSpaceIntroFr: require('./video/writing-space-intro-fr.mp4'),
|
||||||
|
writingSpaceIntroEn: require('./video/writing-space-intro-en.mp4'),
|
||||||
|
videoSpaceIntroFr: require('./video/video-space-intro-fr.mp4'),
|
||||||
|
videoSpaceIntroEn: require('./video/video-space-intro-en.mp4'),
|
||||||
welcomeFr: require('./video/welcome-fr.mp4'),
|
welcomeFr: require('./video/welcome-fr.mp4'),
|
||||||
welcomeEn: require('./video/welcome-en.mp4'),
|
welcomeEn: require('./video/welcome-en.mp4'),
|
||||||
}
|
}
|
||||||
@@ -264,6 +271,8 @@ export const img = {
|
|||||||
goodVibe,
|
goodVibe,
|
||||||
musiclandClub,
|
musiclandClub,
|
||||||
musiclandPlaybackCover,
|
musiclandPlaybackCover,
|
||||||
|
musiclandPlayback: require('./UI/musiclandPlayback.png'),
|
||||||
|
musiclandStreaming: require('./UI/musiclandStreaming.png'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cardsImg = {
|
export const cardsImg = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { VideoView, useVideoPlayer } from 'expo-video'
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||||
|
|
||||||
const BackgroundVideo = ({ source }) => {
|
const BackgroundVideo = ({ source, silent = false }) => {
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
const resolvedSource = useMemo(
|
const resolvedSource = useMemo(
|
||||||
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
|
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
|
||||||
@@ -20,9 +20,9 @@ const BackgroundVideo = ({ source }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
player.muted = muted
|
player.muted = silent || muted
|
||||||
player.play()
|
player.play()
|
||||||
}, [muted, player, source])
|
}, [muted, player, silent, source])
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return null
|
return null
|
||||||
@@ -37,6 +37,7 @@ const BackgroundVideo = ({ source }) => {
|
|||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
/>
|
/>
|
||||||
<View pointerEvents="none" style={styles.scrim} />
|
<View pointerEvents="none" style={styles.scrim} />
|
||||||
|
{!silent ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||||
@@ -48,8 +49,11 @@ const BackgroundVideo = ({ source }) => {
|
|||||||
size={20}
|
size={20}
|
||||||
color="#FFFFFF"
|
color="#FFFFFF"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
<Text style={styles.soundButtonText}>
|
||||||
|
{muted ? 'Activer le son' : 'Couper le son'}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'
|
|||||||
|
|
||||||
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
||||||
|
|
||||||
const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
const BackgroundVideo = ({ source, soundButtonStyle = null, silent = false }) => {
|
||||||
const videoRef = useRef(null)
|
const videoRef = useRef(null)
|
||||||
const [uri, setUri] = useState(null)
|
const [uri, setUri] = useState(null)
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
@@ -63,11 +63,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
video.muted = muted
|
video.muted = silent || muted
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
video.play()?.catch?.(() => {})
|
video.play()?.catch?.(() => {})
|
||||||
}
|
}
|
||||||
}, [muted])
|
}, [muted, silent])
|
||||||
|
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
return null
|
return null
|
||||||
@@ -82,10 +82,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
|||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted={muted}
|
muted={silent || muted}
|
||||||
controls={false}
|
controls={false}
|
||||||
/>
|
/>
|
||||||
<View pointerEvents="none" style={styles.scrim} />
|
<View pointerEvents="none" style={styles.scrim} />
|
||||||
|
{!silent ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||||
@@ -102,8 +103,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
|||||||
size={20}
|
size={20}
|
||||||
color="#FFFFFF"
|
color="#FFFFFF"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
<Text style={styles.soundButtonText}>
|
||||||
|
{muted ? 'Activer le son' : 'Couper le son'}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react'
|
import React, { useCallback, useEffect, useRef } from 'react'
|
||||||
import { View, Pressable, Text } from 'react-native'
|
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||||
import { videos } from '../assets'
|
import { videos } from '../assets'
|
||||||
import { Portal } from '@gorhom/portal'
|
import { Portal } from '@gorhom/portal'
|
||||||
@@ -96,7 +96,6 @@ const FullscreenIntroVideo = ({
|
|||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
paddingHorizontal: 10,
|
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -111,15 +110,7 @@ const FullscreenIntroVideo = ({
|
|||||||
player={player}
|
player={player}
|
||||||
nativeControls={false}
|
nativeControls={false}
|
||||||
contentFit={contentFit}
|
contentFit={contentFit}
|
||||||
height="100%"
|
style={StyleSheet.absoluteFill}
|
||||||
width="100%"
|
|
||||||
// style={{
|
|
||||||
// position: "absolute",
|
|
||||||
// top: 0,
|
|
||||||
// bottom: 0,
|
|
||||||
// left: 24,
|
|
||||||
// right: 24,
|
|
||||||
// }}
|
|
||||||
/>
|
/>
|
||||||
{allowSkip ? (
|
{allowSkip ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { img } from '../assets'
|
|||||||
import { FONT_FAMILY } from '../styles/Fonts'
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
const MusiclandPlaybackCover = ({
|
const MusiclandPlaybackCover = ({
|
||||||
artistName,
|
songTitle,
|
||||||
|
username,
|
||||||
profilePictureURL,
|
profilePictureURL,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const displayName =
|
const displayTitle =
|
||||||
typeof artistName === 'string' && artistName.trim() ? artistName.trim() : 'MusicLand'
|
typeof songTitle === 'string' && songTitle.trim() ? songTitle.trim() : 'Sans titre'
|
||||||
|
const displayUsername =
|
||||||
|
typeof username === 'string' && username.trim() ? username.trim() : 'MusicLand'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]}>
|
||||||
@@ -36,11 +39,18 @@ const MusiclandPlaybackCover = ({
|
|||||||
adjustsFontSizeToFit
|
adjustsFontSizeToFit
|
||||||
minimumFontScale={0.55}
|
minimumFontScale={0.55}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={styles.artistName}
|
style={styles.songTitle}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayTitle}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
minimumFontScale={0.55}
|
||||||
|
numberOfLines={1}
|
||||||
|
style={styles.username}
|
||||||
|
>
|
||||||
|
{displayUsername}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.playbacker}>PLAYBACKER</Text>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -57,25 +67,25 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
profilePicture: {
|
profilePicture: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: '22.3%',
|
width: '31.25%',
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
left: '38.85%',
|
left: '34.375%',
|
||||||
top: '44.3%',
|
top: '39.84375%',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
},
|
},
|
||||||
artistName: {
|
songTitle: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '76.5%',
|
top: '15%',
|
||||||
left: '16%',
|
left: '10%',
|
||||||
width: '68%',
|
width: '80%',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
fontSize: 21,
|
fontSize: 14,
|
||||||
lineHeight: 26,
|
lineHeight: 18,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 0.8,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
playbacker: {
|
username: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '3%',
|
bottom: '3%',
|
||||||
left: '10%',
|
left: '10%',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { memo } from 'react'
|
||||||
|
import { Image, StyleSheet, View } from 'react-native'
|
||||||
|
|
||||||
|
const SpaceLogoCard = ({ source, accessibilityLabel, imageStyle = null }) => (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Image
|
||||||
|
source={source}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
resizeMode="contain"
|
||||||
|
style={[styles.image, imageStyle]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 280,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
alignSelf: 'center',
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(SpaceLogoCard)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import DateTimePicker from '@react-native-community/datetimepicker'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||||
|
import { Palette } from '../../styles'
|
||||||
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
|
|
||||||
|
const getDefaultBirthDate = () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setFullYear(date.getFullYear() - 18)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeDateModal = ({ visible, onClose, onValidate, initialDate, language = 'fr' }) => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(initialDate || getDefaultBirthDate())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSelectedDate(initialDate || getDefaultBirthDate())
|
||||||
|
}
|
||||||
|
}, [initialDate, visible])
|
||||||
|
|
||||||
|
const handleChange = (event, date) => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
if (event.type === 'dismissed') {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
onValidate(date)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
setSelectedDate(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
return visible ? (
|
||||||
|
<DateTimePicker
|
||||||
|
testID="dateTimePicker"
|
||||||
|
value={initialDate || getDefaultBirthDate()}
|
||||||
|
mode="date"
|
||||||
|
display="default"
|
||||||
|
onChange={handleChange}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
presentationStyle="overFullScreen"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Fermer le sélecteur de date"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Date de naissance</Text>
|
||||||
|
<DateTimePicker
|
||||||
|
testID="dateTimePicker"
|
||||||
|
value={selectedDate}
|
||||||
|
mode="date"
|
||||||
|
display="spinner"
|
||||||
|
locale={language === 'en' ? 'en-GB' : 'fr-FR'}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
themeVariant="dark"
|
||||||
|
onChange={handleChange}
|
||||||
|
style={styles.picker}
|
||||||
|
/>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onClose}
|
||||||
|
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelText}>Annuler</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
onValidate(selectedDate)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => [styles.button, styles.validateButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.validateText}>Valider</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 360,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: Palette.lightPurple,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.14)',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
width: '100%',
|
||||||
|
height: 180,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 46,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.28)',
|
||||||
|
},
|
||||||
|
validateButton: {
|
||||||
|
backgroundColor: Palette.white,
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
validateText: {
|
||||||
|
color: Palette.darkPurple,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.72,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default NativeDateModal
|
||||||
@@ -9,8 +9,10 @@ import { Platform } from 'react-native'
|
|||||||
|
|
||||||
const functionsInstances = {}
|
const functionsInstances = {}
|
||||||
const emulatorConfigured = {}
|
const emulatorConfigured = {}
|
||||||
const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60'
|
const emulatorHost =
|
||||||
const USE_FUNCTIONS_EMULATOR = __DEV__
|
process.env.EXPO_PUBLIC_FIREBASE_EMULATOR_HOST ||
|
||||||
|
(Platform.OS === 'web' ? 'localhost' : '127.0.0.1')
|
||||||
|
const USE_FUNCTIONS_EMULATOR = __DEV__ && process.env.EXPO_PUBLIC_USE_FIREBASE_EMULATORS === 'true'
|
||||||
|
|
||||||
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
|
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
|
||||||
const shouldUseEmulator = false
|
const shouldUseEmulator = false
|
||||||
|
|||||||
@@ -89,13 +89,14 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
|
|||||||
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
|
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
|
||||||
if (user.email) payload.email = user.email
|
if (user.email) payload.email = user.email
|
||||||
payload.emailNotifications = true
|
payload.emailNotifications = true
|
||||||
if (isNewUser) {
|
|
||||||
payload.registrationFlowRequired = true
|
|
||||||
}
|
|
||||||
} else if (typeof existingData?.emailNotifications === 'undefined') {
|
} else if (typeof existingData?.emailNotifications === 'undefined') {
|
||||||
payload.emailNotifications = true
|
payload.emailNotifications = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNewUser) {
|
||||||
|
payload.registrationFlowRequired = false
|
||||||
|
}
|
||||||
|
|
||||||
const overrideFirstName =
|
const overrideFirstName =
|
||||||
typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null
|
typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null
|
||||||
const overrideLastName =
|
const overrideLastName =
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const BottomTabScreen = () => {
|
|||||||
name={Routes.Library}
|
name={Routes.Library}
|
||||||
component={Library}
|
component={Library}
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: 'Librairie',
|
tabBarLabel: 'My library',
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarShowLabel: true,
|
tabBarShowLabel: true,
|
||||||
tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused),
|
tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused),
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ import { useGlobal } from 'reactn'
|
|||||||
import firebase, {
|
import firebase, {
|
||||||
arrayUnion,
|
arrayUnion,
|
||||||
notificationsRef,
|
notificationsRef,
|
||||||
|
projectsRef,
|
||||||
serverTimestamp,
|
serverTimestamp,
|
||||||
usersRef,
|
usersRef,
|
||||||
} from '../config/firebase'
|
} from '../config/firebase'
|
||||||
import useDataFromRef from '../hooks/useDataFromRef'
|
import useDataFromRef from '../hooks/useDataFromRef'
|
||||||
|
import { Routes } from '../navigation'
|
||||||
|
import { navigate } from '../navigation/NavigationService'
|
||||||
|
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const NotificationContext = createContext(null)
|
export const NotificationContext = createContext(null)
|
||||||
|
|
||||||
@@ -19,11 +33,14 @@ export default function NotificationProvider({ children }) {
|
|||||||
const [uid] = useGlobal('currentUID')
|
const [uid] = useGlobal('currentUID')
|
||||||
|
|
||||||
const [pendingData, setPendingData] = useGlobal('pendingData')
|
const [pendingData, setPendingData] = useGlobal('pendingData')
|
||||||
|
const [activeRouteName] = useGlobal('activeRouteName')
|
||||||
const [notifInit, setNotifInit] = useState(false)
|
const [notifInit, setNotifInit] = useState(false)
|
||||||
const [allowNotifications, setAllowNotifications] = useState(false)
|
const [allowNotifications, setAllowNotifications] = useState(false)
|
||||||
|
|
||||||
const notificationListener = useRef()
|
const notificationListener = useRef()
|
||||||
const responseListener = useRef()
|
const responseListener = useRef()
|
||||||
|
const handledResponseId = useRef(null)
|
||||||
|
const handledNavigationKey = useRef(null)
|
||||||
|
|
||||||
const notificationsQuery = useMemo(() => {
|
const notificationsQuery = useMemo(() => {
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
@@ -149,24 +166,29 @@ export default function NotificationProvider({ children }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
notificationListener.current = Notifications.addNotificationReceivedListener(
|
notificationListener.current = Notifications.addNotificationReceivedListener(
|
||||||
async (notification) => {
|
(notification) => {
|
||||||
console.log('Notification received', notification)
|
console.log('Notification received', notification)
|
||||||
await Notifications.scheduleNotificationAsync(notification?.request)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
responseListener.current = Notifications.addNotificationResponseReceivedListener(
|
const handleNotificationResponse = async (response) => {
|
||||||
async (response) => {
|
const requestId = response?.notification?.request?.identifier || null
|
||||||
|
if (requestId && handledResponseId.current === requestId) return
|
||||||
|
if (requestId) handledResponseId.current = requestId
|
||||||
|
|
||||||
console.log('Notification response', response)
|
console.log('Notification response', response)
|
||||||
if (response?.actionIdentifier === 'expo.modules.notifications.actions.DEFAULT') {
|
if (response?.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
||||||
const { data } = response.notification.request.content
|
const data = response?.notification?.request?.content?.data || null
|
||||||
if (data) {
|
if (data) {
|
||||||
console.log('Notification data', data)
|
console.log('Notification data', data)
|
||||||
await setPendingData(data)
|
await setPendingData(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
Notifications.getLastNotificationResponseAsync().then(handleNotificationResponse)
|
||||||
|
responseListener.current =
|
||||||
|
Notifications.addNotificationResponseReceivedListener(handleNotificationResponse)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Notifications.removeNotificationSubscription(notificationListener.current)
|
Notifications.removeNotificationSubscription(notificationListener.current)
|
||||||
@@ -174,6 +196,44 @@ export default function NotificationProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [allowNotifications, setPendingData])
|
}, [allowNotifications, setPendingData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const notificationType = pendingData?.type
|
||||||
|
const projectId = pendingData?.projectId
|
||||||
|
|
||||||
|
if (
|
||||||
|
!uid ||
|
||||||
|
!activeRouteName ||
|
||||||
|
notificationType !== 'PLAYBACK_GENERATION_SUCCESS' ||
|
||||||
|
!projectId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationKey = `${notificationType}:${projectId}`
|
||||||
|
if (handledNavigationKey.current === navigationKey) return
|
||||||
|
handledNavigationKey.current = navigationKey
|
||||||
|
|
||||||
|
const openGeneratedPlayback = async () => {
|
||||||
|
try {
|
||||||
|
const projectSnap = await projectsRef.doc(projectId).get()
|
||||||
|
if (!projectSnap.exists) return
|
||||||
|
|
||||||
|
navigate(Routes.PlaybackAiStatus, {
|
||||||
|
project: {
|
||||||
|
...projectSnap.data(),
|
||||||
|
id: projectSnap.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('openGeneratedPlayback error:', error)
|
||||||
|
} finally {
|
||||||
|
await setPendingData(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openGeneratedPlayback()
|
||||||
|
}, [activeRouteName, pendingData, setPendingData, uid])
|
||||||
|
|
||||||
async function registerForPushNotificationsAsync() {
|
async function registerForPushNotificationsAsync() {
|
||||||
try {
|
try {
|
||||||
const isWeb = Platform.OS === 'web'
|
const isWeb = Platform.OS === 'web'
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { UserDataContext } from './UserDataProvider'
|
|||||||
const APP_SCHEME = 'musicland'
|
const APP_SCHEME = 'musicland'
|
||||||
const APP_PATH_PREFIX = 'app'
|
const APP_PATH_PREFIX = 'app'
|
||||||
const APP_STORE_FALLBACK_URL = appleAppStoreUrl
|
const APP_STORE_FALLBACK_URL = appleAppStoreUrl
|
||||||
|
const NATIVE_DEEP_LINK_RESOURCES = new Set([
|
||||||
|
'music',
|
||||||
|
'musics',
|
||||||
|
'playback',
|
||||||
|
'playbacks',
|
||||||
|
'task',
|
||||||
|
'tasks',
|
||||||
|
])
|
||||||
|
|
||||||
const normalizePath = (rawPath = '') => {
|
const normalizePath = (rawPath = '') => {
|
||||||
if (!rawPath) return ''
|
if (!rawPath) return ''
|
||||||
@@ -27,6 +35,14 @@ const buildAppSchemeUrl = (rawPath, queryParams = {}) => {
|
|||||||
return `${APP_SCHEME}://${fullPath}${queryString ? `?${queryString}` : ''}`
|
return `${APP_SCHEME}://${fullPath}${queryString ? `?${queryString}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldOpenNativeApp = (rawPath) => {
|
||||||
|
const normalizedPath = normalizePath(rawPath)
|
||||||
|
if (normalizedPath.toLowerCase() === 'link') return true
|
||||||
|
|
||||||
|
const [resource, identifier] = normalizedPath.split('/').filter(Boolean)
|
||||||
|
return !!identifier && NATIVE_DEEP_LINK_RESOURCES.has(resource?.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
const UniversalLinkProvider = ({ children }) => {
|
const UniversalLinkProvider = ({ children }) => {
|
||||||
const { currentUID } = useContext(UserDataContext)
|
const { currentUID } = useContext(UserDataContext)
|
||||||
const { isFullyLoaded } = useContext(SplashAnimationContext)
|
const { isFullyLoaded } = useContext(SplashAnimationContext)
|
||||||
@@ -137,7 +153,7 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
const isIOSBrowser =
|
const isIOSBrowser =
|
||||||
/iPad|iPhone|iPod/.test(userAgent) ||
|
/iPad|iPhone|iPod/.test(userAgent) ||
|
||||||
(/Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2)
|
(/Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2)
|
||||||
if (isIOSBrowser) {
|
if (isIOSBrowser && shouldOpenNativeApp(path)) {
|
||||||
try {
|
try {
|
||||||
const canOpen = await Linking.canOpenURL(appSchemeURL)
|
const canOpen = await Linking.canOpenURL(appSchemeURL)
|
||||||
if (canOpen) {
|
if (canOpen) {
|
||||||
|
|||||||
@@ -420,9 +420,11 @@ export default ({ children }) => {
|
|||||||
selectProject(fallbackProjectId)
|
selectProject(fallbackProjectId)
|
||||||
}, [currentUserDoc?.selectedProjectId, selectProject, selectedProjectId, userProjects])
|
}, [currentUserDoc?.selectedProjectId, selectProject, selectedProjectId, userProjects])
|
||||||
|
|
||||||
|
const videoLanguage = currentUserDoc?.preferredLanguage === 'en' ? 'en' : 'fr'
|
||||||
const { data: videos } = useDataFromRef({
|
const { data: videos } = useDataFromRef({
|
||||||
ref: videosRef.doc('fr'),
|
ref: videosRef.doc(videoLanguage),
|
||||||
simpleRef: true,
|
simpleRef: true,
|
||||||
|
refreshArray: [videoLanguage],
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveSubscription = useMemo(() => {
|
const hasActiveSubscription = useMemo(() => {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const CreatePassword = ({ navigation }) => {
|
|||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
width={isWeb ? 600 : null}
|
width={isWeb ? 600 : null}
|
||||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
backgroundImg={background.loginBgWeb}
|
||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
title="Inscription"
|
title="Inscription"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { FlatList, Image, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
|
import { FlatList, Image, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
import { BlurView } from 'expo-blur'
|
import { BlurView } from 'expo-blur'
|
||||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { background, icons } from '../../assets'
|
import { background, img } from '../../assets'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import SearchBar from '../../components/SearchBar'
|
import SearchBar from '../../components/SearchBar'
|
||||||
import ShareBtn from '../../components/ShareBtn/ShareBtn'
|
import ShareBtn from '../../components/ShareBtn/ShareBtn'
|
||||||
@@ -18,7 +18,7 @@ import MobileCoinBadge from '../../components/MobileCoinBadge'
|
|||||||
import ResearchHeader from '../Library/components/ResearchHeader'
|
import ResearchHeader from '../Library/components/ResearchHeader'
|
||||||
import SearchResultsList from '../Library/components/SearchResultsList'
|
import SearchResultsList from '../Library/components/SearchResultsList'
|
||||||
import SongCard from './components/SongCard'
|
import SongCard from './components/SongCard'
|
||||||
import ClubCard from '../Home/components/ClubCard'
|
import SpaceLogoCard from '../../components/SpaceLogoCard'
|
||||||
|
|
||||||
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
||||||
const languageOptions = [
|
const languageOptions = [
|
||||||
@@ -466,11 +466,25 @@ const HitParade = () => {
|
|||||||
})
|
})
|
||||||
}, [navigateToMusicDetails, songsList])
|
}, [navigateToMusicDetails, songsList])
|
||||||
|
|
||||||
const heroTitle = isWeb
|
const heroContent = {
|
||||||
? 'Coups de coeur des utilisateurs'
|
Chansons: {
|
||||||
: `Coups de coeur des utilisateurs catégorie ${selectedCategory}`
|
title: 'Nouveautés Streaming',
|
||||||
|
subtitle: 'Découvre les morceaux publiés par la communauté MusicLand.',
|
||||||
const heroSubtitle = 'Les 3 musiques les plus écoutées du mois reçoivent des tokens.'
|
},
|
||||||
|
'Top du mois': {
|
||||||
|
title: 'Les plus écoutés ce mois-ci',
|
||||||
|
subtitle: 'Ce classement est basé sur le nombre d’écoutes du mois en cours.',
|
||||||
|
},
|
||||||
|
'All Time': {
|
||||||
|
title: 'Les plus écoutés de tous les temps',
|
||||||
|
subtitle: 'Ce classement est basé sur le nombre total d’écoutes.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const activeHeroContent = heroContent[selectedCategory] || heroContent.Chansons
|
||||||
|
const heroTitle = isWeb ? 'Streaming : nouveautés et classements' : activeHeroContent.title
|
||||||
|
const heroSubtitle = isWeb
|
||||||
|
? 'Découvre les nouveautés, le Top du mois et les titres les plus écoutés de tous les temps.'
|
||||||
|
: activeHeroContent.subtitle
|
||||||
|
|
||||||
const renderHero = () => (
|
const renderHero = () => (
|
||||||
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
||||||
@@ -658,7 +672,7 @@ const HitParade = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={isWeb ? background.hitParadeBG2 : background.hitParadeBG}
|
backgroundImg={background.hitParadeBG2}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
width="100%"
|
width="100%"
|
||||||
maxWidth={1200}
|
maxWidth={1200}
|
||||||
@@ -681,11 +695,10 @@ const HitParade = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={{ gap: 12, flex: 1 }}>
|
<View style={{ gap: 12, flex: 1 }}>
|
||||||
{/* <Image
|
<SpaceLogoCard
|
||||||
source={icons.musicLandLogo}
|
source={img.musiclandStreaming}
|
||||||
style={{ alignSelf: 'center', height: 150, resizeMode: 'contain' }}
|
accessibilityLabel="Streaming MusicLand"
|
||||||
/> */}
|
/>
|
||||||
<ClubCard />
|
|
||||||
<StreamingLanguageFilter
|
<StreamingLanguageFilter
|
||||||
selectedLanguage={selectedLanguage}
|
selectedLanguage={selectedLanguage}
|
||||||
onSelectLanguage={setSelectedLanguage}
|
onSelectLanguage={setSelectedLanguage}
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ const STAGE_CARD_CONTENT = [
|
|||||||
{
|
{
|
||||||
key: 'director',
|
key: 'director',
|
||||||
step: 'ÉTAPE 3',
|
step: 'ÉTAPE 3',
|
||||||
description:
|
description: {
|
||||||
'Tu vas faire une expérience extraordinaire, tu vas te filmer en train d’interpreter ta chanson en Play Back et je vais te guider pour te faciliter la tâche!',
|
fr: 'Je suis Mr Benaï, responsable de MusicLand Production et je vais t’expliquer toute la finalité de Musicland.',
|
||||||
|
en: "I'm Mr. Benaï, the head of MusicLand Production, and I'm going to explain to you what MusicLand is all about.",
|
||||||
|
},
|
||||||
image: cardsImg.video,
|
image: cardsImg.video,
|
||||||
|
imageStyle: { transform: [{ translateY: 10 }] },
|
||||||
imagePosition: 'left',
|
imagePosition: 'left',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
lockSide: 'right',
|
lockSide: 'right',
|
||||||
@@ -91,6 +94,7 @@ const Home = ({ navigation, route }) => {
|
|||||||
const { setTooltip } = useMinuit()
|
const { setTooltip } = useMinuit()
|
||||||
const [videoUrl, setVideoUrl] = useState(null)
|
const [videoUrl, setVideoUrl] = useState(null)
|
||||||
const navigateToMusicDetails = useNavigateToMusicDetails()
|
const navigateToMusicDetails = useNavigateToMusicDetails()
|
||||||
|
const preferredLanguage = currentUserData?.preferredLanguage === 'en' ? 'en' : 'fr'
|
||||||
|
|
||||||
if (!currentUID) {
|
if (!currentUID) {
|
||||||
return <LandingPage />
|
return <LandingPage />
|
||||||
@@ -314,7 +318,9 @@ const Home = ({ navigation, route }) => {
|
|||||||
const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
|
const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
|
||||||
|
|
||||||
const handleStartNew = useCallback(async () => {
|
const handleStartNew = useCallback(async () => {
|
||||||
const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi
|
const targetRoute = isWeb
|
||||||
|
? songwriterAction?.route || Routes.CreateLyricsWithAi
|
||||||
|
: Routes.WritingLyrics
|
||||||
const targetParams = songwriterAction?.params
|
const targetParams = songwriterAction?.params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -369,8 +375,8 @@ const Home = ({ navigation, route }) => {
|
|||||||
}, [hasLocalAdventureFlag, persistAdventureStarted, setTooltip])
|
}, [hasLocalAdventureFlag, persistAdventureStarted, setTooltip])
|
||||||
|
|
||||||
const handleStartVisit = useCallback(() => {
|
const handleStartVisit = useCallback(() => {
|
||||||
setVideoUrl(isWeb ? videos?.landingWeb : videos?.landing)
|
setVideoUrl(videos?.landing || null)
|
||||||
}, [])
|
}, [videos?.landing])
|
||||||
|
|
||||||
const handleReturnToLanding = useCallback(() => {
|
const handleReturnToLanding = useCallback(() => {
|
||||||
navigate(Routes.LandingPage)
|
navigate(Routes.LandingPage)
|
||||||
@@ -390,9 +396,13 @@ const Home = ({ navigation, route }) => {
|
|||||||
() =>
|
() =>
|
||||||
STAGE_CARD_CONTENT.map((card) => ({
|
STAGE_CARD_CONTENT.map((card) => ({
|
||||||
...card,
|
...card,
|
||||||
|
description:
|
||||||
|
typeof card.description === 'string'
|
||||||
|
? card.description
|
||||||
|
: card.description[preferredLanguage],
|
||||||
isLocked: stageStatesByKey[card.key]?.isLocked ?? true,
|
isLocked: stageStatesByKey[card.key]?.isLocked ?? true,
|
||||||
})),
|
})),
|
||||||
[stageStatesByKey]
|
[preferredLanguage, stageStatesByKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStagePress = useCallback(
|
const handleStagePress = useCallback(
|
||||||
@@ -413,8 +423,12 @@ const Home = ({ navigation, route }) => {
|
|||||||
if (!action?.route) {
|
if (!action?.route) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetRoute =
|
||||||
|
!isWeb && stageKey === 'songwriter' && action.route === Routes.CreateLyricsWithAi
|
||||||
|
? Routes.WritingLyrics
|
||||||
|
: action.route
|
||||||
console.log("action route is ", action.route)
|
console.log("action route is ", action.route)
|
||||||
navigate(action.route, action.params)
|
navigate(targetRoute, action.params)
|
||||||
},
|
},
|
||||||
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew]
|
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew]
|
||||||
)
|
)
|
||||||
@@ -438,6 +452,7 @@ const Home = ({ navigation, route }) => {
|
|||||||
step={card.step}
|
step={card.step}
|
||||||
description={card.description}
|
description={card.description}
|
||||||
image={card.image}
|
image={card.image}
|
||||||
|
imageStyle={card.imageStyle}
|
||||||
imagePosition={card.imagePosition}
|
imagePosition={card.imagePosition}
|
||||||
textAlign={card.textAlign}
|
textAlign={card.textAlign}
|
||||||
isLocked={card.isLocked}
|
isLocked={card.isLocked}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Club MusicLand"
|
accessibilityLabel="Streaming MusicLand"
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.card,
|
styles.card,
|
||||||
cardBaseStyle,
|
cardBaseStyle,
|
||||||
@@ -24,7 +24,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
|
|||||||
pressed && styles.cardPressed,
|
pressed && styles.cardPressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Image source={img.musiclandClub} style={imageStyle} />
|
<Image source={img.musiclandStreaming} style={imageStyle} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const StageCard = ({
|
|||||||
step,
|
step,
|
||||||
description,
|
description,
|
||||||
image,
|
image,
|
||||||
|
imageStyle = null,
|
||||||
imagePosition = 'left',
|
imagePosition = 'left',
|
||||||
textAlign = 'left',
|
textAlign = 'left',
|
||||||
isLocked,
|
isLocked,
|
||||||
@@ -79,6 +80,7 @@ const StageCard = ({
|
|||||||
: isImageOnLeft
|
: isImageOnLeft
|
||||||
? { alignSelf: 'flex-end' }
|
? { alignSelf: 'flex-end' }
|
||||||
: { alignSelf: 'flex-start' },
|
: { alignSelf: 'flex-start' },
|
||||||
|
imageStyle,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -206,6 +208,9 @@ const StageCard = ({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${step} — ${description}`}
|
||||||
|
accessibilityState={{ disabled: !!isLocked }}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
basePressableStyle,
|
basePressableStyle,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||||
import { background, icons, videos } from '../assets'
|
import { background, icons, videos } from '../assets'
|
||||||
|
import BackgroundVideo from '../components/BackgroundVideo'
|
||||||
import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
|
import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
|
||||||
|
import { isWeb } from '../hooks/useLayoutType'
|
||||||
import Page from '../layouts/Page'
|
import Page from '../layouts/Page'
|
||||||
import { Routes } from '../navigation'
|
import { Routes } from '../navigation'
|
||||||
import { navigate, reset } from '../navigation/NavigationService'
|
import { navigate, reset } from '../navigation/NavigationService'
|
||||||
@@ -85,7 +87,15 @@ export default function LandingPage({ route }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Page title="Landing Page" backgroundImg={background.homeBGWeb}>
|
<Page
|
||||||
|
title="Landing Page"
|
||||||
|
backgroundImg={background.homeBGWeb}
|
||||||
|
backgroundContent={
|
||||||
|
phase === FLOW_PHASE.LANGUAGE ? (
|
||||||
|
<BackgroundVideo source={videos.languageBackground} silent />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -121,7 +131,7 @@ export default function LandingPage({ route }) {
|
|||||||
url={activeVideo}
|
url={activeVideo}
|
||||||
visible
|
visible
|
||||||
onClose={activeVideoClose}
|
onClose={activeVideoClose}
|
||||||
contentFit="contain"
|
contentFit={isWeb || isJingle ? 'contain' : 'cover'}
|
||||||
allowSkip={!isJingle}
|
allowSkip={!isJingle}
|
||||||
requireAudio={!isJingle}
|
requireAudio={!isJingle}
|
||||||
offerSoundActivation={isJingle}
|
offerSoundActivation={isJingle}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const Library = () => {
|
|||||||
hasLikedClips
|
hasLikedClips
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.libraryBG} headerType="NONE">
|
<Page backgroundImg={background.libraryMainBackground} headerType="NONE">
|
||||||
{Platform.OS !== 'web' ? (
|
{Platform.OS !== 'web' ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const Library = () => {
|
|||||||
}, [dropdownVisible, closeDropdown])
|
}, [dropdownVisible, closeDropdown])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.libraryBgWeb} headerType="NONE" width={'80%'}>
|
<Page backgroundImg={background.libraryMainBackground} headerType="NONE" width={'80%'}>
|
||||||
<View style={{ gap: 17, paddingBottom: 20, zIndex: dropdownVisible ? 40 : 1 }}>
|
<View style={{ gap: 17, paddingBottom: 20, zIndex: dropdownVisible ? 40 : 1 }}>
|
||||||
<Image
|
<Image
|
||||||
source={icons.musicLandLogo}
|
source={icons.musicLandLogo}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import React, { useCallback, useMemo } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { StyleSheet, Text, View } from 'react-native'
|
import { StyleSheet, Text, View } from 'react-native'
|
||||||
import { useIsFocused } from '@react-navigation/native'
|
import { background, videos } from '../../assets'
|
||||||
import { background } from '../../assets'
|
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
|
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { isWeb } from '../../hooks/useLayoutType'
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
import { useUser, useUserData } from '../../providers/UserDataProvider'
|
import { useUserData } from '../../providers/UserDataProvider'
|
||||||
import { gutters, Palette } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
import {
|
import {
|
||||||
getPlaybackAiStatusLabel,
|
getPlaybackAiStatusLabel,
|
||||||
@@ -19,11 +18,25 @@ import {
|
|||||||
} from '../../utils/playbackAi'
|
} from '../../utils/playbackAi'
|
||||||
|
|
||||||
const Playback = ({ route, navigation }) => {
|
const Playback = ({ route, navigation }) => {
|
||||||
const isFocused = useIsFocused()
|
|
||||||
const { project: routeProject, returnToAdventureModal = false } = route.params || {}
|
const { project: routeProject, returnToAdventureModal = false } = route.params || {}
|
||||||
const { videos } = useUser()
|
const { currentUserData, selectedProject } = useUserData() || {}
|
||||||
const { selectedProject } = useUserData() || {}
|
const [videoUrl, setVideoUrl] = useState(null)
|
||||||
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
|
const preferredLanguage = currentUserData
|
||||||
|
? currentUserData.preferredLanguage === 'en'
|
||||||
|
? 'en'
|
||||||
|
: 'fr'
|
||||||
|
: null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preferredLanguage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoUrl(
|
||||||
|
preferredLanguage === 'en' ? videos.videoSpaceIntroEn : videos.videoSpaceIntroFr
|
||||||
|
)
|
||||||
|
}, [preferredLanguage])
|
||||||
|
|
||||||
const project = useMemo(() => {
|
const project = useMemo(() => {
|
||||||
if (routeProject?.id && selectedProject?.id === routeProject.id) {
|
if (routeProject?.id && selectedProject?.id === routeProject.id) {
|
||||||
return selectedProject
|
return selectedProject
|
||||||
@@ -69,14 +82,6 @@ const Playback = ({ route, navigation }) => {
|
|||||||
<Page
|
<Page
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
||||||
backgroundContent={
|
|
||||||
isFocused ? (
|
|
||||||
<BackgroundVideo
|
|
||||||
source={theoUrl}
|
|
||||||
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
containerStyle={isWeb ? styles.videoPage : undefined}
|
containerStyle={isWeb ? styles.videoPage : undefined}
|
||||||
>
|
>
|
||||||
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
|
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
|
||||||
@@ -115,6 +120,13 @@ const Playback = ({ route, navigation }) => {
|
|||||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<FullscreenIntroVideo
|
||||||
|
url={videoUrl}
|
||||||
|
visible={!!videoUrl}
|
||||||
|
onClose={() => setVideoUrl(null)}
|
||||||
|
contentFit={isWeb ? 'contain' : 'cover'}
|
||||||
|
skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,10 +138,6 @@ const styles = StyleSheet.create({
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
marginLeft: '4%',
|
marginLeft: '4%',
|
||||||
},
|
},
|
||||||
rightSideControl: {
|
|
||||||
right: 24,
|
|
||||||
left: 'auto',
|
|
||||||
},
|
|
||||||
blockedText: {
|
blockedText: {
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
opacity: 0.75,
|
opacity: 0.75,
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ const PlaybackAi = ({ route }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={background.playbackBG2}
|
backgroundImg={Platform.OS === 'web' ? background.playbackWeb : background.playbackMobile}
|
||||||
backgroundColor="#07111B"
|
backgroundColor="#07111B"
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const PlaybackAiStatus = ({ route }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={background.playbackBG2}
|
backgroundImg={background.playbackMobile}
|
||||||
backgroundColor="#07111B"
|
backgroundColor="#07111B"
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const PlaybackAiStatus = ({ route }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={background.playbackBG2}
|
backgroundImg={background.playbackWeb}
|
||||||
backgroundColor="#07111B"
|
backgroundColor="#07111B"
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,14 +27,13 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
Le Guide Ultime du Playback de TA Chanson : L'Expérience Créative Totale
|
Le Guide Ultime du Playback L'Expérience Créative Totale
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>
|
<Text style={styles.sectionTitle}>Introduction</Text>
|
||||||
Introduction : Tu Es l'Artiste ET le Performeur !
|
<Text style={styles.sectionTitle}>Tu es l'artiste et l’interprête!</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Imagine ça : tu as créé Ta chanson, TES paroles, TA mélodie, TON univers. Et
|
Imagine ça : tu as créé TA chanson, TES paroles, TA mélodie, TON univers. Et
|
||||||
maintenant, tu vas lui donner vie en playback. C'est une expérience UNIQUE au monde !
|
maintenant, tu vas lui donner vie en playback. C'est une expérience UNIQUE au monde !
|
||||||
Tu n'imites personne - tu deviens la version scénique de toi-même. C'est comme avoir
|
Tu n'imites personne - tu deviens la version scénique de toi-même. C'est comme avoir
|
||||||
un jumeau qui chante pendant que toi, tu performes. Double dose de créativité, double
|
un jumeau qui chante pendant que toi, tu performes. Double dose de créativité, double
|
||||||
@@ -53,9 +52,9 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
transparaître dans chaque mouvement de tes lèvres.
|
transparaître dans chaque mouvement de tes lèvres.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.subTitle}>La Règle des 15 Écoutes - Version Créateur</Text>
|
<Text style={styles.subTitle}>La Règle des 12 Écoutes - Version Créateur</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Oui, même pour TA chanson, les 15 écoutes sont cruciales ! Mais avec une twist magique
|
Oui, même pour TA chanson, les 12 écoutes sont cruciales ! Mais avec une twist magique
|
||||||
:
|
:
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
@@ -69,12 +68,12 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ?
|
sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ?
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
<Text style={styles.bold}>Écoutes 11-15 : La Transformation</Text> Maintenant,
|
<Text style={styles.bold}>Écoutes 11-12 : La Transformation</Text> Maintenant,
|
||||||
détache-toi du "toi qui a enregistré" pour devenir le "toi qui performe". C'est là que
|
détache-toi du "toi qui a enregistré" pour devenir le "toi qui interprête". C'est là
|
||||||
la magie opère !
|
que la magie opère !
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.subTitle}>Les Secrets du Créateur-Performeur</Text>
|
<Text style={styles.subTitle}>Les Secrets du PLAYBACKER</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
<Text style={styles.bold}>Le Phénomène de Dédoublement</Text> C'est normal de se
|
<Text style={styles.bold}>Le Phénomène de Dédoublement</Text> C'est normal de se
|
||||||
sentir bizarre au début - tu entends ta voix mais tu ne chantes pas ! Embrasse cette
|
sentir bizarre au début - tu entends ta voix mais tu ne chantes pas ! Embrasse cette
|
||||||
@@ -107,8 +106,9 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
|
|
||||||
<Text style={styles.subTitle}>Les Angles Qui Racontent Ton Histoire</Text>
|
<Text style={styles.subTitle}>Les Angles Qui Racontent Ton Histoire</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
<Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour les parties
|
<Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour une
|
||||||
intimes de ta chanson. Le spectateur doit sentir que tu lui confies un secret.
|
interprêtation intimes de ta chanson. Le spectateur doit sentir que tu lui confies un
|
||||||
|
secret.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
<Text style={styles.bold}>L'Angle "Concert" (Plan Large)</Text> Montre l'artiste
|
<Text style={styles.bold}>L'Angle "Concert" (Plan Large)</Text> Montre l'artiste
|
||||||
@@ -125,9 +125,10 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>C'est encore plus puissant quand c'est TA chanson !</Text>
|
<Text style={styles.text}>C'est encore plus puissant quand c'est TA chanson !</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Si tu as composé avec un instrument :{'\n'}• Mime-le même si tu ne l'as pas sous la
|
Si tu as composé avec un instrument :{'\n'}• Trouve l’instrument de ta chanson et mime
|
||||||
main
|
comme si tu savait en jouer.
|
||||||
{'\n'}• Montre les accords que TU as choisis
|
{'\n'}• Si tu n’en as pas, “fais comme si”, fait semblant, montre les accords que tu
|
||||||
|
as choisis
|
||||||
{'\n'}• Tes mains connaissent les mouvements par cœur
|
{'\n'}• Tes mains connaissent les mouvements par cœur
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
@@ -152,7 +153,7 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
<Text style={styles.subTitle}>Les Scènes Qui Amplifient Ta Création</Text>
|
<Text style={styles.subTitle}>Les Scènes Qui Amplifient Ta Création</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
<Text style={styles.bold}>L'Endroit Où Tu l'as Écrite - Le Retour aux Sources</Text>{' '}
|
<Text style={styles.bold}>L'Endroit Où Tu l'as Écrite - Le Retour aux Sources</Text>{' '}
|
||||||
Si tu as écrit ta chanson dans ta chambre, filme-là ! C'est puissant de montrer
|
Si tu as écrit ta chanson dans ta chambre, filme là ! C'est puissant de montrer
|
||||||
l'artiste là où la magie a commencé. L'authenticité sera palpable.
|
l'artiste là où la magie a commencé. L'authenticité sera palpable.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
@@ -174,10 +175,12 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
<Text style={styles.subTitle}>Suggestions Créatives Uniques :</Text>
|
<Text style={styles.subTitle}>Suggestions Créatives Uniques :</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
• La Voiture : Si c'est là que tu as eu l'idée de la mélodie
|
• La Voiture : Si c'est là que tu as eu l'idée de la mélodie
|
||||||
{'\n'}• La Douche : Avec le téléphone protégé, si c'est là que tu chantes toujours
|
|
||||||
{'\n'}• Le Toit/Balcon : Pour une sensation de "crier au monde" ta création
|
{'\n'}• Le Toit/Balcon : Pour une sensation de "crier au monde" ta création
|
||||||
{'\n'}• Devant le Miroir : Le dialogue entre toi créateur et toi performeur
|
{'\n'}• Devant le Miroir : Le dialogue entre toi créateur et toi performeur
|
||||||
{'\n'}• Dans le Noir avec une Seule Lumière : Pour l'aspect dramatique et intime
|
{'\n'}• Dans le Noir avec une Seule Lumière : Pour l'aspect dramatique et intime
|
||||||
|
{'\n'}• Dans un endroit insolite: une station service, sur un pont, sur une
|
||||||
|
balançoire, dans un pré, dans une forêt, dans une piscine,... ta seule limite… c’est
|
||||||
|
ton imagination!
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.chapterTitle}>Chapitre 4 : Le Mindset du Créateur-Performeur</Text>
|
<Text style={styles.chapterTitle}>Chapitre 4 : Le Mindset du Créateur-Performeur</Text>
|
||||||
@@ -226,7 +229,7 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
:{'\n'}• Les gens découvrent ta musique
|
:{'\n'}• Les gens découvrent ta musique
|
||||||
{'\n'}• Ils voient ta personnalité
|
{'\n'}• Ils voient ta personnalité
|
||||||
{'\n'}• Ils connectent avec l'artiste complet que tu es
|
{'\n'}• Ils connectent avec l'artiste complet que tu es
|
||||||
{'\n'}• Ils witness la naissance visuelle de ton art
|
{'\n'}• Ils participe à la naissance visuelle de ton art
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.chapterTitle}>Résumé : Ton Parcours Créatif Unique</Text>
|
<Text style={styles.chapterTitle}>Résumé : Ton Parcours Créatif Unique</Text>
|
||||||
@@ -266,8 +269,10 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
<Text style={styles.chapterTitle}>Ton Défi Ultime : La Première Mondiale !</Text>
|
<Text style={styles.chapterTitle}>Ton Défi Ultime : La Première Mondiale !</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Tu es sur le point de faire quelque chose qu'AUCUN autre artiste ne peut faire : créer
|
Tu es sur le point de faire quelque chose qu'AUCUN autre artiste ne peut faire : créer
|
||||||
la performance visuelle parfaite de TA chanson. C'est ta première mondiale, ton moment
|
la performance visuelle de TA chanson. C'est ta première mondiale, ton moment unique !
|
||||||
unique !
|
</Text>
|
||||||
|
<Text style={[styles.text, styles.bold]}>
|
||||||
|
Elle ne doit pas être parfaite, elle doit être authentique!
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Dans les 24 prochaines heures, donne vie visuellement à ta création. Choisis ta
|
Dans les 24 prochaines heures, donne vie visuellement à ta création. Choisis ta
|
||||||
@@ -283,12 +288,12 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
C'est ton moment. Ta chanson. Ta performance. Ton histoire.
|
C'est ton moment. Ta chanson. Ta performance. Ton histoire.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Le monde attend de découvrir l'artiste complet que tu es ! 🎤🎵✨
|
Le monde attend de découvrir l'artiste qui est en toi ! 🎤🎵✨
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={[styles.text, styles.bold]}>
|
||||||
#MonPlaybackMaChanson #CréateurPerformeur #DoubleArtiste
|
#MonPlaybackMaChanson #Playbacker-performer #Artisteletemps d’une chanson
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={[styles.text, styles.italic]}>
|
||||||
P.S. : Garde chaque prise, même les "ratées". Dans 10 ans, tu regarderas ces vidéos et
|
P.S. : Garde chaque prise, même les "ratées". Dans 10 ans, tu regarderas ces vidéos et
|
||||||
tu verras le début de ton parcours d'artiste. C'est de l'or pur !
|
tu verras le début de ton parcours d'artiste. C'est de l'or pur !
|
||||||
</Text>
|
</Text>
|
||||||
@@ -302,7 +307,9 @@ const PlaybackGuide = ({ navigation }) => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginHorizontal: Platform.OS === 'web' ? gutters : 0,
|
width: '100%',
|
||||||
|
maxWidth: 900,
|
||||||
|
alignSelf: 'center',
|
||||||
marginBottom: gutters,
|
marginBottom: gutters,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -314,6 +321,7 @@ const styles = StyleSheet.create({
|
|||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: gutters,
|
padding: gutters,
|
||||||
paddingBottom: gutters * 3,
|
paddingBottom: gutters * 3,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
@@ -328,6 +336,7 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.primary,
|
color: Palette.primary,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
chapterTitle: {
|
chapterTitle: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
@@ -343,6 +352,7 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
marginTop: 15,
|
marginTop: 15,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
@@ -350,10 +360,14 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
lineHeight: 24,
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
bold: {
|
bold: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
},
|
},
|
||||||
|
italic: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default PlaybackGuide
|
export default PlaybackGuide
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect, useIsFocused } from '@react-navigation/native'
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BackHandler, Text, TouchableOpacity, View } from 'react-native'
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
BackHandler,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import Svg, { Circle } from 'react-native-svg'
|
import Svg, { Circle } from 'react-native-svg'
|
||||||
import alert from '../../components/Alert'
|
import alert from '../../components/Alert'
|
||||||
@@ -25,6 +32,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|||||||
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
||||||
const COUNTDOWN_SECONDS = 10
|
const COUNTDOWN_SECONDS = 10
|
||||||
const CAMERA_STARTUP_DELAY_MS = 250
|
const CAMERA_STARTUP_DELAY_MS = 250
|
||||||
|
const CAMERA_READY_TIMEOUT_MS = 8000
|
||||||
const LOG_PREFIX = '[RecordPlayback]'
|
const LOG_PREFIX = '[RecordPlayback]'
|
||||||
const KEEP_AWAKE_TAG = 'record-playback'
|
const KEEP_AWAKE_TAG = 'record-playback'
|
||||||
const log =
|
const log =
|
||||||
@@ -39,6 +47,7 @@ const CAMERA_FACING_OPTIONS = [
|
|||||||
|
|
||||||
const RecordPlayback = ({ route }) => {
|
const RecordPlayback = ({ route }) => {
|
||||||
const { top, bottom } = useSafeAreaInsets()
|
const { top, bottom } = useSafeAreaInsets()
|
||||||
|
const isFocused = useIsFocused()
|
||||||
const { project } = route.params || {}
|
const { project } = route.params || {}
|
||||||
log('Route params received', { hasProject: !!project })
|
log('Route params received', { hasProject: !!project })
|
||||||
const projectId = project?.id
|
const projectId = project?.id
|
||||||
@@ -76,6 +85,9 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const [isRecording, setIsRecording] = useState(false)
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
const [showProgress, setShowProgress] = useState(false)
|
const [showProgress, setShowProgress] = useState(false)
|
||||||
const [cameraFacing, setCameraFacing] = useState('front')
|
const [cameraFacing, setCameraFacing] = useState('front')
|
||||||
|
const [cameraReady, setCameraReady] = useState(false)
|
||||||
|
const [cameraMountError, setCameraMountError] = useState(null)
|
||||||
|
const [cameraMountKey, setCameraMountKey] = useState(0)
|
||||||
|
|
||||||
// Musique
|
// Musique
|
||||||
const songUrl = project?.songUrl || null
|
const songUrl = project?.songUrl || null
|
||||||
@@ -771,14 +783,68 @@ const RecordPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permissionsGranted = !!cameraPermission?.granted
|
const permissionsGranted = !!cameraPermission?.granted
|
||||||
|
const cameraCanMount = permissionsGranted && isFocused
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
log('Camera permission state updated', { granted: permissionsGranted })
|
log('Camera permission state updated', { granted: permissionsGranted })
|
||||||
}, [permissionsGranted])
|
}, [permissionsGranted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCameraReady(false)
|
||||||
|
setCameraMountError(null)
|
||||||
|
}, [cameraCanMount, cameraFacing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cameraCanMount || cameraReady || cameraMountError) return undefined
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setCameraMountError("La caméra n'a pas répondu à temps.")
|
||||||
|
}, CAMERA_READY_TIMEOUT_MS)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [cameraCanMount, cameraFacing, cameraMountError, cameraMountKey, cameraReady])
|
||||||
|
|
||||||
|
const handleCameraFacingChange = useCallback((nextFacing) => {
|
||||||
|
setCameraReady(false)
|
||||||
|
setCameraMountError(null)
|
||||||
|
setCameraFacing(nextFacing)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCameraReady = useCallback(() => {
|
||||||
|
log('Camera preview ready', { facing: cameraFacing })
|
||||||
|
setCameraMountError(null)
|
||||||
|
setCameraReady(true)
|
||||||
|
}, [cameraFacing])
|
||||||
|
|
||||||
|
const handleCameraMountError = useCallback((event) => {
|
||||||
|
const message = event?.message || "La caméra n'a pas pu démarrer."
|
||||||
|
console.error(LOG_PREFIX, 'Camera mount error', message)
|
||||||
|
setCameraReady(false)
|
||||||
|
setCameraMountError(message)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const retryCameraMount = useCallback(() => {
|
||||||
|
setCameraReady(false)
|
||||||
|
setCameraMountError(null)
|
||||||
|
setCameraMountKey((currentKey) => currentKey + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1, backgroundColor: Palette.black }}>
|
||||||
<CameraView ref={cameraRef} style={{ flex: 1 }} facing={cameraFacing} mode="video" mute>
|
{cameraCanMount && (
|
||||||
|
<CameraView
|
||||||
|
key={`${cameraFacing}-${cameraMountKey}`}
|
||||||
|
ref={cameraRef}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
facing={cameraFacing}
|
||||||
|
mode="video"
|
||||||
|
mute
|
||||||
|
active={isFocused}
|
||||||
|
onCameraReady={handleCameraReady}
|
||||||
|
onMountError={handleCameraMountError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: gutters,
|
paddingHorizontal: gutters,
|
||||||
@@ -792,8 +858,8 @@ const RecordPlayback = ({ route }) => {
|
|||||||
<View style={{ marginTop: 12, alignItems: 'flex-end' }}>
|
<View style={{ marginTop: 12, alignItems: 'flex-end' }}>
|
||||||
<CameraFacingSelector
|
<CameraFacingSelector
|
||||||
value={cameraFacing}
|
value={cameraFacing}
|
||||||
onChange={setCameraFacing}
|
onChange={handleCameraFacingChange}
|
||||||
disabled={!permissionsGranted || isPreparing || isRecording}
|
disabled={!cameraReady || isPreparing || isRecording}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -844,10 +910,60 @@ const RecordPlayback = ({ route }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{cameraCanMount && !cameraReady && !cameraMountError && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={Palette.white} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Initialisation de la caméra…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cameraMountError && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Impossible d'afficher la caméra. Réessaie dans un instant.
|
||||||
|
</Text>
|
||||||
|
<GradientButton
|
||||||
|
title="Réessayer la caméra"
|
||||||
|
containerStyle={{ width: '80%' }}
|
||||||
|
onPress={retryCameraMount}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Start button */}
|
{/* Start button */}
|
||||||
{permissionsGranted && !isPreparing && !isRecording && (
|
{cameraReady && !isPreparing && !isRecording && (
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title="Lancer ma musique"
|
title="Lancer ma musique"
|
||||||
containerStyle={{ width: '80%', alignSelf: 'center' }}
|
containerStyle={{ width: '80%', alignSelf: 'center' }}
|
||||||
@@ -857,7 +973,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress circulaire + restart */}
|
{/* Progress circulaire + restart */}
|
||||||
{permissionsGranted && (isRecording || showProgress || isPreparing) && (
|
{cameraReady && (isRecording || showProgress || isPreparing) && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -906,7 +1022,6 @@ const RecordPlayback = ({ route }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CameraView>
|
|
||||||
{/* CreateLyricsHeader overlay outside of CameraView */}
|
{/* CreateLyricsHeader overlay outside of CameraView */}
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import useDataFromRef from '../../hooks/useDataFromRef'
|
|||||||
import usePlayer from '../../hooks/usePlayer'
|
import usePlayer from '../../hooks/usePlayer'
|
||||||
import PlaybackItem from './components/PlaybackItem'
|
import PlaybackItem from './components/PlaybackItem'
|
||||||
import PlaybackCharts from './components/PlaybackCharts'
|
import PlaybackCharts from './components/PlaybackCharts'
|
||||||
|
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
|
||||||
import { Palette } from '../../styles'
|
import { Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
|
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
|
||||||
|
|
||||||
const Playbacks = () => {
|
const Playbacks = () => {
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
@@ -21,7 +23,13 @@ const Playbacks = () => {
|
|||||||
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
||||||
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
||||||
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
|
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
|
||||||
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
|
const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE)
|
||||||
|
const {
|
||||||
|
data: rawPlaybacks = [],
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
} = useDataFromRef({
|
||||||
ref: projectsRef.where('playbackUrl', '!=', null),
|
ref: projectsRef.where('playbackUrl', '!=', null),
|
||||||
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
|
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
|
||||||
simpleRef: false,
|
simpleRef: false,
|
||||||
@@ -29,18 +37,23 @@ const Playbacks = () => {
|
|||||||
usePagination: true,
|
usePagination: true,
|
||||||
batchSize: 6,
|
batchSize: 6,
|
||||||
})
|
})
|
||||||
|
const filteredPlaybacks = useMemo(
|
||||||
|
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
|
||||||
|
[playbackType, rawPlaybacks]
|
||||||
|
)
|
||||||
|
|
||||||
const focusIndex = useMemo(() => {
|
const focusIndex = useMemo(() => {
|
||||||
if (!focusProjectId) return -1
|
if (!focusProjectId) return -1
|
||||||
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||||
}, [focusProjectId, rawPlaybacks])
|
}, [filteredPlaybacks, focusProjectId])
|
||||||
|
|
||||||
const playbacks = useMemo(() => {
|
const playbacks = useMemo(() => {
|
||||||
if (focusIndex < 0) return rawPlaybacks
|
if (focusIndex < 0) return filteredPlaybacks
|
||||||
const target = rawPlaybacks[focusIndex]
|
const target = filteredPlaybacks[focusIndex]
|
||||||
const before = rawPlaybacks.slice(0, focusIndex)
|
const before = filteredPlaybacks.slice(0, focusIndex)
|
||||||
const after = rawPlaybacks.slice(focusIndex + 1)
|
const after = filteredPlaybacks.slice(focusIndex + 1)
|
||||||
return [target, ...after, ...before]
|
return [target, ...after, ...before]
|
||||||
}, [focusIndex, rawPlaybacks])
|
}, [filteredPlaybacks, focusIndex])
|
||||||
|
|
||||||
const onSnap = useCallback(
|
const onSnap = useCallback(
|
||||||
(index) => {
|
(index) => {
|
||||||
@@ -64,6 +77,14 @@ const Playbacks = () => {
|
|||||||
}
|
}
|
||||||
}, [focusProjectId])
|
}, [focusProjectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusProjectId) return
|
||||||
|
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
|
||||||
|
if (focusedPlayback) {
|
||||||
|
setPlaybackType(getPlaybackType(focusedPlayback))
|
||||||
|
}
|
||||||
|
}, [focusProjectId, rawPlaybacks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadMore && triedLoadMoreRef.current < 6) {
|
if (loadMore && triedLoadMoreRef.current < 6) {
|
||||||
triedLoadMoreRef.current += 1
|
triedLoadMoreRef.current += 1
|
||||||
@@ -71,6 +92,12 @@ const Playbacks = () => {
|
|||||||
}
|
}
|
||||||
}, [loadMore, playbacks])
|
}, [loadMore, playbacks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playbacks.length === 0 && hasMore && !loading) {
|
||||||
|
loadMore?.()
|
||||||
|
}
|
||||||
|
}, [hasMore, loadMore, loading, playbacks.length])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusProjectId) return
|
if (!focusProjectId) return
|
||||||
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||||
@@ -108,6 +135,12 @@ const Playbacks = () => {
|
|||||||
|
|
||||||
const showFeed = viewMode === 'feed'
|
const showFeed = viewMode === 'feed'
|
||||||
|
|
||||||
|
const handlePlaybackTypeChange = useCallback((nextType) => {
|
||||||
|
setFocusProjectId(null)
|
||||||
|
setPlaybackType(nextType)
|
||||||
|
setActiveIndex(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openFeedForPlayback = useCallback((project) => {
|
const openFeedForPlayback = useCallback((project) => {
|
||||||
const nextId =
|
const nextId =
|
||||||
project && typeof project === 'object'
|
project && typeof project === 'object'
|
||||||
@@ -116,17 +149,27 @@ const Playbacks = () => {
|
|||||||
if (!nextId) {
|
if (!nextId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (project && typeof project === 'object') {
|
||||||
|
setPlaybackType(getPlaybackType(project))
|
||||||
|
}
|
||||||
setFocusProjectId(nextId)
|
setFocusProjectId(nextId)
|
||||||
setViewMode('feed')
|
setViewMode('feed')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!showFeed) {
|
if (!showFeed) {
|
||||||
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} />
|
return (
|
||||||
|
<PlaybackCharts
|
||||||
|
onPlaybackPress={openFeedForPlayback}
|
||||||
|
playbackType={playbackType}
|
||||||
|
onPlaybackTypeChange={handlePlaybackTypeChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: 'black' }}>
|
<View style={{ flex: 1, backgroundColor: 'black' }}>
|
||||||
<Carousel
|
<Carousel
|
||||||
|
key={playbackType}
|
||||||
data={playbacks}
|
data={playbacks}
|
||||||
vertical
|
vertical
|
||||||
height={responsiveHeight(100)}
|
height={responsiveHeight(100)}
|
||||||
@@ -142,6 +185,19 @@ const Playbacks = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{playbacks.length === 0 && !loading ? (
|
||||||
|
<View style={styles.emptyState} pointerEvents="none">
|
||||||
|
<Text style={styles.emptyStateText}>
|
||||||
|
Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.feedHeader}>
|
||||||
|
<PlaybackTypeTabs
|
||||||
|
value={playbackType}
|
||||||
|
onChange={handlePlaybackTypeChange}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.backToCharts}
|
style={styles.backToCharts}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
@@ -151,16 +207,25 @@ const Playbacks = () => {
|
|||||||
<Text style={styles.backToChartsText}>Classement</Text>
|
<Text style={styles.backToChartsText}>Classement</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Playbacks
|
export default Playbacks
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
backToCharts: {
|
feedHeader: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 32,
|
top: 32,
|
||||||
right: 20,
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
|
backToCharts: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -172,4 +237,16 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { Palette } from '../../styles'
|
|||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import PlaybackItem from './components/PlaybackItem.web'
|
import PlaybackItem from './components/PlaybackItem.web'
|
||||||
import PlaybackCharts from './components/PlaybackCharts'
|
import PlaybackCharts from './components/PlaybackCharts'
|
||||||
|
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
|
||||||
|
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
|
||||||
|
|
||||||
const Playbacks = () => {
|
const Playbacks = () => {
|
||||||
const { height: windowHeight } = useWindowDimensions()
|
const { height: windowHeight } = useWindowDimensions()
|
||||||
@@ -28,6 +30,7 @@ const Playbacks = () => {
|
|||||||
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
||||||
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
||||||
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
|
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
|
||||||
|
const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE)
|
||||||
const userCache = useRef(new Map())
|
const userCache = useRef(new Map())
|
||||||
const { getUserByUid } = useUser() || {}
|
const { getUserByUid } = useUser() || {}
|
||||||
const isFocused = useIsFocused()
|
const isFocused = useIsFocused()
|
||||||
@@ -44,18 +47,23 @@ const Playbacks = () => {
|
|||||||
usePagination: true,
|
usePagination: true,
|
||||||
batchSize: 6,
|
batchSize: 6,
|
||||||
})
|
})
|
||||||
|
const filteredPlaybacks = useMemo(
|
||||||
|
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
|
||||||
|
[playbackType, rawPlaybacks]
|
||||||
|
)
|
||||||
|
|
||||||
const focusIndex = useMemo(() => {
|
const focusIndex = useMemo(() => {
|
||||||
if (!focusProjectId) return -1
|
if (!focusProjectId) return -1
|
||||||
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||||
}, [focusProjectId, rawPlaybacks])
|
}, [filteredPlaybacks, focusProjectId])
|
||||||
|
|
||||||
const playbacks = useMemo(() => {
|
const playbacks = useMemo(() => {
|
||||||
if (focusIndex < 0) return rawPlaybacks
|
if (focusIndex < 0) return filteredPlaybacks
|
||||||
const target = rawPlaybacks[focusIndex]
|
const target = filteredPlaybacks[focusIndex]
|
||||||
const before = rawPlaybacks.slice(0, focusIndex)
|
const before = filteredPlaybacks.slice(0, focusIndex)
|
||||||
const after = rawPlaybacks.slice(focusIndex + 1)
|
const after = filteredPlaybacks.slice(focusIndex + 1)
|
||||||
return [target, ...after, ...before]
|
return [target, ...after, ...before]
|
||||||
}, [focusIndex, rawPlaybacks])
|
}, [filteredPlaybacks, focusIndex])
|
||||||
|
|
||||||
const activePlayback = playbacks?.[activeIndex] || null
|
const activePlayback = playbacks?.[activeIndex] || null
|
||||||
const activeVideoUrl = activePlayback?.playbackUrl || null
|
const activeVideoUrl = activePlayback?.playbackUrl || null
|
||||||
@@ -188,6 +196,14 @@ const Playbacks = () => {
|
|||||||
focusLoadAttemptsRef.current = 0
|
focusLoadAttemptsRef.current = 0
|
||||||
}, [focusProjectId])
|
}, [focusProjectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusProjectId) return
|
||||||
|
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
|
||||||
|
if (focusedPlayback) {
|
||||||
|
setPlaybackType(getPlaybackType(focusedPlayback))
|
||||||
|
}
|
||||||
|
}, [focusProjectId, rawPlaybacks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusProjectId) return
|
if (!focusProjectId) return
|
||||||
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||||
@@ -207,6 +223,12 @@ const Playbacks = () => {
|
|||||||
}
|
}
|
||||||
}, [focusIndex, focusProjectId, loadMore, playbacks])
|
}, [focusIndex, focusProjectId, loadMore, playbacks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playbacks.length === 0 && hasMore && !loading) {
|
||||||
|
loadMore?.()
|
||||||
|
}
|
||||||
|
}, [hasMore, loadMore, loading, playbacks.length])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextFocusId = route?.params?.projectId || route?.params?.focusId || null
|
const nextFocusId = route?.params?.projectId || route?.params?.focusId || null
|
||||||
setFocusProjectId(nextFocusId)
|
setFocusProjectId(nextFocusId)
|
||||||
@@ -335,6 +357,13 @@ const Playbacks = () => {
|
|||||||
|
|
||||||
const showFeed = viewMode === 'feed'
|
const showFeed = viewMode === 'feed'
|
||||||
|
|
||||||
|
const handlePlaybackTypeChange = useCallback((nextType) => {
|
||||||
|
setFocusProjectId(null)
|
||||||
|
setPlaybackType(nextType)
|
||||||
|
lastActiveIndexRef.current = 0
|
||||||
|
setActiveIndex(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openFeedForPlayback = useCallback((project) => {
|
const openFeedForPlayback = useCallback((project) => {
|
||||||
const nextId =
|
const nextId =
|
||||||
project && typeof project === 'object'
|
project && typeof project === 'object'
|
||||||
@@ -343,12 +372,21 @@ const Playbacks = () => {
|
|||||||
if (!nextId) {
|
if (!nextId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (project && typeof project === 'object') {
|
||||||
|
setPlaybackType(getPlaybackType(project))
|
||||||
|
}
|
||||||
setFocusProjectId(nextId)
|
setFocusProjectId(nextId)
|
||||||
setViewMode('feed')
|
setViewMode('feed')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!showFeed) {
|
if (!showFeed) {
|
||||||
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} />
|
return (
|
||||||
|
<PlaybackCharts
|
||||||
|
onPlaybackPress={openFeedForPlayback}
|
||||||
|
playbackType={playbackType}
|
||||||
|
onPlaybackTypeChange={handlePlaybackTypeChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -378,6 +416,7 @@ const Playbacks = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<FlatList
|
<FlatList
|
||||||
|
key={playbackType}
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
data={playbacks}
|
data={playbacks}
|
||||||
keyExtractor={(item, index) => String(item?.id || index)}
|
keyExtractor={(item, index) => String(item?.id || index)}
|
||||||
@@ -402,6 +441,13 @@ const Playbacks = () => {
|
|||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
/>
|
/>
|
||||||
|
{playbacks.length === 0 && !loading ? (
|
||||||
|
<View style={styles.emptyState} pointerEvents="none">
|
||||||
|
<Text style={styles.emptyStateText}>
|
||||||
|
Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
{showIndicators && (
|
{showIndicators && (
|
||||||
<View style={styles.indicatorContainer} pointerEvents="box-none">
|
<View style={styles.indicatorContainer} pointerEvents="box-none">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -453,6 +499,12 @@ const Playbacks = () => {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<View style={styles.feedHeader}>
|
||||||
|
<PlaybackTypeTabs
|
||||||
|
value={playbackType}
|
||||||
|
onChange={handlePlaybackTypeChange}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.backToCharts}
|
style={styles.backToCharts}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
@@ -463,6 +515,7 @@ const Playbacks = () => {
|
|||||||
<Text style={styles.backToChartsText}>Retour au classement</Text>
|
<Text style={styles.backToChartsText}>Retour au classement</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
// </Page>
|
// </Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -488,6 +541,17 @@ const styles = StyleSheet.create({
|
|||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(2, 2, 2, 0.35)',
|
backgroundColor: 'rgba(2, 2, 2, 0.35)',
|
||||||
},
|
},
|
||||||
|
feedHeader: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 28,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
topOverlayContainer: {
|
topOverlayContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 32,
|
top: 32,
|
||||||
@@ -572,9 +636,6 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
backToCharts: {
|
backToCharts: {
|
||||||
position: 'absolute',
|
|
||||||
top: 28,
|
|
||||||
right: 24,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
@@ -596,4 +657,16 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { FlatList, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
|
import { FlatList, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
import { BlurView } from 'expo-blur'
|
import { BlurView } from 'expo-blur'
|
||||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { background } from '../../../assets'
|
import { background, img } from '../../../assets'
|
||||||
import BorderGradientButton from '../../../components/BorderGradientButton'
|
import BorderGradientButton from '../../../components/BorderGradientButton'
|
||||||
import MobileCoinBadge from '../../../components/MobileCoinBadge'
|
import MobileCoinBadge from '../../../components/MobileCoinBadge'
|
||||||
import SearchBar from '../../../components/SearchBar'
|
import SearchBar from '../../../components/SearchBar'
|
||||||
@@ -17,7 +17,9 @@ import CreateLyricsHeader from '../../Writing/components/CreateLyricsHeader'
|
|||||||
import SongCard from '../../HitParade/components/SongCard'
|
import SongCard from '../../HitParade/components/SongCard'
|
||||||
import { Palette, Style } from '../../../styles'
|
import { Palette, Style } from '../../../styles'
|
||||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||||
import ClubCard from '../../Home/components/ClubCard'
|
import SpaceLogoCard from '../../../components/SpaceLogoCard'
|
||||||
|
import PlaybackTypeTabs from './PlaybackTypeTabs'
|
||||||
|
import { matchesPlaybackType, PLAYBACK_TYPE } from '../../../utils/playbackType'
|
||||||
|
|
||||||
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
||||||
|
|
||||||
@@ -37,7 +39,11 @@ const chunkArray = (items = [], size = 10) => {
|
|||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackCharts = ({ onPlaybackPress }) => {
|
const PlaybackCharts = ({
|
||||||
|
onPlaybackPress,
|
||||||
|
playbackType = PLAYBACK_TYPE.LIVE,
|
||||||
|
onPlaybackTypeChange,
|
||||||
|
}) => {
|
||||||
const isWeb = Platform.OS === 'web'
|
const isWeb = Platform.OS === 'web'
|
||||||
const [selectedCategory, setSelectedCategory] = useState('Playbacks')
|
const [selectedCategory, setSelectedCategory] = useState('Playbacks')
|
||||||
const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
|
const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
|
||||||
@@ -67,19 +73,25 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
condition: true,
|
condition: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const playbacksList = useMemo(
|
const playbacksList = useMemo(() => {
|
||||||
() => (Array.isArray(allPlaybacks) ? allPlaybacks : []),
|
const items = Array.isArray(allPlaybacks) ? allPlaybacks : []
|
||||||
[allPlaybacks]
|
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||||
)
|
}, [allPlaybacks, playbackType])
|
||||||
|
|
||||||
const topMonthList = useMemo(
|
const topMonthList = useMemo(
|
||||||
() => (Array.isArray(topMonthPlaybacks) ? topMonthPlaybacks : []),
|
() => {
|
||||||
[topMonthPlaybacks]
|
const items = Array.isArray(topMonthPlaybacks) ? topMonthPlaybacks : []
|
||||||
|
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||||
|
},
|
||||||
|
[playbackType, topMonthPlaybacks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const allTimeList = useMemo(
|
const allTimeList = useMemo(
|
||||||
() => (Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []),
|
() => {
|
||||||
[allTimePlaybacks]
|
const items = Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []
|
||||||
|
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||||
|
},
|
||||||
|
[allTimePlaybacks, playbackType]
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -104,7 +116,10 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics])
|
const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics])
|
||||||
const playbackResults = useMemo(() => (Array.isArray(playbacks) ? playbacks : []), [playbacks])
|
const playbackResults = useMemo(() => {
|
||||||
|
const items = Array.isArray(playbacks) ? playbacks : []
|
||||||
|
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||||
|
}, [playbackType, playbacks])
|
||||||
const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users])
|
const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users])
|
||||||
|
|
||||||
const musicsLoading = searchLoading
|
const musicsLoading = searchLoading
|
||||||
@@ -252,8 +267,18 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
[onPlaybackPress]
|
[onPlaybackPress]
|
||||||
)
|
)
|
||||||
|
|
||||||
const heroTitle = isWeb ? 'Playbacks coups de coeur' : 'Classements des playbacks'
|
const playbackTypeLabel = playbackType === PLAYBACK_TYPE.AI ? 'Playbacks IA' : 'Playbacks live'
|
||||||
const heroSubtitle = 'Découvre les playbacks les plus écoutés et lance la lecture verticale.'
|
const heroTitle = isWeb ? `${playbackTypeLabel} coups de coeur` : `Classement des ${playbackTypeLabel.toLowerCase()}`
|
||||||
|
const heroSubtitle =
|
||||||
|
playbackType === PLAYBACK_TYPE.AI
|
||||||
|
? 'Découvre les playbacks créés avec l’intelligence artificielle.'
|
||||||
|
: 'Découvre les performances enregistrées en live.'
|
||||||
|
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateText}>Aucun {playbackTypeLabel.toLowerCase()} pour le moment.</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
const renderHero = () => (
|
const renderHero = () => (
|
||||||
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
||||||
@@ -289,6 +314,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
data={playbacksList}
|
data={playbacksList}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
refreshing={allPlaybacksLoading}
|
refreshing={allPlaybacksLoading}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<SongCard
|
<SongCard
|
||||||
rank={null}
|
rank={null}
|
||||||
@@ -359,6 +385,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
onPress={() => handlePlaybackSelected(item)}
|
onPress={() => handlePlaybackSelected(item)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{playbacksList.length === 0 ? renderEmptyState() : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -435,7 +462,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Playbacks
|
{playbackTypeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -544,7 +571,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={isWeb ? background.playbackBG2 : background.playbackBG}
|
backgroundImg={background.playbackMainBackground}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
width="100%"
|
width="100%"
|
||||||
maxWidth={1200}
|
maxWidth={1200}
|
||||||
@@ -567,7 +594,13 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={{ gap: 12, flex: 1 }}>
|
<View style={{ gap: 12, flex: 1 }}>
|
||||||
<ClubCard />
|
<SpaceLogoCard
|
||||||
|
source={img.musiclandPlayback}
|
||||||
|
accessibilityLabel="Playback MusicLand"
|
||||||
|
imageStyle={styles.playbackLogoImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlaybackTypeTabs value={playbackType} onChange={onPlaybackTypeChange} />
|
||||||
|
|
||||||
{isWeb && (
|
{isWeb && (
|
||||||
<View
|
<View
|
||||||
@@ -660,3 +693,20 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default PlaybackCharts
|
export default PlaybackCharts
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
playbackLogoImage: {
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
paddingVertical: 32,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||||
|
import { Palette } from '../../../styles'
|
||||||
|
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||||
|
import { PLAYBACK_TYPE } from '../../../utils/playbackType'
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: 'Playbacks live', value: PLAYBACK_TYPE.LIVE },
|
||||||
|
{ label: 'Playbacks IA', value: PLAYBACK_TYPE.AI },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PlaybackTypeTabs = ({ value, onChange, compact = false, style }) => (
|
||||||
|
<View style={[styles.container, compact && styles.compactContainer, style]}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: isSelected }}
|
||||||
|
onPress={() => onChange?.(option.value)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.tab,
|
||||||
|
compact && styles.compactTab,
|
||||||
|
isSelected && styles.selectedTab,
|
||||||
|
pressed && styles.pressedTab,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.label,
|
||||||
|
compact && styles.compactLabel,
|
||||||
|
isSelected && styles.selectedLabel,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PlaybackTypeTabs
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: 'rgba(9, 6, 16, 0.62)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
},
|
||||||
|
compactContainer: {
|
||||||
|
alignSelf: 'auto',
|
||||||
|
padding: 3,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
minHeight: 42,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
compactTab: {
|
||||||
|
minHeight: 34,
|
||||||
|
paddingHorizontal: 11,
|
||||||
|
},
|
||||||
|
selectedTab: {
|
||||||
|
backgroundColor: Palette.white,
|
||||||
|
},
|
||||||
|
pressedTab: {
|
||||||
|
opacity: 0.78,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
compactLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
selectedLabel: {
|
||||||
|
color: '#0F0C14',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -12,7 +12,7 @@ import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../.
|
|||||||
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { navigate } from '../../navigation/NavigationService'
|
||||||
import { useUserData, useUser } from '../../providers/UserDataProvider'
|
import { useUserData, useUser } from '../../providers/UserDataProvider'
|
||||||
import { useStripe } from '../../providers/StripeProvider'
|
import { useStripe } from '../../providers/StripeProvider'
|
||||||
import { Palette } from '../../styles'
|
import { Palette } from '../../styles'
|
||||||
@@ -444,6 +444,15 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
isDownloading,
|
isDownloading,
|
||||||
pendingDownloadIntent,
|
pendingDownloadIntent,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const handleGoHome = useCallback(() => {
|
||||||
|
navigate(Routes.BottomTab, {
|
||||||
|
screen: Routes.HomeStack,
|
||||||
|
params: {
|
||||||
|
screen: Routes.Home,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FullscreenIntroVideo
|
<FullscreenIntroVideo
|
||||||
@@ -456,7 +465,7 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
backgroundColor="#07111B"
|
backgroundColor="#07111B"
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
<MusicLandHeader onPressBack={goBack} progress={50} />
|
<MusicLandHeader onPressBack={handleGoHome} progress={50} />
|
||||||
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title="Ton playback est prêt"
|
title="Ton playback est prêt"
|
||||||
@@ -505,6 +514,12 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Retour à l'accueil"
|
||||||
|
onPress={handleGoHome}
|
||||||
|
disabled={isPublishing || isDownloading}
|
||||||
|
containerStyle={styles.homeButton}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Page>
|
</Page>
|
||||||
</>
|
</>
|
||||||
@@ -586,4 +601,8 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
|
homeButton: {
|
||||||
|
marginTop: 2,
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { View } from 'react-native'
|
import Constants from 'expo-constants'
|
||||||
|
import * as Updates from 'expo-updates'
|
||||||
|
import { Text, View } from 'react-native'
|
||||||
import { SheetManager } from 'react-native-actions-sheet'
|
import { SheetManager } from 'react-native-actions-sheet'
|
||||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
@@ -45,6 +47,18 @@ const SETTINGS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const getVersionLabel = () => {
|
||||||
|
const appVersion = Constants.expoConfig?.version || 'inconnue'
|
||||||
|
const commitId = Constants.expoConfig?.extra?.commitId
|
||||||
|
const source = __DEV__ ? 'développement' : Updates.isEmbeddedLaunch ? 'intégrée' : 'OTA'
|
||||||
|
|
||||||
|
if (typeof commitId === 'string' && commitId) {
|
||||||
|
return `Version ${appVersion} · ${source} · commit ${commitId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Version ${appVersion} · ${source}`
|
||||||
|
}
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { onSignOut } = useUserData()
|
const { onSignOut } = useUserData()
|
||||||
|
|
||||||
@@ -84,6 +98,18 @@ const Settings = () => {
|
|||||||
}}
|
}}
|
||||||
onPress={() => SheetManager.show('DeleteAccount')}
|
onPress={() => SheetManager.show('DeleteAccount')}
|
||||||
/>
|
/>
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
style={{
|
||||||
|
paddingTop: 8,
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.7,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getVersionLabel()}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import * as AppleAuthentication from 'expo-apple-authentication'
|
import * as AppleAuthentication from 'expo-apple-authentication'
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker'
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { useGlobal } from 'reactn'
|
import { useGlobal } from 'reactn'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +18,7 @@ import { background } from '../assets'
|
|||||||
import BorderGradientButton from '../components/BorderGradientButton'
|
import BorderGradientButton from '../components/BorderGradientButton'
|
||||||
import { Input } from '../components/Input'
|
import { Input } from '../components/Input'
|
||||||
import ItemContainer from '../components/ItemContainer/ItemContainer'
|
import ItemContainer from '../components/ItemContainer/ItemContainer'
|
||||||
|
import NativeDateModal from '../components/modal/NativeDateModal'
|
||||||
import WebDateModal from '../components/modal/WebDateModal'
|
import WebDateModal from '../components/modal/WebDateModal'
|
||||||
import firebase from '../config/firebase'
|
import firebase from '../config/firebase'
|
||||||
import { isWeb } from '../hooks/useLayoutType'
|
import { isWeb } from '../hooks/useLayoutType'
|
||||||
@@ -152,13 +152,6 @@ const Register = ({ navigation, route }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDateChange = (event, selectedDate) => {
|
|
||||||
setShowDatePicker(Platform.OS === 'ios')
|
|
||||||
if (selectedDate) {
|
|
||||||
setBirthDate(selectedDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderFormContent = () => (
|
const renderFormContent = () => (
|
||||||
<View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}>
|
<View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}>
|
||||||
<Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}>
|
<Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}>
|
||||||
@@ -222,17 +215,13 @@ const Register = ({ navigation, route }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{showDatePicker && (
|
<NativeDateModal
|
||||||
<DateTimePicker
|
visible={showDatePicker}
|
||||||
testID="dateTimePicker"
|
onClose={() => setShowDatePicker(false)}
|
||||||
value={birthDate || new Date()}
|
onValidate={setBirthDate}
|
||||||
mode="date"
|
initialDate={birthDate}
|
||||||
is24Hour={true}
|
language={preferredLanguage}
|
||||||
display="default"
|
|
||||||
onChange={onDateChange}
|
|
||||||
maximumDate={new Date()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -291,7 +280,7 @@ const Register = ({ navigation, route }) => {
|
|||||||
<Page
|
<Page
|
||||||
width={isWeb ? '100%' : null}
|
width={isWeb ? '100%' : null}
|
||||||
maxWidth={isWeb ? 640 : 1200}
|
maxWidth={isWeb ? 640 : 1200}
|
||||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
backgroundImg={background.loginBgWeb}
|
||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
title="Inscription"
|
title="Inscription"
|
||||||
onBackPressed={handleBack}
|
onBackPressed={handleBack}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const RegistrationChoice = () => {
|
|||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
width={isWeb ? 720 : null}
|
width={isWeb ? 720 : null}
|
||||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
backgroundImg={background.loginBgWeb}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
title="Choisis ton expérience"
|
title="Choisis ton expérience"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Routes } from '../../navigation'
|
|||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
import { gutters } from '../../styles'
|
import { gutters } from '../../styles'
|
||||||
import { useUserData } from '../../providers/UserDataProvider'
|
import { useUserData } from '../../providers/UserDataProvider'
|
||||||
import { isWeb } from '../../hooks/useLayoutType'
|
|
||||||
|
|
||||||
const Compose = () => {
|
const Compose = () => {
|
||||||
const { videos } = useUserData()
|
const { videos } = useUserData()
|
||||||
@@ -21,8 +20,8 @@ const Compose = () => {
|
|||||||
const [videoUrl, setVideoUrl] = useState(null)
|
const [videoUrl, setVideoUrl] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVideoUrl(isWeb ? videos.malikWeb : videos?.malik)
|
setVideoUrl(videos?.malik || null)
|
||||||
}, [])
|
}, [videos?.malik])
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (fromLyrics) {
|
if (fromLyrics) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useIsFocused, useRoute } from '@react-navigation/native'
|
|||||||
import { Dimensions, Modal, Text, View } from 'react-native'
|
import { Dimensions, Modal, Text, View } from 'react-native'
|
||||||
import SwiperFlatList from 'react-native-swiper-flatlist'
|
import SwiperFlatList from 'react-native-swiper-flatlist'
|
||||||
import { background, icons } from '../../assets'
|
import { background, icons } from '../../assets'
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
|
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import AppAlert from '../../components/Alert'
|
import AppAlert from '../../components/Alert'
|
||||||
@@ -17,7 +17,7 @@ import { useUser } from '../../providers/UserDataProvider'
|
|||||||
import { gutters, Palette } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import { normalizeStructureType } from '../../utils/songStructure'
|
import { normalizeStructureType } from '../../utils/songStructure'
|
||||||
import { openCoinPackModal } from '../../utils/coinPackModal'
|
import { showInsufficientCreditsAlert } from '../../utils/insufficientCredits'
|
||||||
import ChooseGenre from './ChooseGenre'
|
import ChooseGenre from './ChooseGenre'
|
||||||
import ChooseInstruments from './ChooseInstruments'
|
import ChooseInstruments from './ChooseInstruments'
|
||||||
import ChooseRhythm from './ChooseRhythm'
|
import ChooseRhythm from './ChooseRhythm'
|
||||||
@@ -61,6 +61,7 @@ const ComposeSong = () => {
|
|||||||
|
|
||||||
const [isConfirmVisible, setIsConfirmVisible] = useState(false)
|
const [isConfirmVisible, setIsConfirmVisible] = useState(false)
|
||||||
const [isProcessingConfirmation, setIsProcessingConfirmation] = useState(false)
|
const [isProcessingConfirmation, setIsProcessingConfirmation] = useState(false)
|
||||||
|
const [showMalikVideo, setShowMalikVideo] = useState(true)
|
||||||
const isRegenerationFlow = route?.params?.isRegeneration === true
|
const isRegenerationFlow = route?.params?.isRegeneration === true
|
||||||
const fromLyrics = route?.params?.fromLyrics === true
|
const fromLyrics = route?.params?.fromLyrics === true
|
||||||
|
|
||||||
@@ -182,9 +183,11 @@ const ComposeSong = () => {
|
|||||||
typeof error?.message === 'string'
|
typeof error?.message === 'string'
|
||||||
? error.message.replace(/^functions\.https\.HttpsError:\s*/iu, '')
|
? error.message.replace(/^functions\.https\.HttpsError:\s*/iu, '')
|
||||||
: null
|
: null
|
||||||
throw new Error(
|
const orderError = new Error(
|
||||||
message || 'Une erreur est survenue lors de la création de la commande de crédits.'
|
message || 'Une erreur est survenue lors de la création de la commande de crédits.'
|
||||||
)
|
)
|
||||||
|
orderError.code = error?.code
|
||||||
|
throw orderError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +198,7 @@ const ComposeSong = () => {
|
|||||||
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0
|
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0
|
||||||
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
|
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
|
||||||
setIsConfirmVisible(false)
|
setIsConfirmVisible(false)
|
||||||
AppAlert(
|
showInsufficientCreditsAlert(MUSIC_GENERATION_COIN_COST)
|
||||||
'Crédits insuffisants',
|
|
||||||
"Tu n'as pas assez de pièces pour générer une musique. Recharge ton compte pour continuer."
|
|
||||||
)
|
|
||||||
openCoinPackModal()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +208,10 @@ const ComposeSong = () => {
|
|||||||
navigate(Routes.GeneratingSong, { config: musicConfig })
|
navigate(Routes.GeneratingSong, { config: musicConfig })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsConfirmVisible(false)
|
setIsConfirmVisible(false)
|
||||||
|
if (error?.code === 'functions/failed-precondition') {
|
||||||
|
showInsufficientCreditsAlert(MUSIC_GENERATION_COIN_COST)
|
||||||
|
return
|
||||||
|
}
|
||||||
const message =
|
const message =
|
||||||
error?.message || 'Une erreur est survenue lors du lancement de la génération.'
|
error?.message || 'Une erreur est survenue lors du lancement de la génération.'
|
||||||
AppAlert('Impossible de lancer la génération', message)
|
AppAlert('Impossible de lancer la génération', message)
|
||||||
@@ -303,13 +306,7 @@ const ComposeSong = () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||||
backgroundImg={background.studioBG2}
|
|
||||||
backgroundContent={
|
|
||||||
isFocused ? <BackgroundVideo source={videos?.malik} /> : null
|
|
||||||
}
|
|
||||||
headerType="NONE"
|
|
||||||
>
|
|
||||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
||||||
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
|
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
|
||||||
<View
|
<View
|
||||||
@@ -474,6 +471,11 @@ const ComposeSong = () => {
|
|||||||
</CreateLyricsHeader>
|
</CreateLyricsHeader>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<FullscreenIntroVideo
|
||||||
|
visible={isFocused && showMalikVideo && !!videos?.malik}
|
||||||
|
url={videos?.malik}
|
||||||
|
onClose={() => setShowMalikVideo(false)}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useIsFocused, useRoute } from '@react-navigation/native'
|
import { useIsFocused, useRoute } from '@react-navigation/native'
|
||||||
import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
|
import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
|
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import AppAlert from '../../components/Alert'
|
import AppAlert from '../../components/Alert'
|
||||||
@@ -16,7 +16,7 @@ import { useUser } from '../../providers/UserDataProvider'
|
|||||||
import { gutters, Palette } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import { normalizeStructureType } from '../../utils/songStructure'
|
import { normalizeStructureType } from '../../utils/songStructure'
|
||||||
import { openCoinPackModal } from '../../utils/coinPackModal'
|
import { showInsufficientCreditsAlert } from '../../utils/insufficientCredits'
|
||||||
import ChooseGenre from './ChooseGenre'
|
import ChooseGenre from './ChooseGenre'
|
||||||
import ChooseInstruments from './ChooseInstruments'
|
import ChooseInstruments from './ChooseInstruments'
|
||||||
import ChooseRhythm from './ChooseRhythm'
|
import ChooseRhythm from './ChooseRhythm'
|
||||||
@@ -64,6 +64,7 @@ const ComposeSong = () => {
|
|||||||
const [rhythm, setRhythm] = useState(null)
|
const [rhythm, setRhythm] = useState(null)
|
||||||
const [isConfirmVisible, setIsConfirmVisible] = useState(false)
|
const [isConfirmVisible, setIsConfirmVisible] = useState(false)
|
||||||
const [isProcessingConfirmation, setIsProcessingConfirmation] = useState(false)
|
const [isProcessingConfirmation, setIsProcessingConfirmation] = useState(false)
|
||||||
|
const [showMalikVideo, setShowMalikVideo] = useState(true)
|
||||||
const isRegenerationFlow = route?.params?.isRegeneration === true
|
const isRegenerationFlow = route?.params?.isRegeneration === true
|
||||||
const fromLyrics = route?.params?.fromLyrics === true
|
const fromLyrics = route?.params?.fromLyrics === true
|
||||||
|
|
||||||
@@ -238,9 +239,11 @@ const ComposeSong = () => {
|
|||||||
typeof error?.message === 'string'
|
typeof error?.message === 'string'
|
||||||
? error.message.replace(/^functions\.https\.HttpsError:\s*/iu, '')
|
? error.message.replace(/^functions\.https\.HttpsError:\s*/iu, '')
|
||||||
: null
|
: null
|
||||||
throw new Error(
|
const orderError = new Error(
|
||||||
message || 'Une erreur est survenue lors de la création de la commande de crédits.'
|
message || 'Une erreur est survenue lors de la création de la commande de crédits.'
|
||||||
)
|
)
|
||||||
|
orderError.code = error?.code
|
||||||
|
throw orderError
|
||||||
}
|
}
|
||||||
}, [currentUID, selectedProjectId])
|
}, [currentUID, selectedProjectId])
|
||||||
|
|
||||||
@@ -251,11 +254,7 @@ const ComposeSong = () => {
|
|||||||
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0
|
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0
|
||||||
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
|
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
|
||||||
setIsConfirmVisible(false)
|
setIsConfirmVisible(false)
|
||||||
AppAlert(
|
showInsufficientCreditsAlert(MUSIC_GENERATION_COIN_COST)
|
||||||
'Crédits insuffisants',
|
|
||||||
"Tu n'as pas assez de pièces pour générer une musique. Recharge ton compte pour continuer."
|
|
||||||
)
|
|
||||||
openCoinPackModal()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +264,10 @@ const ComposeSong = () => {
|
|||||||
navigate(Routes.GeneratingSong, { config: musicConfig })
|
navigate(Routes.GeneratingSong, { config: musicConfig })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsConfirmVisible(false)
|
setIsConfirmVisible(false)
|
||||||
|
if (error?.code === 'functions/failed-precondition') {
|
||||||
|
showInsufficientCreditsAlert(MUSIC_GENERATION_COIN_COST)
|
||||||
|
return
|
||||||
|
}
|
||||||
const message =
|
const message =
|
||||||
error?.message || 'Une erreur est survenue lors du lancement de la génération.'
|
error?.message || 'Une erreur est survenue lors du lancement de la génération.'
|
||||||
AppAlert('Impossible de lancer la génération', message)
|
AppAlert('Impossible de lancer la génération', message)
|
||||||
@@ -307,14 +310,6 @@ const ComposeSong = () => {
|
|||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={background.studioBG2}
|
backgroundImg={background.studioBG2}
|
||||||
backgroundContent={
|
|
||||||
isFocused ? (
|
|
||||||
<BackgroundVideo
|
|
||||||
source={videos?.malikWeb}
|
|
||||||
soundButtonStyle={styles.rightSideControl}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
coinBadgeContainerStyle={styles.rightSideCredits}
|
coinBadgeContainerStyle={styles.rightSideCredits}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
@@ -503,6 +498,11 @@ const ComposeSong = () => {
|
|||||||
</CreateLyricsHeader>
|
</CreateLyricsHeader>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<FullscreenIntroVideo
|
||||||
|
visible={isFocused && showMalikVideo && !!videos?.malik}
|
||||||
|
url={videos?.malik}
|
||||||
|
onClose={() => setShowMalikVideo(false)}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -510,10 +510,6 @@ const ComposeSong = () => {
|
|||||||
export default ComposeSong
|
export default ComposeSong
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
rightSideControl: {
|
|
||||||
right: 24,
|
|
||||||
left: 'auto',
|
|
||||||
},
|
|
||||||
rightSideCredits: {
|
rightSideCredits: {
|
||||||
right: 24,
|
right: 24,
|
||||||
left: 'auto',
|
left: 'auto',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const GeneratingSong = () => {
|
|||||||
const isFocused = useIsFocused()
|
const isFocused = useIsFocused()
|
||||||
const askedRef = useRef(false)
|
const askedRef = useRef(false)
|
||||||
const callingRef = useRef(false)
|
const callingRef = useRef(false)
|
||||||
|
const statusPollInFlightRef = useRef(false)
|
||||||
const localStartRef = useRef(null)
|
const localStartRef = useRef(null)
|
||||||
const errorAlertShownRef = useRef(false)
|
const errorAlertShownRef = useRef(false)
|
||||||
|
|
||||||
@@ -132,6 +133,46 @@ const GeneratingSong = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedProject?.musicStatus])
|
}, [selectedProject?.musicStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const taskId = selectedProject?.sunoTaskId
|
||||||
|
if (!isFocused || selectedProject?.musicStatus !== 'GENERATING' || !taskId) return undefined
|
||||||
|
|
||||||
|
let isActive = true
|
||||||
|
const getSunoStatus = firebase.functions().httpsCallable('music-getSunoStatus')
|
||||||
|
const pollStatus = async () => {
|
||||||
|
if (!isActive || statusPollInFlightRef.current) return
|
||||||
|
statusPollInFlightRef.current = true
|
||||||
|
try {
|
||||||
|
const { data } = await getSunoStatus({ taskId })
|
||||||
|
if (isActive && data?.failed && selectedProjectId) {
|
||||||
|
await projectsRef.doc(selectedProjectId).set(
|
||||||
|
{
|
||||||
|
musicStatus: 'FAILED',
|
||||||
|
musicError: {
|
||||||
|
source: 'SUNO_API',
|
||||||
|
message: data?.errorMessage || 'La génération Suno a échoué.',
|
||||||
|
},
|
||||||
|
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Suno status polling failed', error?.message || error)
|
||||||
|
} finally {
|
||||||
|
statusPollInFlightRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollStatus()
|
||||||
|
const interval = global.setInterval(pollStatus, 15000)
|
||||||
|
return () => {
|
||||||
|
isActive = false
|
||||||
|
global.clearInterval(interval)
|
||||||
|
statusPollInFlightRef.current = false
|
||||||
|
}
|
||||||
|
}, [isFocused, selectedProject?.musicStatus, selectedProject?.sunoTaskId, selectedProjectId])
|
||||||
|
|
||||||
const effectiveConfig = useMemo(() => {
|
const effectiveConfig = useMemo(() => {
|
||||||
// Use selectedProject.musicConfig only
|
// Use selectedProject.musicConfig only
|
||||||
const cfg = selectedProject?.musicConfig || {}
|
const cfg = selectedProject?.musicConfig || {}
|
||||||
@@ -165,8 +206,11 @@ const GeneratingSong = () => {
|
|||||||
tempo: cfg?.tempo,
|
tempo: cfg?.tempo,
|
||||||
projectId: selectedProjectId || selectedProject?.id || null,
|
projectId: selectedProjectId || selectedProject?.id || null,
|
||||||
})
|
})
|
||||||
const taskId = data?.response?.data?.taskId || data?.response?.data?.task_id
|
const taskId = data?.response?.data?.taskId
|
||||||
if (selectedProjectId && taskId) {
|
if (!taskId) {
|
||||||
|
throw new Error("Suno n'a pas retourné d'identifiant de génération.")
|
||||||
|
}
|
||||||
|
if (selectedProjectId) {
|
||||||
const baseUpdate = {
|
const baseUpdate = {
|
||||||
sunoTaskId: taskId,
|
sunoTaskId: taskId,
|
||||||
musicStatus: 'GENERATING',
|
musicStatus: 'GENERATING',
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ const SongReady = () => {
|
|||||||
})
|
})
|
||||||
}, [selectedProject?.musicUrls])
|
}, [selectedProject?.musicUrls])
|
||||||
|
|
||||||
|
const navigateToCover = useCallback(() => {
|
||||||
|
reset({
|
||||||
|
index: 1,
|
||||||
|
routes: [{ name: Routes.BottomTab }, { name: Routes.ChooseCoverType }],
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const validateSelection = async () => {
|
const validateSelection = async () => {
|
||||||
try {
|
try {
|
||||||
const url = musicUrls[selectedIndex]
|
const url = musicUrls[selectedIndex]
|
||||||
@@ -126,6 +133,7 @@ const SongReady = () => {
|
|||||||
coverUrl: null,
|
coverUrl: null,
|
||||||
coverStatus: null,
|
coverStatus: null,
|
||||||
playbackUrl: null,
|
playbackUrl: null,
|
||||||
|
playbackProvider: null,
|
||||||
playbackStatus: null,
|
playbackStatus: null,
|
||||||
playbackGenerating: null,
|
playbackGenerating: null,
|
||||||
songPublishedOnMusicLand: false,
|
songPublishedOnMusicLand: false,
|
||||||
@@ -144,10 +152,7 @@ const SongReady = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await pauseAllPlayers()
|
await pauseAllPlayers()
|
||||||
reset({
|
navigateToCover()
|
||||||
index: 1,
|
|
||||||
routes: [{ name: Routes.BottomTab }, { name: Routes.ChooseCoverType }],
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Validate error', e?.message)
|
console.log('Validate error', e?.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native'
|
|||||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { SwiperFlatList } from 'react-native-swiper-flatlist'
|
import { SwiperFlatList } from 'react-native-swiper-flatlist'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
|
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
|
||||||
@@ -27,7 +26,7 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
|||||||
const CTA_BUTTON_MAX_WIDTH = 500
|
const CTA_BUTTON_MAX_WIDTH = 500
|
||||||
|
|
||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { selectedProject, videos } = useUser()
|
const { selectedProject } = useUser()
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [progress, setProgress] = useState(16)
|
const [progress, setProgress] = useState(16)
|
||||||
@@ -342,15 +341,11 @@ const CreateLyricsWithAi = () => {
|
|||||||
}, [selectedIndex, parentLayout?.height])
|
}, [selectedIndex, parentLayout?.height])
|
||||||
|
|
||||||
const isCtaVisible = selectedIndex !== 8
|
const isCtaVisible = selectedIndex !== 8
|
||||||
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
backgroundImg={isQuestionStep ? null : background.writingMobileBackground}
|
backgroundImg={selectedIndex === MAX_STEP_INDEX ? null : background.writingMobileBackground}
|
||||||
backgroundContent={
|
backgroundColor="#0F0C19"
|
||||||
isQuestionStep ? <BackgroundVideo source={videos?.celine} /> : null
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
onLayout={(event) => {
|
onLayout={(event) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
const FAKE_PROGRESS_MAX = 96
|
const FAKE_PROGRESS_MAX = 96
|
||||||
const PROGRESS_INTERVAL_MS = 250
|
const PROGRESS_INTERVAL_MS = 250
|
||||||
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
||||||
const FUNCTIONS_REGION = 'europe-west1'
|
const FUNCTIONS_REGION = 'us-central1'
|
||||||
|
|
||||||
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
@@ -264,14 +264,14 @@ const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '50%',
|
...(Platform.OS === 'web' ? { height: '50%' } : { maxWidth: 540, alignSelf: 'center' }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: '#0F0C1933',
|
backgroundColor: Platform.OS === 'web' ? '#0F0C1933' : '#211A30',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
import { background, icons } from '../../assets'
|
import { background, icons } from '../../assets'
|
||||||
import alert from '../../components/Alert'
|
import alert from '../../components/Alert'
|
||||||
@@ -31,7 +31,7 @@ import CustomInput from './components/CustomInput'
|
|||||||
|
|
||||||
const RESPONSIBILITY_CHECKBOX_LABEL =
|
const RESPONSIBILITY_CHECKBOX_LABEL =
|
||||||
'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.'
|
'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.'
|
||||||
const FUNCTIONS_REGION = 'europe-west1'
|
const FUNCTIONS_REGION = 'us-central1'
|
||||||
|
|
||||||
const Lyrics = ({ navigation }) => {
|
const Lyrics = ({ navigation }) => {
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
@@ -142,6 +142,11 @@ const Lyrics = ({ navigation }) => {
|
|||||||
|
|
||||||
const [titleValue, setTitleValue] = useState(initial.title || '')
|
const [titleValue, setTitleValue] = useState(initial.title || '')
|
||||||
const [sections, setSections] = useState(initial.sections || [])
|
const [sections, setSections] = useState(initial.sections || [])
|
||||||
|
const moderationRequestRef = useRef({
|
||||||
|
signature: '',
|
||||||
|
data: null,
|
||||||
|
promise: null,
|
||||||
|
})
|
||||||
const setSectionAt = (index, value) => {
|
const setSectionAt = (index, value) => {
|
||||||
setSections((prev) => {
|
setSections((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
@@ -187,6 +192,77 @@ const Lyrics = ({ navigation }) => {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const getModerationResult = useCallback(({ title, lyrics }) => {
|
||||||
|
const signature = JSON.stringify({ title, lyrics })
|
||||||
|
const cachedRequest = moderationRequestRef.current
|
||||||
|
|
||||||
|
if (cachedRequest.signature === signature) {
|
||||||
|
if (cachedRequest.data) return Promise.resolve(cachedRequest.data)
|
||||||
|
if (cachedRequest.promise) return cachedRequest.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||||
|
'lyrics-analyseLyricsToxicity'
|
||||||
|
)
|
||||||
|
const requestPromise = callable({ title, lyrics })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (
|
||||||
|
moderationRequestRef.current.signature === signature &&
|
||||||
|
moderationRequestRef.current.promise === requestPromise
|
||||||
|
) {
|
||||||
|
moderationRequestRef.current = {
|
||||||
|
signature,
|
||||||
|
data,
|
||||||
|
promise: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (
|
||||||
|
moderationRequestRef.current.signature === signature &&
|
||||||
|
moderationRequestRef.current.promise === requestPromise
|
||||||
|
) {
|
||||||
|
moderationRequestRef.current = {
|
||||||
|
signature,
|
||||||
|
data: null,
|
||||||
|
promise: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
moderationRequestRef.current = {
|
||||||
|
signature,
|
||||||
|
data: null,
|
||||||
|
promise: requestPromise,
|
||||||
|
}
|
||||||
|
return requestPromise
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const title = (titleValue || '').trim()
|
||||||
|
const lyrics = (sections || []).map((section) => ({
|
||||||
|
type: normalizeStructureType(section?.type),
|
||||||
|
lyrics: segmentRequiresLyrics(normalizeStructureType(section?.type))
|
||||||
|
? (section?.lyrics || '').trim()
|
||||||
|
: '',
|
||||||
|
}))
|
||||||
|
const hasIncompleteSection = lyrics.some(
|
||||||
|
(section) => segmentRequiresLyrics(section.type) && !section.lyrics
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!title || hasIncompleteSection) return undefined
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
getModerationResult({ title, lyrics }).catch((error) => {
|
||||||
|
console.log('Background moderation failed', error?.message || error)
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [getModerationResult, sections, titleValue])
|
||||||
|
|
||||||
const handleSensitiveCancel = useCallback(() => {
|
const handleSensitiveCancel = useCallback(() => {
|
||||||
closeSensitiveContentModal(false)
|
closeSensitiveContentModal(false)
|
||||||
}, [closeSensitiveContentModal])
|
}, [closeSensitiveContentModal])
|
||||||
@@ -240,10 +316,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
|
|
||||||
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
||||||
try {
|
try {
|
||||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
const data = await getModerationResult({
|
||||||
'lyrics-analyseLyricsToxicity'
|
|
||||||
)
|
|
||||||
const { data } = await callable({
|
|
||||||
title: titleTrimmed,
|
title: titleTrimmed,
|
||||||
lyrics: normalizedNewLyrics,
|
lyrics: normalizedNewLyrics,
|
||||||
})
|
})
|
||||||
@@ -375,6 +448,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
projectSelections,
|
projectSelections,
|
||||||
alertMessage,
|
alertMessage,
|
||||||
confirmSensitiveContent,
|
confirmSensitiveContent,
|
||||||
|
getModerationResult,
|
||||||
sanitize,
|
sanitize,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
projectHasLyrics,
|
projectHasLyrics,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { StyleSheet, Text, View } from 'react-native'
|
import { StyleSheet, Text, View } from 'react-native'
|
||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||||
import { background } from '../../assets'
|
import { background, videos } from '../../assets'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import { strings } from '../../constants/strings'
|
import { strings } from '../../constants/strings'
|
||||||
@@ -18,15 +18,26 @@ const CTA_BUTTON_HEIGHT = 58
|
|||||||
const CTA_BUTTON_MAX_WIDTH = 520
|
const CTA_BUTTON_MAX_WIDTH = 520
|
||||||
|
|
||||||
const WritingLyrics = () => {
|
const WritingLyrics = () => {
|
||||||
const { createNewProject, selectedProjectId, updateProjectData, videos } = useUserData()
|
const { createNewProject, currentUserData, selectedProjectId, updateProjectData } =
|
||||||
|
useUserData()
|
||||||
const { setIsLoading } = useMinuit()
|
const { setIsLoading } = useMinuit()
|
||||||
|
|
||||||
const [videoUrl, setVideoUrl] = useState(null)
|
const [videoUrl, setVideoUrl] = useState(null)
|
||||||
|
const preferredLanguage = currentUserData
|
||||||
|
? currentUserData.preferredLanguage === 'en'
|
||||||
|
? 'en'
|
||||||
|
: 'fr'
|
||||||
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVideoUrl(isWeb ? videos.celineWeb : videos?.celine)
|
if (!preferredLanguage) {
|
||||||
// setVideoUrl(videos?.test);
|
return
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
|
setVideoUrl(
|
||||||
|
preferredLanguage === 'en' ? videos.writingSpaceIntroEn : videos.writingSpaceIntroFr
|
||||||
|
)
|
||||||
|
}, [preferredLanguage])
|
||||||
|
|
||||||
const startWriting = React.useCallback(async () => {
|
const startWriting = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +77,13 @@ const WritingLyrics = () => {
|
|||||||
<Text style={styles.secondaryNote}>{strings.writing.onboarding.secondaryNote}</Text>
|
<Text style={styles.secondaryNote}>{strings.writing.onboarding.secondaryNote}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<FullscreenIntroVideo url={videoUrl} visible={!!videoUrl} onClose={() => setVideoUrl(null)} />
|
<FullscreenIntroVideo
|
||||||
|
url={videoUrl}
|
||||||
|
visible={!!videoUrl}
|
||||||
|
onClose={() => setVideoUrl(null)}
|
||||||
|
contentFit={isWeb ? 'contain' : 'cover'}
|
||||||
|
skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { BlurView } from 'expo-blur'
|
import { BlurView } from 'expo-blur'
|
||||||
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
import { Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import { Input } from '../../components/Input'
|
import { Input } from '../../components/Input'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
|
import MusiclandPlaybackCover from '../../components/MusiclandPlaybackCover'
|
||||||
import firebase, { projectsRef, usersRef } from '../../config/firebase'
|
import firebase, { projectsRef, usersRef } from '../../config/firebase'
|
||||||
|
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
@@ -16,6 +19,8 @@ import { FONT_FAMILY } from '../../styles/Fonts'
|
|||||||
import { buildFullName } from '../../utils/artistName'
|
import { buildFullName } from '../../utils/artistName'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
|
|
||||||
|
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||||
|
|
||||||
export const ChooseCoverType = () => {
|
export const ChooseCoverType = () => {
|
||||||
const isIOS = Platform.OS === 'ios'
|
const isIOS = Platform.OS === 'ios'
|
||||||
const [choiceVisible, setChoiceVisible] = useState(false)
|
const [choiceVisible, setChoiceVisible] = useState(false)
|
||||||
@@ -27,8 +32,14 @@ export const ChooseCoverType = () => {
|
|||||||
const [savingChoice, setSavingChoice] = useState(false)
|
const [savingChoice, setSavingChoice] = useState(false)
|
||||||
const [savingPseudo, setSavingPseudo] = useState(false)
|
const [savingPseudo, setSavingPseudo] = useState(false)
|
||||||
|
|
||||||
const { selectedProject, selectedProjectId, currentUserData, currentUID } = useUserData()
|
const {
|
||||||
const { setTooltip } = useMinuit()
|
selectedProject,
|
||||||
|
selectedProjectId,
|
||||||
|
updateProjectData,
|
||||||
|
currentUserData,
|
||||||
|
currentUID,
|
||||||
|
} = useUserData()
|
||||||
|
const { setIsLoading, setTooltip } = useMinuit()
|
||||||
|
|
||||||
const projectId = selectedProject?.id || selectedProjectId || null
|
const projectId = selectedProject?.id || selectedProjectId || null
|
||||||
const hasArtistPreference = useMemo(() => {
|
const hasArtistPreference = useMemo(() => {
|
||||||
@@ -104,6 +115,64 @@ export const ChooseCoverType = () => {
|
|||||||
ensureArtistPreference(() => navigate(Routes.PouchReady))
|
ensureArtistPreference(() => navigate(Routes.PouchReady))
|
||||||
}, [ensureArtistPreference])
|
}, [ensureArtistPreference])
|
||||||
|
|
||||||
|
const pickCenterImage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled) return
|
||||||
|
|
||||||
|
const uri = result.assets?.[0]?.uri
|
||||||
|
if (!uri || !projectId) {
|
||||||
|
throw new Error("Impossible d'identifier l'image ou le projet")
|
||||||
|
}
|
||||||
|
|
||||||
|
await setIsLoading(true)
|
||||||
|
const { resultURI } = await uploadFileToFirebase({
|
||||||
|
uri,
|
||||||
|
path: `musics/${projectId}/cover-center-image.jpg`,
|
||||||
|
shouldCompress: false,
|
||||||
|
fileType: 'IMAGE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resultURI) {
|
||||||
|
throw new Error("Le téléversement de l'image a échoué")
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProjectData(
|
||||||
|
{
|
||||||
|
coverCenterImageURL: resultURI,
|
||||||
|
coverStatus: 'PENDING',
|
||||||
|
cover: {
|
||||||
|
template: COVER_TEMPLATE,
|
||||||
|
result: null,
|
||||||
|
generatedBackground: null,
|
||||||
|
selectedOptionId: COVER_TEMPLATE,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
navigate(Routes.PouchReady)
|
||||||
|
} catch (error) {
|
||||||
|
console.log('pickCenterImage error', error?.message)
|
||||||
|
setTooltip({
|
||||||
|
type: 'error',
|
||||||
|
text: error?.message || "Impossible de sélectionner l'image",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId, setIsLoading, setTooltip, updateProjectData])
|
||||||
|
|
||||||
|
const handlePickCenterImage = useCallback(() => {
|
||||||
|
ensureArtistPreference(pickCenterImage)
|
||||||
|
}, [ensureArtistPreference, pickCenterImage])
|
||||||
|
|
||||||
const closeChoiceModal = useCallback(() => {
|
const closeChoiceModal = useCallback(() => {
|
||||||
setChoiceVisible(false)
|
setChoiceVisible(false)
|
||||||
setChoiceDismissAction(null)
|
setChoiceDismissAction(null)
|
||||||
@@ -251,24 +320,40 @@ export const ChooseCoverType = () => {
|
|||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.studioBG2} backgroundColor={'#2A2E33'} headerType="NONE">
|
<Page backgroundImg={background.studioBG2} backgroundColor={'#2A2E33'} headerType="NONE">
|
||||||
<MusicLandHeader onPressBack={goBack} progress={9} />
|
<MusicLandHeader onPressBack={goBack} progress={9} />
|
||||||
<View style={{ flex: 1, position: 'relative', justifyContent: 'flex-end' }}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<View
|
<View style={styles.coverInstructions}>
|
||||||
style={{
|
<Text style={styles.coverTitle}>Personnalise ta pochette</Text>
|
||||||
paddingBottom: gutters * 2,
|
<Text style={styles.coverDescription}>
|
||||||
paddingHorizontal: gutters,
|
Ton morceau est choisi ! Crée maintenant la pochette qui l’accompagnera dans
|
||||||
gap: 12,
|
MusicLand. Ta photo ou l’image de ton choix apparaîtra au centre du modèle ci-dessous.
|
||||||
}}
|
</Text>
|
||||||
>
|
</View>
|
||||||
{/* <BorderGradientButton
|
<View style={styles.previewContainer}>
|
||||||
title="Choisir une image"
|
<MusiclandPlaybackCover
|
||||||
onPress={handlePickUserImage}
|
songTitle={selectedProject?.title}
|
||||||
/> */}
|
username={selectedProject?.userName || currentUserData?.userName || 'MusicLand'}
|
||||||
|
profilePictureURL={
|
||||||
|
selectedProject?.coverCenterImageURL || currentUserData?.profilePictureURL || null
|
||||||
|
}
|
||||||
|
style={styles.coverPreview}
|
||||||
|
/>
|
||||||
|
<Text style={styles.previewCaption}>Aperçu de ta pochette</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.coverInstructions}>
|
||||||
|
<Text style={styles.coverDescription}>
|
||||||
|
Choisis une image dans ta galerie pour la personnaliser, ou conserve le visuel
|
||||||
|
proposé. Tu pourras vérifier puis valider ta pochette à l’étape suivante.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Choisir une image pour ma pochette"
|
||||||
|
onPress={handlePickCenterImage}
|
||||||
|
/>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title="Voir ma pochette"
|
title="Continuer avec cette pochette"
|
||||||
onPress={handleShowCover}
|
onPress={handleShowCover}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={choiceVisible}
|
visible={choiceVisible}
|
||||||
transparent
|
transparent
|
||||||
@@ -356,6 +441,45 @@ export const ChooseCoverType = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
content: {
|
||||||
|
flexGrow: 1,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 620,
|
||||||
|
alignSelf: 'center',
|
||||||
|
paddingVertical: gutters,
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
coverPreview: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
previewCaption: {
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 13,
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
|
coverInstructions: {
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(15, 12, 20, 0.88)',
|
||||||
|
},
|
||||||
|
coverTitle: {
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 22,
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
|
coverDescription: {
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.65)',
|
backgroundColor: 'rgba(0,0,0,0.65)',
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ const PouchReady = () => {
|
|||||||
const coverUrl = templateCover?.result || null
|
const coverUrl = templateCover?.result || null
|
||||||
const isPreparing = selectedProject?.coverStatus === 'GENERATING'
|
const isPreparing = selectedProject?.coverStatus === 'GENERATING'
|
||||||
const hasPreparationError = selectedProject?.coverStatus === 'ERROR'
|
const hasPreparationError = selectedProject?.coverStatus === 'ERROR'
|
||||||
const artistName =
|
const username =
|
||||||
typeof selectedProject?.userName === 'string' && selectedProject.userName.trim()
|
typeof selectedProject?.userName === 'string' && selectedProject.userName.trim()
|
||||||
? selectedProject.userName.trim()
|
? selectedProject.userName.trim()
|
||||||
: currentUserData?.userName || 'MusicLand'
|
: currentUserData?.userName || 'MusicLand'
|
||||||
|
const songTitle = selectedProject?.title || 'Sans titre'
|
||||||
const profilePictureURL = currentUserData?.profilePictureURL || null
|
const profilePictureURL = currentUserData?.profilePictureURL || null
|
||||||
|
const centerImageURL = selectedProject?.coverCenterImageURL || profilePictureURL
|
||||||
|
|
||||||
const selectedOption = useMemo(() => {
|
const selectedOption = useMemo(() => {
|
||||||
if (!coverUrl) {
|
if (!coverUrl) {
|
||||||
@@ -118,8 +120,9 @@ const PouchReady = () => {
|
|||||||
throw new Error('cover_project_identity_missing')
|
throw new Error('cover_project_identity_missing')
|
||||||
}
|
}
|
||||||
const coverBlob = await createMusiclandPlaybackCover({
|
const coverBlob = await createMusiclandPlaybackCover({
|
||||||
artistName,
|
songTitle,
|
||||||
profilePictureURL,
|
username,
|
||||||
|
profilePictureURL: centerImageURL,
|
||||||
})
|
})
|
||||||
const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png`
|
const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png`
|
||||||
const { resultURI } = await uploadFileToFirebase({
|
const { resultURI } = await uploadFileToFirebase({
|
||||||
@@ -169,13 +172,14 @@ const PouchReady = () => {
|
|||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
artistName,
|
|
||||||
coverUrl,
|
coverUrl,
|
||||||
profilePictureURL,
|
centerImageURL,
|
||||||
selectedOption,
|
selectedOption,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
|
songTitle,
|
||||||
updateProjectData,
|
updateProjectData,
|
||||||
|
username,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,12 +188,17 @@ const PouchReady = () => {
|
|||||||
<View style={styles.pageContent}>
|
<View style={styles.pageContent}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title="Ta pochette MusicLand"
|
title="Ta pochette MusicLand"
|
||||||
subTitle="Ta photo de profil et ton nom d’artiste apparaissent automatiquement."
|
subTitle={
|
||||||
|
selectedProject?.coverCenterImageURL
|
||||||
|
? "L’image choisie et ton nom d’artiste apparaissent automatiquement."
|
||||||
|
: "Ta photo de profil et ton nom d’artiste apparaissent automatiquement."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<View style={[styles.previewArea, Style.containerCenter]}>
|
<View style={[styles.previewArea, Style.containerCenter]}>
|
||||||
<MusiclandPlaybackCover
|
<MusiclandPlaybackCover
|
||||||
artistName={artistName}
|
songTitle={songTitle}
|
||||||
profilePictureURL={profilePictureURL}
|
username={username}
|
||||||
|
profilePictureURL={centerImageURL}
|
||||||
coverUrl={coverUrl}
|
coverUrl={coverUrl}
|
||||||
style={isWeb ? styles.coverWeb : styles.coverMobile}
|
style={isWeb ? styles.coverWeb : styles.coverMobile}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
|||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import * as Sharing from 'expo-sharing'
|
import * as Sharing from 'expo-sharing'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
|
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import { projectsRef, serverTimestamp } from '../../config/firebase'
|
import { projectsRef, serverTimestamp } from '../../config/firebase'
|
||||||
@@ -22,7 +23,6 @@ import useGlobalLoading from '../../hooks/useGlobalLoading'
|
|||||||
import { isWeb } from '../../hooks/useLayoutType'
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
import { getArtistDisplayName } from '../../utils/artistName'
|
import { getArtistDisplayName } from '../../utils/artistName'
|
||||||
import { toDate } from '../../utils/dateFormatting'
|
import { toDate } from '../../utils/dateFormatting'
|
||||||
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
|
||||||
import { musiclandShareHeading } from '../../data'
|
import { musiclandShareHeading } from '../../data'
|
||||||
|
|
||||||
const SongDownload = ({ route }) => {
|
const SongDownload = ({ route }) => {
|
||||||
@@ -34,7 +34,14 @@ const SongDownload = ({ route }) => {
|
|||||||
promptPurchaseConfirm = false,
|
promptPurchaseConfirm = false,
|
||||||
backRoute = null,
|
backRoute = null,
|
||||||
} = route?.params || {}
|
} = route?.params || {}
|
||||||
const { selectedProject, updateProjectData, hasActiveSubscription, hasPurchased } = useUser()
|
const {
|
||||||
|
selectedProject,
|
||||||
|
updateProjectData,
|
||||||
|
hasActiveSubscription,
|
||||||
|
hasPurchased,
|
||||||
|
currentUserData,
|
||||||
|
videos,
|
||||||
|
} = useUser()
|
||||||
const { createSongDownloadCheckout } = useStripe()
|
const { createSongDownloadCheckout } = useStripe()
|
||||||
const { setTooltip } = useMinuit()
|
const { setTooltip } = useMinuit()
|
||||||
const { setLoading } = useGlobalLoading()
|
const { setLoading } = useGlobalLoading()
|
||||||
@@ -43,10 +50,21 @@ const SongDownload = ({ route }) => {
|
|||||||
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
|
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
|
||||||
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
|
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
|
||||||
const [isPublishing, setIsPublishing] = useState(false)
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
const { videos } = useUser()
|
const [benaiVideoUrl, setBenaiVideoUrl] = useState(null)
|
||||||
const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1
|
const benaiVideoHandledRef = useRef(false)
|
||||||
const [showIntro, setShowIntro] = useState(null)
|
|
||||||
const introTriggeredRef = useRef(false)
|
useEffect(() => {
|
||||||
|
if (skipAdventureGate || benaiVideoHandledRef.current) return
|
||||||
|
|
||||||
|
const preferredLanguage = currentUserData?.preferredLanguage === 'en' ? 'en' : 'fr'
|
||||||
|
if (videos?.id !== preferredLanguage) return
|
||||||
|
|
||||||
|
benaiVideoHandledRef.current = true
|
||||||
|
const url = typeof videos?.benai === 'string' ? videos.benai.trim() : ''
|
||||||
|
if (url) {
|
||||||
|
setBenaiVideoUrl(url)
|
||||||
|
}
|
||||||
|
}, [currentUserData?.preferredLanguage, skipAdventureGate, videos])
|
||||||
|
|
||||||
const projectForStage = useMemo(() => {
|
const projectForStage = useMemo(() => {
|
||||||
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
|
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
|
||||||
@@ -106,17 +124,6 @@ const SongDownload = ({ route }) => {
|
|||||||
return true
|
return true
|
||||||
}, [backRoute])
|
}, [backRoute])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipAdventureGate || introTriggeredRef.current) return
|
|
||||||
if (typeof videos === 'undefined') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
introTriggeredRef.current = true
|
|
||||||
if (part1Url) {
|
|
||||||
setShowIntro(part1Url)
|
|
||||||
}
|
|
||||||
}, [part1Url, skipAdventureGate, videos])
|
|
||||||
|
|
||||||
const continueFlow = useCallback(async () => {
|
const continueFlow = useCallback(async () => {
|
||||||
if (!selectedOption) {
|
if (!selectedOption) {
|
||||||
return
|
return
|
||||||
@@ -155,10 +162,6 @@ const SongDownload = ({ route }) => {
|
|||||||
}
|
}
|
||||||
}, [coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData])
|
}, [coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData])
|
||||||
|
|
||||||
const handleCloseIntro = useCallback(() => {
|
|
||||||
setShowIntro(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleContinueAdventure = useCallback(() => {
|
const handleContinueAdventure = useCallback(() => {
|
||||||
continueFlow()
|
continueFlow()
|
||||||
}, [continueFlow])
|
}, [continueFlow])
|
||||||
@@ -610,7 +613,11 @@ const SongDownload = ({ route }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
|
<FullscreenIntroVideo
|
||||||
|
url={benaiVideoUrl}
|
||||||
|
visible={!!benaiVideoUrl}
|
||||||
|
onClose={() => setBenaiVideoUrl(null)}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Asset } from 'expo-asset'
|
|||||||
import { img } from '../assets'
|
import { img } from '../assets'
|
||||||
|
|
||||||
const COVER_SIZE = 1024
|
const COVER_SIZE = 1024
|
||||||
const PROFILE_SIZE = 228
|
const PROFILE_SIZE = 320
|
||||||
|
|
||||||
const loadImage = (source) =>
|
const loadImage = (source) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -28,9 +28,15 @@ const drawCenteredText = (context, text, y, fontSize, letterSpacing) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getArtistFontSize = (artistName) => {
|
const getTitleFontSize = (songTitle) => {
|
||||||
if (artistName.length <= 16) return 52
|
if (songTitle.length <= 16) return 38
|
||||||
if (artistName.length <= 24) return 44
|
if (songTitle.length <= 24) return 32
|
||||||
|
return 26
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsernameFontSize = (username) => {
|
||||||
|
if (username.length <= 16) return 52
|
||||||
|
if (username.length <= 24) return 44
|
||||||
return 36
|
return 36
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +65,15 @@ const drawProfilePicture = (context, profilePicture) => {
|
|||||||
sourceY,
|
sourceY,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
398,
|
(COVER_SIZE - PROFILE_SIZE) / 2,
|
||||||
454,
|
568 - PROFILE_SIZE / 2,
|
||||||
PROFILE_SIZE,
|
PROFILE_SIZE,
|
||||||
PROFILE_SIZE
|
PROFILE_SIZE
|
||||||
)
|
)
|
||||||
context.restore()
|
context.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMusiclandPlaybackCover = async ({ artistName, profilePictureURL }) => {
|
export const createMusiclandPlaybackCover = async ({ songTitle, username, profilePictureURL }) => {
|
||||||
const templateAsset = Asset.fromModule(img.musiclandPlaybackCover)
|
const templateAsset = Asset.fromModule(img.musiclandPlaybackCover)
|
||||||
await templateAsset.downloadAsync()
|
await templateAsset.downloadAsync()
|
||||||
const assetUri = templateAsset.localUri || templateAsset.uri
|
const assetUri = templateAsset.localUri || templateAsset.uri
|
||||||
@@ -91,10 +97,12 @@ export const createMusiclandPlaybackCover = async ({ artistName, profilePictureU
|
|||||||
|
|
||||||
context.fillStyle = '#FFFFFF'
|
context.fillStyle = '#FFFFFF'
|
||||||
context.textBaseline = 'middle'
|
context.textBaseline = 'middle'
|
||||||
context.font = `400 ${getArtistFontSize(artistName)}px Inter, Arial, sans-serif`
|
const titleFontSize = getTitleFontSize(songTitle)
|
||||||
drawCenteredText(context, artistName, 824, getArtistFontSize(artistName), 3)
|
context.font = `400 ${titleFontSize}px Inter, Arial, sans-serif`
|
||||||
context.font = '400 54px Inter, Arial, sans-serif'
|
drawCenteredText(context, songTitle, 170, titleFontSize, 2)
|
||||||
drawCenteredText(context, 'PLAYBACKER', 963, 54, 5)
|
const usernameFontSize = getUsernameFontSize(username)
|
||||||
|
context.font = `400 ${usernameFontSize}px Inter, Arial, sans-serif`
|
||||||
|
drawCenteredText(context, username, 963, usernameFontSize, 4)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import AppAlert from '../components/Alert'
|
||||||
|
import { openCoinPackModal } from './coinPackModal'
|
||||||
|
|
||||||
|
export const showInsufficientCreditsAlert = (requiredCredits) => {
|
||||||
|
AppAlert(
|
||||||
|
'Crédits insuffisants',
|
||||||
|
`Cette création nécessite ${requiredCredits} crédits. Achète des crédits pour continuer.`,
|
||||||
|
[
|
||||||
|
{ text: 'Plus tard', style: 'cancel' },
|
||||||
|
{ text: 'Acheter des crédits', onPress: openCoinPackModal },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const PLAYBACK_TYPE = Object.freeze({
|
||||||
|
LIVE: 'live',
|
||||||
|
AI: 'ai',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getPlaybackType = (project) =>
|
||||||
|
project?.playbackProvider === 'heygen' ? PLAYBACK_TYPE.AI : PLAYBACK_TYPE.LIVE
|
||||||
|
|
||||||
|
export const matchesPlaybackType = (project, playbackType) =>
|
||||||
|
getPlaybackType(project) === playbackType
|
||||||
@@ -24,6 +24,19 @@ export const getRouteAfterAuthentication = (userData = {}) =>
|
|||||||
? Routes.RegistrationChoice
|
? Routes.RegistrationChoice
|
||||||
: Routes.BottomTab
|
: Routes.BottomTab
|
||||||
|
|
||||||
|
export const getMainMenuReset = () => ({
|
||||||
|
index: 0,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: Routes.BottomTab,
|
||||||
|
params: {
|
||||||
|
screen: Routes.HomeStack,
|
||||||
|
params: { screen: Routes.Home },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
export const getExperienceReset = (experience) => {
|
export const getExperienceReset = (experience) => {
|
||||||
if (experience === 'listen') {
|
if (experience === 'listen') {
|
||||||
return {
|
return {
|
||||||
@@ -37,18 +50,7 @@ export const getExperienceReset = (experience) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return getMainMenuReset()
|
||||||
index: 0,
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
name: Routes.BottomTab,
|
|
||||||
params: {
|
|
||||||
screen: Routes.HomeStack,
|
|
||||||
params: { screen: Routes.Home },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const completeRegistrationFlow = async (experience) => {
|
export const completeRegistrationFlow = async (experience) => {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{ "source": "/link", "destination": "/index.html" },
|
{ "source": "/(.*)", "destination": "/index.html" }
|
||||||
{ "source": "/link/:path*", "destination": "/index.html" },
|
|
||||||
{ "source": "/music/:musicId", "destination": "/index.html" },
|
|
||||||
{ "source": "/tasks/:taskId", "destination": "/index.html" },
|
|
||||||
{ "source": "/playback/:playbackId", "destination": "/index.html" },
|
|
||||||
{ "source": "/playbacks/:playbackId", "destination": "/index.html" }
|
|
||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"source": "/.web/well-known/apple-app-site-association",
|
"source": "/.well-known/apple-app-site-association",
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"key": "Content-Type",
|
"key": "Content-Type",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"applinks": {
|
|
||||||
"apps": [],
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"appID": "W2QZ9CTMYJ.com.omedis.musicland.ai",
|
|
||||||
"paths": [
|
|
||||||
"/music/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"webcredentials": {
|
|
||||||
"apps": [
|
|
||||||
"W2QZ9CTMYJ.com.omedis.musicland.ai"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3046,6 +3046,11 @@ archive-type@^4.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
file-type "^4.2.0"
|
file-type "^4.2.0"
|
||||||
|
|
||||||
|
arg@4.1.0:
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0"
|
||||||
|
integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==
|
||||||
|
|
||||||
arg@^5.0.2:
|
arg@^5.0.2:
|
||||||
version "5.0.2"
|
version "5.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||||
@@ -5180,7 +5185,7 @@ expo-document-picker@~13.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-13.0.3.tgz#9ec9b056447d11a68d61e93d33c2211fd01b460f"
|
resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-13.0.3.tgz#9ec9b056447d11a68d61e93d33c2211fd01b460f"
|
||||||
integrity sha512-348xcsiA/YhgWm1SuJNNdb5cUDpRJYCyIk8MhOU2MEDxbVRR+Q1TiUBTCIMVqaWHcxsFQzP56Wwv9n24qjeILg==
|
integrity sha512-348xcsiA/YhgWm1SuJNNdb5cUDpRJYCyIk8MhOU2MEDxbVRR+Q1TiUBTCIMVqaWHcxsFQzP56Wwv9n24qjeILg==
|
||||||
|
|
||||||
expo-eas-client@~0.13.2:
|
expo-eas-client@~0.13.2, expo-eas-client@~0.13.3:
|
||||||
version "0.13.3"
|
version "0.13.3"
|
||||||
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.13.3.tgz#1535a99a224e360581c6253b0a1ea767e19815b8"
|
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.13.3.tgz#1535a99a224e360581c6253b0a1ea767e19815b8"
|
||||||
integrity sha512-t+1F1tiDocSot8iSnrn/CjTUMvVvPV2DpafSVcticpbSzMGybEN7wcamO1t18fK7WxGXpZE9gxtd80qwv/LLqQ==
|
integrity sha512-t+1F1tiDocSot8iSnrn/CjTUMvVvPV2DpafSVcticpbSzMGybEN7wcamO1t18fK7WxGXpZE9gxtd80qwv/LLqQ==
|
||||||
@@ -5325,6 +5330,11 @@ expo-store-review@~8.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-store-review/-/expo-store-review-8.0.1.tgz#f9d5f584ee6d1e97e7c6374dc840641a31ccfd36"
|
resolved "https://registry.yarnpkg.com/expo-store-review/-/expo-store-review-8.0.1.tgz#f9d5f584ee6d1e97e7c6374dc840641a31ccfd36"
|
||||||
integrity sha512-eJoZbsAsOXpyl1CEXauiMqs3z++8Qf1OMDUBEGmRj9hew+w9JbS3wvFOZpc0UXbm/NYn5YwiLfK7epQjLLKz4A==
|
integrity sha512-eJoZbsAsOXpyl1CEXauiMqs3z++8Qf1OMDUBEGmRj9hew+w9JbS3wvFOZpc0UXbm/NYn5YwiLfK7epQjLLKz4A==
|
||||||
|
|
||||||
|
expo-structured-headers@~4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-structured-headers/-/expo-structured-headers-4.0.0.tgz#85537ae6daec61ebfb214ede4107c8841c6e16d0"
|
||||||
|
integrity sha512-uPiwZjWq3AdFGgY52+I2nGPrNa6izxAglymPXHUZLekZW290GqIUOk7MBNDD4sg4JwUbSi3gdxEurpEvuq+FSg==
|
||||||
|
|
||||||
expo-system-ui@~4.0.9:
|
expo-system-ui@~4.0.9:
|
||||||
version "4.0.9"
|
version "4.0.9"
|
||||||
resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-4.0.9.tgz#a3dc4af52dab589dd433a28ed286360cea0e008d"
|
resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-4.0.9.tgz#a3dc4af52dab589dd433a28ed286360cea0e008d"
|
||||||
@@ -5338,6 +5348,26 @@ expo-updates-interface@~1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.0.0.tgz#b98c66b800d29561c62409556948b2af3d5316e5"
|
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.0.0.tgz#b98c66b800d29561c62409556948b2af3d5316e5"
|
||||||
integrity sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ==
|
integrity sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ==
|
||||||
|
|
||||||
|
expo-updates@~0.27.5:
|
||||||
|
version "0.27.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-0.27.5.tgz#2f7c7f4cc159adb5ee9de7ae3378ef7da67026f1"
|
||||||
|
integrity sha512-Y771xWW+jRT/pgAwE/BYHEZ4NrY/aSDyqbUABkbzyobQsS03usTwGZb7nadT4f1+x9NbKHxanWtsZeEmWeOx8w==
|
||||||
|
dependencies:
|
||||||
|
"@expo/code-signing-certificates" "^0.0.6"
|
||||||
|
"@expo/config" "~10.0.11"
|
||||||
|
"@expo/config-plugins" "~9.0.17"
|
||||||
|
"@expo/spawn-async" "^1.7.2"
|
||||||
|
arg "4.1.0"
|
||||||
|
chalk "^4.1.2"
|
||||||
|
expo-eas-client "~0.13.3"
|
||||||
|
expo-manifests "~0.15.8"
|
||||||
|
expo-structured-headers "~4.0.0"
|
||||||
|
expo-updates-interface "~1.0.0"
|
||||||
|
fast-glob "^3.3.2"
|
||||||
|
fbemitter "^3.0.0"
|
||||||
|
ignore "^5.3.1"
|
||||||
|
resolve-from "^5.0.0"
|
||||||
|
|
||||||
expo-video@~2.0.6:
|
expo-video@~2.0.6:
|
||||||
version "2.0.6"
|
version "2.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-2.0.6.tgz#4da4e4d427ad42649881a5336697b5a7164aaab3"
|
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-2.0.6.tgz#4da4e4d427ad42649881a5336697b5a7164aaab3"
|
||||||
@@ -6309,7 +6339,7 @@ ieee754@^1.1.13, ieee754@^1.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||||
|
|
||||||
ignore@^5.2.0:
|
ignore@^5.2.0, ignore@^5.3.1:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||||
|
|||||||