diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..b33a6b5f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,217 @@
+name: Bug report
+description: Report a reproducible bug (one per issue).
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting a bug.
+
+ If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Quick checks
+
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Pre-flight checks
+ options:
+ - label: I searched existing issues and this is not a duplicate.
+ required: true
+ - label: I can reproduce this on the latest release or latest main build.
+ required: false
+ - label: This issue is limited to a single bug (not multiple unrelated problems).
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Version & device
+
+ - type: input
+ id: app_version
+ attributes:
+ label: App version / OTA update ID
+ description: Release version, commit hash, or OTA update ID. You can find your OTA update ID in Settings > App updates > Current version (hold to copy).
+ placeholder: "e.g. 1.2.3, main@abc1234, or an OTA ID"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: install_method
+ attributes:
+ label: Install method
+ options:
+ - GitHub Release APK / IPA
+ - Expo Go
+ - Built from source
+ - Other (please describe below)
+ validations:
+ required: true
+
+ - type: dropdown
+ id: platform
+ attributes:
+ label: Platform
+ options:
+ - Android phone/tablet
+ - iOS (iPhone/iPad)
+ - Android emulator
+ - iOS Simulator
+ - Other (please describe below)
+ validations:
+ required: true
+
+ - type: input
+ id: device_model
+ attributes:
+ label: Device model
+ description: "Example: iPhone 15 Pro, Pixel 8, Galaxy S23 Ultra, iPad Pro, etc."
+ placeholder: "e.g. iPhone 15 Pro"
+ validations:
+ required: true
+
+ - type: input
+ id: os_version
+ attributes:
+ label: OS version
+ placeholder: "e.g. Android 14, iOS 17.2"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: player_mode
+ attributes:
+ label: Player mode
+ description: If you are using an external player, most playback issues must be reported to that player instead.
+ options:
+ - Internal player (iOS: KSPlayer)
+ - Internal player (Android: ExoPlayer)
+ - Internal player (Android: MPV)
+ - External player
+ - Ask every time
+ - Not sure
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## What happened?
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area (tag)
+ description: Pick the closest match. It helps triage.
+ options:
+ - Playback (start/stop/buffering)
+ - Streams / Sources (selection, loading, errors)
+ - Next Episode / Auto-play
+ - Watch Progress (resume, watched state, history)
+ - Subtitles (styling, sync)
+ - Audio tracks
+ - UI / Layout / Animations
+ - Settings
+ - Sync (Trakt / SIMKL / remote)
+ - Downloads
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
+ placeholder: |
+ 1. Open ...
+ 2. Navigate to ...
+ 3. Press ...
+ 4. Observe ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ placeholder: "What you expected to happen."
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ placeholder: "What actually happened (include any on-screen error text/codes)."
+ validations:
+ required: true
+
+ - type: dropdown
+ id: frequency
+ attributes:
+ label: Frequency
+ options:
+ - Always
+ - Often (more than 50%)
+ - Sometimes
+ - Rarely
+ - Once
+ validations:
+ required: true
+
+ - type: dropdown
+ id: regression
+ attributes:
+ label: Did this work before?
+ options:
+ - Not sure
+ - Yes, it used to work
+ - No, it never worked
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Extra context (optional)
+
+ - type: textarea
+ id: media_details
+ attributes:
+ label: Media details (optional)
+ description: Only include what you can safely share.
+ placeholder: |
+ - Content type: series/movie
+ - Season/Episode: S1E2
+ - Stream/source: (addon name / source label)
+ - Video format: (if known)
+ validations:
+ required: false
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs (optional but helpful)
+ description: |
+ Not required, but super helpful for playback/crash issues.
+ If you can, include a short snippet from Metro bundler, Xcode, or `adb logcat`.
+ render: shell
+ placeholder: |
+ adb logcat -d | tail -n 300
+ validations:
+ required: false
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Anything else? (optional)
+ description: Screenshots/recordings, related issues, workarounds, etc.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..80724f85
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Downloads / Releases
+ url: https://github.com/tapframe/NuvioMobile/releases
+ about: Grab the latest GitHub Release APK/IPA here.
+ - name: Documentation
+ url: https://github.com/tapframe/NuvioMobile/blob/main/README.md
+ about: Read the README for setup and usage details.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..e359e5bb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,78 @@
+name: Feature request
+description: Suggest an improvement or new feature.
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
+
+ Feature requests are reviewed as product proposals first.
+ Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
+ Unapproved feature PRs will usually be closed.
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area (tag)
+ options:
+ - Playback
+ - Streams / Sources
+ - Next Episode / Auto-play
+ - Watch Progress
+ - Subtitles
+ - Audio
+ - UI / Layout / Animations
+ - Settings
+ - Sync (Trakt / SIMKL / remote)
+ - Downloads
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem statement
+ description: What problem are you trying to solve?
+ placeholder: "I want to be able to..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposed
+ attributes:
+ label: Proposed solution
+ description: What would you like the app to do?
+ validations:
+ required: true
+
+ - type: dropdown
+ id: contribution_plan
+ attributes:
+ label: Are you planning to implement this yourself?
+ description: Major features are usually implemented in-house unless approved first.
+ options:
+ - No, this is only a proposal
+ - Maybe, but only if approved first
+ - Yes, but I understand implementation still needs maintainer approval
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered (optional)
+ description: Any workarounds or other approaches you considered.
+ validations:
+ required: false
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Additional context (optional)
+ description: Mockups, examples from other apps, etc.
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..163ac398
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,43 @@
+## Summary
+
+
+
+## PR type
+
+
+- Bug fix
+- Small maintenance improvement
+- Docs fix
+- Approved larger change (link approval below)
+
+## Why
+
+
+
+## Policy check
+
+
+- [ ] This PR is not cosmetic only.
+- [ ] This PR does not add a new major feature without prior approval.
+- [ ] This PR is small in scope and focused on one problem.
+- [ ] If this is a larger or directional change, I linked the issue where it was approved.
+
+
+
+## Testing
+
+
+- [ ] iOS tested
+- [ ] Android tested
+
+## Screenshots / Video (UI changes only)
+
+
+
+## Breaking changes
+
+
+
+## Linked issues
+
+
diff --git a/.gitignore b/.gitignore
index efd2b6c5..6486d03e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,4 +105,5 @@ LibTorrent/
iTorrent/
simkl-docss
downloader.md
-server
\ No newline at end of file
+server
+Deliverables 2
\ No newline at end of file
diff --git a/App.tsx b/App.tsx
index 5bd5ff19..7ce219f0 100644
--- a/App.tsx
+++ b/App.tsx
@@ -48,6 +48,7 @@ import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage';
import { CampaignManager } from './src/components/promotions/CampaignManager';
import { isErrorReportingEnabledSync } from './src/services/telemetryService';
+import { supabaseSyncService } from './src/services/supabaseSyncService';
// Initialize Sentry with privacy-first defaults
// Settings are loaded from telemetryService and can be controlled by user
@@ -180,6 +181,15 @@ const ThemedApp = () => {
const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding');
setHasCompletedOnboarding(onboardingCompleted === 'true');
+ // Initialize Supabase auth/session and start background sync.
+ // This is intentionally non-blocking for app startup UX.
+ supabaseSyncService
+ .initialize()
+ .then(() => supabaseSyncService.startupSync())
+ .catch((error) => {
+ console.warn('[App] Supabase sync bootstrap failed:', error);
+ });
+
// Initialize update service
await UpdateService.initialize();
@@ -314,4 +324,4 @@ const styles = StyleSheet.create({
},
});
-export default Sentry.wrap(App);
\ No newline at end of file
+export default Sentry.wrap(App);
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..59c7d56e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,80 @@
+# Contributing
+
+Thanks for helping improve NuvioMobile.
+
+## PR policy
+
+Pull requests are currently intended for:
+
+- Reproducible bug fixes
+- Small stability improvements
+- Minor maintenance work
+- Small documentation fixes that improve accuracy
+
+Pull requests are generally **not** accepted for:
+
+- New major features
+- Product direction changes
+- Large UX / UI redesigns
+- Cosmetic-only changes
+- Refactors without a clear user-facing or maintenance benefit
+
+For feature ideas and bigger changes, please open an issue first. Feature implementation is usually kept in-house unless it has been discussed and explicitly approved beforehand.
+
+## Where to ask questions
+
+- Use **Issues** for bugs, feature requests, setup help, and general support.
+
+## Bug reports (rules)
+
+To keep issues fixable, bug reports should include:
+
+- App version or OTA update ID (Settings > App updates > Current version, hold to copy)
+- Platform + device model + OS version (Android/iOS)
+- Install method (release APK/IPA / Expo Go / built from source)
+- Steps to reproduce (exact steps)
+- Expected vs actual behavior
+- Frequency (always/sometimes/once)
+
+Logs are **optional**, but they help a lot for playback/crash issues.
+
+### How to capture logs (optional)
+
+If you can, reproduce the issue once, then attach a short log snippet from around the time it happened:
+
+For Android:
+```sh
+adb logcat -d | tail -n 300
+```
+For iOS/Metro:
+```sh
+# Copy from your Metro bundler output or Xcode console
+```
+
+If the issue is a crash, also include any stack trace shown by Android Studio, Xcode, or `adb logcat`.
+
+## Feature requests (rules)
+
+Please include:
+
+- The problem you are solving (use case)
+- Your proposed solution
+- Alternatives considered (if any)
+
+Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
+
+## Before opening a PR
+
+Please make sure your PR is all of the following:
+
+- Small in scope
+- Focused on one problem
+- Clearly aligned with the current direction of the project
+- Not cosmetic-only
+- Not a new major feature unless it was discussed and approved first
+
+PRs that do not fit this policy will usually be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
+
+## One issue per problem
+
+Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.
diff --git a/README.md b/README.md
index 671cef36..9ae35957 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
+
@@ -54,7 +54,7 @@ Nuvio functions solely as a client-side interface for browsing metadata and play
Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content.
-For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
+For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://nuvioapp.space/legal)**.
## Built With
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 04e1b814..1ca68471 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 35
- versionName "1.3.7"
+ versionCode 37
+ versionName "1.4.1"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
@@ -118,7 +118,7 @@ android {
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant ->
variant.outputs.each { output ->
- def baseVersionCode = 35 // Current versionCode 35 from defaultConfig
+ def baseVersionCode = 37 // Current versionCode 37 from defaultConfig
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 810d5111..fea5dd4c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -21,9 +21,9 @@
-
+
-
+
@@ -37,4 +37,4 @@
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
index 15b512eb..386d80e4 100644
Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
index 8b78e2f9..4c75777f 100644
Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
index 528962ab..329ead81 100644
Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
index 0969e6a3..dafc337b 100644
Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
index 7de47821..71feaf84 100644
Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index 70320a98..34bef077 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index 46b9e444..bd1b79d3 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 63e85ffe..4658ecfb 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index a5a1dfa9..725ef09a 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index 133272a5..924b4aed 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index a1156c2f..ab4a4218 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index 705ab974..df2db884 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index 401543d6..a2fd709b 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index 111ae461..c3e36e46 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 76fb597c..4601fb63 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 5e694a8a..6a59a560 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index a06585cb..50486cf7 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index 57b30c91..2a4c113c 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index ab60c474..844fcf56 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index acdda655..3fd4dbc7 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 9b0596a4..f3acf267 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -3,5 +3,5 @@
containfalsedark
- 1.3.7
+ 1.4.1
\ No newline at end of file
diff --git a/app.json b/app.json
index d5510500..357b3913 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Nuvio",
"slug": "nuvio",
- "version": "1.3.7",
+ "version": "1.4.1",
"orientation": "default",
"backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@@ -17,7 +17,7 @@
"ios": {
"supportsTablet": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
- "buildNumber": "35",
+ "buildNumber": "37",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -35,7 +35,7 @@
"LSSupportsOpeningDocumentsInPlace": true,
"UIFileSharingEnabled": true
},
- "bundleIdentifier": "com.nuvio.app",
+ "bundleIdentifier": "com.nuvio.hub",
"associatedDomains": [],
"jsEngine": "hermes",
"appleTeamId": "8QBDZ766S3"
@@ -52,7 +52,7 @@
"android.permission.WRITE_SETTINGS"
],
"package": "com.nuvio.app",
- "versionCode": 35,
+ "versionCode": 37,
"architectures": [
"arm64-v8a",
"armeabi-v7a",
@@ -105,6 +105,6 @@
"fallbackToCacheTimeout": 30000,
"url": "https://ota.nuvioapp.space/api/manifest"
},
- "runtimeVersion": "1.3.7"
+ "runtimeVersion": "1.4.1"
}
}
diff --git a/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png
index fc396770..589a0139 100644
Binary files a/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png
index 02d4de67..3a9261d7 100644
Binary files a/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png
index 41b56cf1..3c101f3f 100644
Binary files a/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png
index 570413cc..4a50f3a8 100644
Binary files a/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png
index a101d4a9..c72a7215 100644
Binary files a/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png
index 493e3b98..b2f544c0 100644
Binary files a/assets/android/ic_launcher-web.png and b/assets/android/ic_launcher-web.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png
index 558dfbae..d044d633 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher.png and b/assets/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png
index 2f49c5f1..0d8e9ca8 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_foreground.png and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png
index 1b48a3ef..589a0139 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_round.png and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png
index 3524b2fc..7912de17 100644
Binary files a/assets/android/mipmap-ldpi/ic_launcher.png and b/assets/android/mipmap-ldpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-ldpi/ic_launcher_foreground.png b/assets/android/mipmap-ldpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..efd269fb
Binary files /dev/null and b/assets/android/mipmap-ldpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-ldpi/ic_launcher_round.png b/assets/android/mipmap-ldpi/ic_launcher_round.png
index 02020f6f..1a964dbd 100644
Binary files a/assets/android/mipmap-ldpi/ic_launcher_round.png and b/assets/android/mipmap-ldpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png
index 6c259c80..526eaffd 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher.png and b/assets/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png
index 64546171..335c655b 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_foreground.png and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png
index f637369f..3a9261d7 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_round.png and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png
index 9fb69a54..3c101f3f 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher.png and b/assets/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png
index f03be67e..f964ed8d 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png
index c34a4836..3c101f3f 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_round.png and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png
index d16402dd..4a50f3a8 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher.png and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png
index 8605f5e6..0e2377c7 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png
index 749d0724..4a50f3a8 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png
index 2e35f619..c72a7215 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index 6cc901eb..dd2e6bee 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png
index 06cdb2f5..c72a7215 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png
index e5dd4a6b..b2f544c0 100644
Binary files a/assets/android/playstore-icon.png and b/assets/android/playstore-icon.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png
index 18d719cf..1fdc796e 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png
index 5bd9ac8e..35572c94 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png
index a526217a..92d5c55a 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png
index e2841b48..8954f0f4 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png
index 34e2d79c..37175fd9 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png
index 11953d6c..77d6dc8d 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png
index 5bd9ac8e..330fb482 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png
index 56a3b787..2cda25b7 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png
index c36efb6e..3678d9c9 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png
index c36efb6e..3678d9c9 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png
index d84612d1..7e8b1ce9 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png
index 53317602..52b302e8 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png
index 082be8c5..7e342789 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 2218f825..48877c47 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png
index 8ac40dd4..8c09f13f 100644
Binary files a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png
index 637db4d5..95e87d0b 100644
Binary files a/assets/ios/iTunesArtwork@1x.png and b/assets/ios/iTunesArtwork@1x.png differ
diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png
index 8ac40dd4..8c09f13f 100644
Binary files a/assets/ios/iTunesArtwork@2x.png and b/assets/ios/iTunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png
index 18b16be3..ce974813 100644
Binary files a/assets/ios/iTunesArtwork@3x.png and b/assets/ios/iTunesArtwork@3x.png differ
diff --git a/assets/nuvio-sync-icon-og.png b/assets/nuvio-sync-icon-og.png
new file mode 100644
index 00000000..28bf19e5
Binary files /dev/null and b/assets/nuvio-sync-icon-og.png differ
diff --git a/assets/player-icons/ic_player_aspect_ratio.svg b/assets/player-icons/ic_player_aspect_ratio.svg
new file mode 100644
index 00000000..12b2f7b6
--- /dev/null
+++ b/assets/player-icons/ic_player_aspect_ratio.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/player-icons/ic_player_audio_filled.svg b/assets/player-icons/ic_player_audio_filled.svg
new file mode 100644
index 00000000..2961dd6a
--- /dev/null
+++ b/assets/player-icons/ic_player_audio_filled.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/assets/player-icons/ic_player_audio_outline.svg b/assets/player-icons/ic_player_audio_outline.svg
new file mode 100644
index 00000000..87c64646
--- /dev/null
+++ b/assets/player-icons/ic_player_audio_outline.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/assets/player-icons/ic_player_episodes.svg b/assets/player-icons/ic_player_episodes.svg
new file mode 100644
index 00000000..c09c7205
--- /dev/null
+++ b/assets/player-icons/ic_player_episodes.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/player-icons/ic_player_pause.svg b/assets/player-icons/ic_player_pause.svg
new file mode 100644
index 00000000..69f83449
--- /dev/null
+++ b/assets/player-icons/ic_player_pause.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/assets/player-icons/ic_player_play.svg b/assets/player-icons/ic_player_play.svg
new file mode 100644
index 00000000..d375a176
--- /dev/null
+++ b/assets/player-icons/ic_player_play.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/player-icons/ic_player_source.svg b/assets/player-icons/ic_player_source.svg
new file mode 100644
index 00000000..1e79c2a3
--- /dev/null
+++ b/assets/player-icons/ic_player_source.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/player-icons/ic_player_subtitles.svg b/assets/player-icons/ic_player_subtitles.svg
new file mode 100644
index 00000000..bf8041e3
--- /dev/null
+++ b/assets/player-icons/ic_player_subtitles.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/simkl-favicon.png b/assets/simkl-favicon.png
index c8454b3e..15918960 100644
Binary files a/assets/simkl-favicon.png and b/assets/simkl-favicon.png differ
diff --git a/assets/simkl-logo.png b/assets/simkl-logo.png
index 6836cfc0..9efcf66f 100644
Binary files a/assets/simkl-logo.png and b/assets/simkl-logo.png differ
diff --git a/assets/text_only_og.png b/assets/text_only_og.png
new file mode 100644
index 00000000..35eca9a4
Binary files /dev/null and b/assets/text_only_og.png differ
diff --git a/assets/trakt-favicon.png b/assets/trakt-favicon.png
index e69de29b..37075b4f 100644
Binary files a/assets/trakt-favicon.png and b/assets/trakt-favicon.png differ
diff --git a/assets/trakt-logo.png b/assets/trakt-logo.png
deleted file mode 100644
index 3b237e43..00000000
--- a/assets/trakt-logo.png
+++ /dev/null
@@ -1,2 +0,0 @@
-// This is a placeholder for a binary PNG file
-// Replace this file with an actual Trakt logo image
\ No newline at end of file
diff --git a/docs/SUPABASE_SYNC.md b/docs/SUPABASE_SYNC.md
new file mode 100644
index 00000000..494c2ddc
--- /dev/null
+++ b/docs/SUPABASE_SYNC.md
@@ -0,0 +1,1254 @@
+# NuvioTV Supabase Sync Documentation
+
+This document describes the complete Supabase backend used by NuvioTV for cross-device data synchronization. It covers database schema, RPC functions, authentication, device linking, and integration patterns.
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Prerequisites](#prerequisites)
+3. [Database Schema](#database-schema)
+4. [RPC Functions](#rpc-functions)
+5. [Integration Guide](#integration-guide)
+6. [Data Models](#data-models)
+7. [Sync Behavior & Restrictions](#sync-behavior--restrictions)
+8. [Error Handling](#error-handling)
+
+---
+
+## Overview
+
+NuvioTV syncs the following data to Supabase so linked devices share the same state:
+
+| Data | Description | Trakt Override |
+|------|-------------|----------------|
+| **Plugins** | JavaScript plugin repository URLs | No (always syncs) |
+| **Addons** | Stremio-compatible addon manifest URLs | No (always syncs) |
+| **Watch Progress** | Per-movie/episode playback position | Yes (skipped when Trakt connected) |
+| **Library** | Saved movies & TV shows | Yes (skipped when Trakt connected) |
+| **Watched Items** | Permanent watched history (movies & episodes) | Yes (skipped when Trakt connected) |
+
+### Authentication Model
+
+- **Anonymous**: Auto-created account, can generate/claim sync codes
+- **Email/Password**: Full account with permanent data storage
+- **Linked Device**: A device linked to another account via sync code; reads/writes the owner's data
+
+### Security Model
+
+All data operations use **SECURITY DEFINER** RPC functions that call `get_sync_owner()` to resolve the effective user ID. This allows linked devices to transparently access the owner's data without needing direct RLS access.
+
+---
+
+## Prerequisites
+
+- Supabase project with:
+ - **Auth** enabled (anonymous sign-in + email/password)
+ - **pgcrypto** extension enabled (for `crypt()`, `gen_salt()`)
+- Environment variables:
+ - `SUPABASE_URL` — Your Supabase project URL
+ - `SUPABASE_ANON_KEY` — Your Supabase anonymous/public key
+
+---
+
+## Database Schema
+
+### Tables
+
+#### `sync_codes`
+
+Temporary codes for device linking, protected by a bcrypt-hashed PIN.
+
+```sql
+CREATE TABLE sync_codes (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ code TEXT NOT NULL,
+ pin_hash TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ DEFAULT 'infinity'::TIMESTAMPTZ
+);
+
+ALTER TABLE sync_codes ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own sync codes"
+ ON sync_codes FOR ALL
+ USING (auth.uid() = owner_id)
+ WITH CHECK (auth.uid() = owner_id);
+```
+
+#### `linked_devices`
+
+Maps a child device's user ID to a parent (owner) user ID.
+
+```sql
+CREATE TABLE linked_devices (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ device_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ device_name TEXT,
+ linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(owner_id, device_user_id)
+);
+
+ALTER TABLE linked_devices ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Owners can read their linked devices"
+ ON linked_devices FOR SELECT
+ USING (auth.uid() = owner_id);
+
+CREATE POLICY "Devices can read their own link"
+ ON linked_devices FOR SELECT
+ USING (auth.uid() = device_user_id);
+```
+
+#### `plugins`
+
+Plugin repository URLs synced across devices.
+
+```sql
+CREATE TABLE plugins (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ name TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_plugins_user_id ON plugins(user_id);
+ALTER TABLE plugins ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own plugins"
+ ON plugins FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+#### `addons`
+
+Addon manifest URLs synced across devices.
+
+```sql
+CREATE TABLE addons (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ name TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_addons_user_id ON addons(user_id);
+ALTER TABLE addons ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own addons"
+ ON addons FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+#### `watch_progress`
+
+Per-movie or per-episode playback progress.
+
+```sql
+CREATE TABLE watch_progress (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ content_id TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ video_id TEXT NOT NULL,
+ season INTEGER,
+ episode INTEGER,
+ position BIGINT NOT NULL DEFAULT 0,
+ duration BIGINT NOT NULL DEFAULT 0,
+ last_watched BIGINT NOT NULL DEFAULT 0,
+ progress_key TEXT NOT NULL
+);
+
+CREATE INDEX idx_watch_progress_user_id ON watch_progress(user_id);
+ALTER TABLE watch_progress ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own watch progress"
+ ON watch_progress FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+#### `library_items`
+
+Saved movies and TV shows (bookmarks/favorites).
+
+```sql
+CREATE TABLE library_items (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ content_id TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ poster TEXT,
+ poster_shape TEXT NOT NULL DEFAULT 'POSTER',
+ background TEXT,
+ description TEXT,
+ release_info TEXT,
+ imdb_rating REAL,
+ genres TEXT[] DEFAULT '{}',
+ addon_base_url TEXT,
+ added_at BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ UNIQUE(user_id, content_id, content_type)
+);
+
+CREATE INDEX idx_library_items_user_id ON library_items(user_id);
+ALTER TABLE library_items ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own library items"
+ ON library_items FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+#### `watched_items`
+
+Permanent watched history. Unlike `watch_progress` (which is capped and stores playback position), this table is a permanent record of everything the user has watched or marked as watched. Used to determine if a movie or episode should show a "watched" checkmark.
+
+```sql
+CREATE TABLE watched_items (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ content_id TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ season INTEGER,
+ episode INTEGER,
+ watched_at BIGINT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE UNIQUE INDEX idx_watched_items_unique
+ ON watched_items (user_id, content_id, COALESCE(season, -1), COALESCE(episode, -1));
+
+CREATE INDEX idx_watched_items_user_id ON watched_items(user_id);
+
+ALTER TABLE watched_items ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage own watched items"
+ ON watched_items FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+> **Note:** The unique index uses `COALESCE(season, -1)` and `COALESCE(episode, -1)` because PostgreSQL treats NULLs as distinct in unique constraints. Movies have `NULL` season/episode, so without COALESCE, multiple entries for the same movie would be allowed.
+
+### Triggers
+
+```sql
+-- Auto-update updated_at timestamp
+CREATE OR REPLACE FUNCTION set_updated_at()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$;
+
+-- Apply to tables with updated_at
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON plugins FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON addons FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON sync_codes FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+```
+
+---
+
+## RPC Functions
+
+### Core: `get_sync_owner()`
+
+Resolves the effective user ID. If the current user is a linked device, returns the owner's ID. Otherwise returns the caller's own ID. This is the foundation of the linked-device sync model.
+
+```sql
+CREATE OR REPLACE FUNCTION get_sync_owner()
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_owner_id uuid;
+BEGIN
+ SELECT owner_id INTO v_owner_id
+ FROM linked_devices
+ WHERE device_user_id = auth.uid()
+ LIMIT 1;
+
+ RETURN COALESCE(v_owner_id, auth.uid());
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION get_sync_owner() TO authenticated;
+```
+
+### Core: `can_access_user_data(p_user_id UUID)`
+
+Helper to check if the current user can access another user's data (either they are that user, or they are a linked device).
+
+```sql
+CREATE OR REPLACE FUNCTION can_access_user_data(p_user_id UUID)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ IF auth.uid() = p_user_id THEN
+ RETURN true;
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM public.linked_devices
+ WHERE owner_id = p_user_id
+ AND device_user_id = auth.uid()
+ ) THEN
+ RETURN true;
+ END IF;
+
+ RETURN false;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION can_access_user_data(UUID) TO authenticated;
+```
+
+### Device Linking: `generate_sync_code(p_pin TEXT)`
+
+Generates a sync code for the current user. If a code already exists, updates the PIN. The code format is `XXXX-XXXX-XXXX-XXXX-XXXX` (uppercase hex). PIN is bcrypt-hashed.
+
+```sql
+CREATE OR REPLACE FUNCTION generate_sync_code(p_pin TEXT)
+RETURNS TABLE(code TEXT)
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_user_id uuid;
+ v_existing_code text;
+ v_new_code text;
+ v_pin_hash text;
+BEGIN
+ v_user_id := auth.uid();
+
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ SELECT sc.code INTO v_existing_code
+ FROM sync_codes sc
+ WHERE sc.owner_id = v_user_id
+ ORDER BY sc.created_at DESC
+ LIMIT 1;
+
+ IF v_existing_code IS NOT NULL THEN
+ v_pin_hash := crypt(p_pin, gen_salt('bf'));
+ UPDATE sync_codes
+ SET pin_hash = v_pin_hash
+ WHERE sync_codes.owner_id = v_user_id
+ AND sync_codes.code = v_existing_code;
+ RETURN QUERY SELECT v_existing_code;
+ RETURN;
+ END IF;
+
+ v_new_code := upper(
+ substr(md5(random()::text || clock_timestamp()::text), 1, 4) || '-' ||
+ substr(md5(random()::text || clock_timestamp()::text), 5, 4) || '-' ||
+ substr(md5(random()::text || clock_timestamp()::text), 9, 4) || '-' ||
+ substr(md5(random()::text || clock_timestamp()::text), 13, 4) || '-' ||
+ substr(md5(random()::text || clock_timestamp()::text), 17, 4)
+ );
+
+ v_pin_hash := crypt(p_pin, gen_salt('bf'));
+
+ INSERT INTO sync_codes (owner_id, code, pin_hash)
+ VALUES (v_user_id, v_new_code, v_pin_hash);
+
+ RETURN QUERY SELECT v_new_code;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION generate_sync_code(TEXT) TO authenticated;
+```
+
+### Device Linking: `get_sync_code(p_pin TEXT)`
+
+Retrieves the existing sync code for the current user, validated by PIN.
+
+```sql
+CREATE OR REPLACE FUNCTION get_sync_code(p_pin TEXT)
+RETURNS TABLE(code TEXT)
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_user_id uuid;
+ v_existing_code text;
+ v_existing_pin_hash text;
+BEGIN
+ v_user_id := auth.uid();
+
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ SELECT sc.code, sc.pin_hash
+ INTO v_existing_code, v_existing_pin_hash
+ FROM sync_codes sc
+ WHERE sc.owner_id = v_user_id
+ ORDER BY sc.created_at DESC
+ LIMIT 1;
+
+ IF v_existing_code IS NULL THEN
+ RAISE EXCEPTION 'No sync code found. Generate one first.';
+ END IF;
+
+ IF v_existing_pin_hash != crypt(p_pin, v_existing_pin_hash) THEN
+ RAISE EXCEPTION 'Incorrect PIN';
+ END IF;
+
+ RETURN QUERY SELECT v_existing_code;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION get_sync_code(TEXT) TO authenticated;
+```
+
+### Device Linking: `claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT)`
+
+Links the current device to the owner of the sync code. Validates the PIN, then creates a `linked_devices` row.
+
+```sql
+CREATE OR REPLACE FUNCTION claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT DEFAULT NULL)
+RETURNS TABLE(result_owner_id UUID, success BOOLEAN, message TEXT)
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_owner_id uuid;
+ v_pin_hash text;
+BEGIN
+ SELECT sc.owner_id, sc.pin_hash
+ INTO v_owner_id, v_pin_hash
+ FROM sync_codes sc
+ WHERE sc.code = p_code;
+
+ IF v_owner_id IS NULL THEN
+ RETURN QUERY SELECT NULL::uuid, false, 'Sync code not found'::text;
+ RETURN;
+ END IF;
+
+ IF crypt(p_pin, v_pin_hash) != v_pin_hash THEN
+ RETURN QUERY SELECT NULL::uuid, false, 'Incorrect PIN'::text;
+ RETURN;
+ END IF;
+
+ INSERT INTO linked_devices (owner_id, device_user_id, device_name)
+ VALUES (v_owner_id, auth.uid(), p_device_name)
+ ON CONFLICT (owner_id, device_user_id) DO UPDATE
+ SET device_name = EXCLUDED.device_name;
+
+ RETURN QUERY SELECT v_owner_id, true, 'Device linked successfully'::text;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION claim_sync_code(TEXT, TEXT, TEXT) TO authenticated;
+```
+
+### Device Linking: `unlink_device(p_device_user_id UUID)`
+
+Removes a linked device. Only the owner can unlink their devices.
+
+```sql
+CREATE OR REPLACE FUNCTION unlink_device(p_device_user_id UUID)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ DELETE FROM linked_devices
+ WHERE (owner_id = auth.uid() AND device_user_id = p_device_user_id)
+ OR (device_user_id = auth.uid() AND device_user_id = p_device_user_id);
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION unlink_device(UUID) TO authenticated;
+```
+
+### Sync: `sync_push_plugins(p_plugins JSONB)`
+
+Full-replace push of plugin repository URLs.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_push_plugins(p_plugins JSONB)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id uuid;
+ v_plugin jsonb;
+BEGIN
+ SELECT get_sync_owner() INTO v_effective_user_id;
+
+ DELETE FROM plugins WHERE user_id = v_effective_user_id;
+
+ FOR v_plugin IN SELECT * FROM jsonb_array_elements(p_plugins)
+ LOOP
+ INSERT INTO plugins (user_id, url, name, enabled, sort_order)
+ VALUES (
+ v_effective_user_id,
+ v_plugin->>'url',
+ v_plugin->>'name',
+ COALESCE((v_plugin->>'enabled')::boolean, true),
+ (v_plugin->>'sort_order')::int
+ );
+ END LOOP;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_push_plugins(JSONB) TO authenticated;
+```
+
+### Sync: `sync_push_addons(p_addons JSONB)`
+
+Full-replace push of addon manifest URLs.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_push_addons(p_addons JSONB)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id uuid;
+ v_addon jsonb;
+BEGIN
+ SELECT get_sync_owner() INTO v_effective_user_id;
+
+ DELETE FROM addons WHERE user_id = v_effective_user_id;
+
+ FOR v_addon IN SELECT * FROM jsonb_array_elements(p_addons)
+ LOOP
+ INSERT INTO addons (user_id, url, sort_order)
+ VALUES (
+ v_effective_user_id,
+ v_addon->>'url',
+ (v_addon->>'sort_order')::int
+ );
+ END LOOP;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_push_addons(JSONB) TO authenticated;
+```
+
+### Sync: `sync_push_watch_progress(p_entries JSONB)`
+
+Full-replace push of watch progress entries.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_push_watch_progress(p_entries JSONB)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+
+ DELETE FROM watch_progress WHERE user_id = v_effective_user_id;
+
+ INSERT INTO watch_progress (
+ user_id, content_id, content_type, video_id,
+ season, episode, position, duration, last_watched, progress_key
+ )
+ SELECT
+ v_effective_user_id,
+ (entry->>'content_id'),
+ (entry->>'content_type'),
+ (entry->>'video_id'),
+ (entry->>'season')::INTEGER,
+ (entry->>'episode')::INTEGER,
+ (entry->>'position')::BIGINT,
+ (entry->>'duration')::BIGINT,
+ (entry->>'last_watched')::BIGINT,
+ (entry->>'progress_key')
+ FROM jsonb_array_elements(p_entries) AS entry;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_push_watch_progress(JSONB) TO authenticated;
+```
+
+### Sync: `sync_pull_watch_progress()`
+
+Returns all watch progress for the effective user (owner or linked device's owner).
+
+```sql
+CREATE OR REPLACE FUNCTION sync_pull_watch_progress()
+RETURNS SETOF watch_progress
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+ RETURN QUERY SELECT * FROM watch_progress WHERE user_id = v_effective_user_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_pull_watch_progress() TO authenticated;
+```
+
+### Sync: `sync_push_library(p_items JSONB)`
+
+Full-replace push of library items.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_push_library(p_items JSONB)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+
+ DELETE FROM library_items WHERE user_id = v_effective_user_id;
+
+ INSERT INTO library_items (
+ user_id, content_id, content_type, name, poster, poster_shape,
+ background, description, release_info, imdb_rating, genres,
+ addon_base_url, added_at
+ )
+ SELECT
+ v_effective_user_id,
+ (item->>'content_id'),
+ (item->>'content_type'),
+ COALESCE(item->>'name', ''),
+ (item->>'poster'),
+ COALESCE(item->>'poster_shape', 'POSTER'),
+ (item->>'background'),
+ (item->>'description'),
+ (item->>'release_info'),
+ (item->>'imdb_rating')::REAL,
+ COALESCE(
+ (SELECT array_agg(g::TEXT) FROM jsonb_array_elements_text(item->'genres') AS g),
+ '{}'
+ ),
+ (item->>'addon_base_url'),
+ COALESCE((item->>'added_at')::BIGINT, EXTRACT(EPOCH FROM now())::BIGINT * 1000)
+ FROM jsonb_array_elements(p_items) AS item;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_push_library(JSONB) TO authenticated;
+```
+
+### Sync: `sync_pull_library()`
+
+Returns all library items for the effective user.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_pull_library()
+RETURNS SETOF library_items
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+ RETURN QUERY SELECT * FROM library_items WHERE user_id = v_effective_user_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_pull_library() TO authenticated;
+```
+
+### Sync: `sync_push_watched_items(p_items JSONB)`
+
+Full-replace push of watched items (permanent watched history).
+
+```sql
+CREATE OR REPLACE FUNCTION sync_push_watched_items(p_items JSONB)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+ DELETE FROM watched_items WHERE user_id = v_effective_user_id;
+ INSERT INTO watched_items (user_id, content_id, content_type, title, season, episode, watched_at)
+ SELECT
+ v_effective_user_id,
+ (item->>'content_id'),
+ (item->>'content_type'),
+ COALESCE(item->>'title', ''),
+ (item->>'season')::INTEGER,
+ (item->>'episode')::INTEGER,
+ (item->>'watched_at')::BIGINT
+ FROM jsonb_array_elements(p_items) AS item;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_push_watched_items(JSONB) TO authenticated;
+```
+
+### Sync: `sync_pull_watched_items()`
+
+Returns all watched items for the effective user.
+
+```sql
+CREATE OR REPLACE FUNCTION sync_pull_watched_items()
+RETURNS SETOF watched_items
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_effective_user_id UUID;
+BEGIN
+ v_effective_user_id := get_sync_owner();
+ RETURN QUERY SELECT * FROM watched_items WHERE user_id = v_effective_user_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION sync_pull_watched_items() TO authenticated;
+```
+
+---
+
+## Integration Guide
+
+### 1. Authentication
+
+All API calls require a Supabase auth session. Initialize the Supabase client and authenticate:
+
+```
+POST {SUPABASE_URL}/auth/v1/signup
+Headers: apikey: {SUPABASE_ANON_KEY}
+Body: { "email": "user@example.com", "password": "..." }
+```
+
+Or for anonymous sign-in:
+
+```
+POST {SUPABASE_URL}/auth/v1/signup
+Headers: apikey: {SUPABASE_ANON_KEY}
+Body: {}
+```
+
+All subsequent requests include:
+```
+Headers:
+ apikey: {SUPABASE_ANON_KEY}
+ Authorization: Bearer {ACCESS_TOKEN}
+```
+
+### 2. Calling RPC Functions
+
+All RPCs are called via the Supabase PostgREST endpoint:
+
+```
+POST {SUPABASE_URL}/rest/v1/rpc/{function_name}
+Headers:
+ apikey: {SUPABASE_ANON_KEY}
+ Authorization: Bearer {ACCESS_TOKEN}
+ Content-Type: application/json
+Body: { ...parameters... }
+```
+
+### 3. Device Linking Flow
+
+**Device A (Parent) — Generate Sync Code:**
+
+```json
+// POST /rest/v1/rpc/generate_sync_code
+{ "p_pin": "1234" }
+
+// Response:
+[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }]
+```
+
+**Device B (Child) — Claim Sync Code:**
+
+```json
+// POST /rest/v1/rpc/claim_sync_code
+{
+ "p_code": "A1B2-C3D4-E5F6-G7H8-I9J0",
+ "p_pin": "1234",
+ "p_device_name": "Living Room TV"
+}
+
+// Response:
+[{
+ "result_owner_id": "uuid-of-device-a-user",
+ "success": true,
+ "message": "Device linked successfully"
+}]
+```
+
+After claiming, Device B's `get_sync_owner()` will return Device A's user ID, so all push/pull operations operate on the shared data.
+
+**Retrieve Existing Code (with PIN):**
+
+```json
+// POST /rest/v1/rpc/get_sync_code
+{ "p_pin": "1234" }
+
+// Response:
+[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }]
+```
+
+**Get Linked Devices:**
+
+```
+GET {SUPABASE_URL}/rest/v1/linked_devices?select=*&owner_id=eq.{your_user_id}
+```
+
+**Unlink a Device:**
+
+```json
+// POST /rest/v1/rpc/unlink_device
+{ "p_device_user_id": "uuid-of-device-to-unlink" }
+```
+
+### 4. Pushing Data
+
+All push RPCs use a **full-replace** strategy: existing data for the effective user is deleted, then the new data is inserted. This means you must always push the **complete** local dataset, not just changes.
+
+#### Push Plugins
+
+```json
+// POST /rest/v1/rpc/sync_push_plugins
+{
+ "p_plugins": [
+ {
+ "url": "https://example.com/plugin-repo",
+ "name": "My Plugin Repo",
+ "enabled": true,
+ "sort_order": 0
+ }
+ ]
+}
+```
+
+#### Push Addons
+
+```json
+// POST /rest/v1/rpc/sync_push_addons
+{
+ "p_addons": [
+ {
+ "url": "https://example.com/addon/manifest.json",
+ "sort_order": 0
+ }
+ ]
+}
+```
+
+#### Push Watch Progress
+
+```json
+// POST /rest/v1/rpc/sync_push_watch_progress
+{
+ "p_entries": [
+ {
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "video_id": "tt1234567",
+ "season": null,
+ "episode": null,
+ "position": 3600000,
+ "duration": 7200000,
+ "last_watched": 1700000000000,
+ "progress_key": "tt1234567"
+ },
+ {
+ "content_id": "tt7654321",
+ "content_type": "series",
+ "video_id": "tt7654321:2:5",
+ "season": 2,
+ "episode": 5,
+ "position": 1800000,
+ "duration": 3600000,
+ "last_watched": 1700000000000,
+ "progress_key": "tt7654321_s2e5"
+ }
+ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `content_id` | string | IMDB ID or content identifier |
+| `content_type` | string | `"movie"` or `"series"` |
+| `video_id` | string | Video stream identifier |
+| `season` | int/null | Season number (null for movies) |
+| `episode` | int/null | Episode number (null for movies) |
+| `position` | long | Playback position in milliseconds |
+| `duration` | long | Total duration in milliseconds |
+| `last_watched` | long | Unix timestamp in milliseconds |
+| `progress_key` | string | Unique key: `contentId` for movies, `contentId_s{S}e{E}` for episodes |
+
+#### Push Library Items
+
+```json
+// POST /rest/v1/rpc/sync_push_library
+{
+ "p_items": [
+ {
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "name": "Example Movie",
+ "poster": "https://image.tmdb.org/t/p/w500/poster.jpg",
+ "poster_shape": "POSTER",
+ "background": "https://image.tmdb.org/t/p/original/backdrop.jpg",
+ "description": "A great movie about...",
+ "release_info": "2024",
+ "imdb_rating": 8.5,
+ "genres": ["Action", "Thriller"],
+ "addon_base_url": "https://example.com/addon"
+ }
+ ]
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `content_id` | string | Yes | IMDB ID or content identifier |
+| `content_type` | string | Yes | `"movie"` or `"series"` |
+| `name` | string | No | Display name (defaults to `""`) |
+| `poster` | string | No | Poster image URL |
+| `poster_shape` | string | No | `"POSTER"`, `"LANDSCAPE"`, or `"SQUARE"` (defaults to `"POSTER"`) |
+| `background` | string | No | Background/backdrop image URL |
+| `description` | string | No | Content description |
+| `release_info` | string | No | Release year or date string |
+| `imdb_rating` | float | No | IMDB rating (0.0-10.0) |
+| `genres` | string[] | No | Genre list (defaults to `[]`) |
+| `addon_base_url` | string | No | Source addon base URL |
+| `added_at` | long | No | Timestamp in ms (defaults to current time) |
+
+#### Push Watched Items
+
+```json
+// POST /rest/v1/rpc/sync_push_watched_items
+{
+ "p_items": [
+ {
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "title": "Example Movie",
+ "season": null,
+ "episode": null,
+ "watched_at": 1700000000000
+ },
+ {
+ "content_id": "tt7654321",
+ "content_type": "series",
+ "title": "Example Series",
+ "season": 2,
+ "episode": 5,
+ "watched_at": 1700000000000
+ }
+ ]
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `content_id` | string | Yes | IMDB ID or content identifier |
+| `content_type` | string | Yes | `"movie"` or `"series"` |
+| `title` | string | No | Display name (defaults to `""`) |
+| `season` | int/null | No | Season number (null for movies) |
+| `episode` | int/null | No | Episode number (null for movies) |
+| `watched_at` | long | Yes | Unix timestamp in milliseconds |
+
+### 5. Pulling Data
+
+#### Pull Watch Progress
+
+```json
+// POST /rest/v1/rpc/sync_pull_watch_progress
+{}
+
+// Response: array of watch_progress rows
+[
+ {
+ "id": "uuid",
+ "user_id": "uuid",
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "video_id": "tt1234567",
+ "season": null,
+ "episode": null,
+ "position": 3600000,
+ "duration": 7200000,
+ "last_watched": 1700000000000,
+ "progress_key": "tt1234567"
+ }
+]
+```
+
+#### Pull Library Items
+
+```json
+// POST /rest/v1/rpc/sync_pull_library
+{}
+
+// Response: array of library_items rows
+[
+ {
+ "id": "uuid",
+ "user_id": "uuid",
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "name": "Example Movie",
+ "poster": "https://...",
+ "poster_shape": "POSTER",
+ "background": "https://...",
+ "description": "...",
+ "release_info": "2024",
+ "imdb_rating": 8.5,
+ "genres": ["Action", "Thriller"],
+ "addon_base_url": "https://...",
+ "added_at": 1700000000000,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+]
+```
+
+#### Pull Watched Items
+
+```json
+// POST /rest/v1/rpc/sync_pull_watched_items
+{}
+
+// Response: array of watched_items rows
+[
+ {
+ "id": "uuid",
+ "user_id": "uuid",
+ "content_id": "tt1234567",
+ "content_type": "movie",
+ "title": "Example Movie",
+ "season": null,
+ "episode": null,
+ "watched_at": 1700000000000,
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+]
+```
+
+#### Pull Plugins/Addons (Direct Table Query)
+
+Plugins and addons are pulled via direct table queries using the effective user ID:
+
+```
+// First, get the effective user ID
+POST /rest/v1/rpc/get_sync_owner
+{}
+// Response: "uuid-of-effective-owner"
+
+// Then query tables
+GET /rest/v1/addons?select=*&user_id=eq.{effective_user_id}&order=sort_order
+GET /rest/v1/plugins?select=*&user_id=eq.{effective_user_id}&order=sort_order
+```
+
+---
+
+## Data Models
+
+### Plugin
+
+```json
+{
+ "url": "string (required)",
+ "name": "string (optional)",
+ "enabled": "boolean (default: true)",
+ "sort_order": "integer (default: 0)"
+}
+```
+
+### Addon
+
+```json
+{
+ "url": "string (required)",
+ "sort_order": "integer (default: 0)"
+}
+```
+
+### Watch Progress Entry
+
+```json
+{
+ "content_id": "string (required)",
+ "content_type": "string (required) - 'movie' | 'series'",
+ "video_id": "string (required)",
+ "season": "integer (optional, null for movies)",
+ "episode": "integer (optional, null for movies)",
+ "position": "long (required) - playback position in ms",
+ "duration": "long (required) - total duration in ms",
+ "last_watched": "long (required) - unix timestamp in ms",
+ "progress_key": "string (required) - unique key per entry"
+}
+```
+
+### Library Item
+
+```json
+{
+ "content_id": "string (required)",
+ "content_type": "string (required) - 'movie' | 'series'",
+ "name": "string (default: '')",
+ "poster": "string (optional) - poster image URL",
+ "poster_shape": "string (default: 'POSTER') - 'POSTER' | 'LANDSCAPE' | 'SQUARE'",
+ "background": "string (optional) - backdrop image URL",
+ "description": "string (optional)",
+ "release_info": "string (optional) - release year/date",
+ "imdb_rating": "float (optional) - 0.0 to 10.0",
+ "genres": "string[] (default: []) - list of genre names",
+ "addon_base_url": "string (optional) - source addon URL",
+ "added_at": "long (default: current time) - unix timestamp in ms"
+}
+```
+
+### Watched Item
+
+```json
+{
+ "content_id": "string (required)",
+ "content_type": "string (required) - 'movie' | 'series'",
+ "title": "string (default: '') - display name",
+ "season": "integer (optional, null for movies)",
+ "episode": "integer (optional, null for movies)",
+ "watched_at": "long (required) - unix timestamp in ms"
+}
+```
+
+### Linked Device
+
+```json
+{
+ "owner_id": "uuid (required) - parent account user ID",
+ "device_user_id": "uuid (required) - this device's user ID",
+ "device_name": "string (optional) - human-readable device name",
+ "linked_at": "timestamptz (auto-set)"
+}
+```
+
+### Sync Code
+
+```json
+{
+ "owner_id": "uuid - user who generated the code",
+ "code": "string - format: XXXX-XXXX-XXXX-XXXX-XXXX",
+ "pin_hash": "string - bcrypt hash of the PIN",
+ "is_active": "boolean (default: true)",
+ "expires_at": "timestamptz (default: infinity)"
+}
+```
+
+---
+
+## Sync Behavior & Restrictions
+
+### Startup Sync Flow
+
+When the app starts and the user is authenticated (anonymous or full account):
+
+1. **Pull plugins** from remote → install any new ones locally
+2. **Pull addons** from remote → install any new ones locally
+3. If Trakt is **NOT** connected:
+ - **Pull watch progress** → merge into local (additive)
+ - **Push watch progress** → so linked devices can pull
+ - **Pull library items** → merge into local (additive)
+ - **Push library items** → so linked devices can pull
+ - **Pull watched items** → merge into local (additive)
+ - **Push watched items** → so linked devices can pull
+
+### On-Demand Sync
+
+- **Plugins/Addons**: Pushed to remote immediately when added or removed
+- **Watch Progress**: Pushed with a 2-second debounce after any playback position update
+- **Library Items**: Pushed with a 2-second debounce after add or remove
+- **Watched Items**: Pushed with a 2-second debounce after mark/unmark as watched
+
+### Merge Strategy
+
+- **Push**: Full-replace. The entire local dataset replaces the remote dataset.
+- **Pull (merge)**: Additive. Remote items not already present locally are added. Existing local items are preserved. Match keys vary by data type: `content_id` + `content_type` for library, `content_id` + `season` + `episode` for watched items.
+
+### Trakt Override
+
+When Trakt is connected:
+- **Watch progress**, **library**, and **watched items** sync via Supabase is **completely skipped**
+- Trakt becomes the source of truth for these data types
+- **Plugins** and **addons** always sync regardless of Trakt status
+
+### Push on Account Events
+
+| Event | Action |
+|-------|--------|
+| Sign up (email) | Push all local data to remote |
+| Sign in (email) | Pull all remote data to local |
+| Generate sync code | Push all local data to remote, then generate code |
+| Claim sync code | Pull all remote data from owner to local |
+
+---
+
+## Error Handling
+
+### Sync Code Errors
+
+| Error Message | Cause |
+|---------------|-------|
+| `Not authenticated` | No auth session |
+| `No sync code found. Generate one first.` | Calling `get_sync_code` before generating |
+| `Incorrect PIN` | Wrong PIN for `get_sync_code` or `claim_sync_code` |
+| `Sync code not found` | Invalid or non-existent code in `claim_sync_code` |
+| `Device linked successfully` | Success response from `claim_sync_code` |
+
+### Auth Errors
+
+| Error Message | Cause |
+|---------------|-------|
+| `Invalid login credentials` | Wrong email or password |
+| `Email not confirmed` | Email verification pending |
+| `User already registered` | Duplicate email signup |
+| `Password is too short/weak` | Password policy violation |
+| `Signup is disabled` | Admin disabled signups |
+| `Rate limit` / `Too many requests` | Too many auth attempts |
+
+### Network Errors
+
+| Error Message | Cause |
+|---------------|-------|
+| `Unable to resolve host` | No internet |
+| `Timeout` / `Timed out` | Connection timeout |
+| `Connection refused` | Server unreachable |
+| `404` | RPC function not found (missing migration) |
+| `400` / `Bad request` | Invalid parameters |
diff --git a/eas.json b/eas.json
index b208a76d..d68dbe69 100644
--- a/eas.json
+++ b/eas.json
@@ -5,13 +5,22 @@
},
"build": {
"development": {
+ "env": {
+ "SENTRY_DISABLE_AUTO_UPLOAD": "true"
+ },
"developmentClient": true,
"distribution": "internal"
},
"preview": {
+ "env": {
+ "SENTRY_DISABLE_AUTO_UPLOAD": "true"
+ },
"distribution": "internal"
},
"production": {
+ "env": {
+ "SENTRY_DISABLE_AUTO_UPLOAD": "true"
+ },
"autoIncrement": true,
"extends": "apk",
"android": {
@@ -21,12 +30,18 @@
}
},
"release": {
+ "env": {
+ "SENTRY_DISABLE_AUTO_UPLOAD": "true"
+ },
"distribution": "store",
"android": {
"buildType": "app-bundle"
}
},
"apk": {
+ "env": {
+ "SENTRY_DISABLE_AUTO_UPLOAD": "true"
+ },
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
diff --git a/index.html b/index.html
index 749e2f0a..fe298212 100644
--- a/index.html
+++ b/index.html
@@ -779,9 +779,9 @@
01
-
Stremio Addon Support
-
Full compatibility with Stremio addons. Access your favorite content
- providers seamlessly.
+
Stremio Addon Integration
+
Supports user-installed Stremio addons for metadata and source
+ integration.
@@ -984,9 +984,9 @@
Copyright & DMCA
-
We respect the intellectual property rights of others. Since Nuvio does not host any content, we
- cannot remove content from the internet. However, if you believe that the application interface
- itself infringes on your rights, please contact us.
+
We respect the intellectual property rights of others. Nuvio does not host media content.
+ If you believe this project's code, assets, or interface infringes your rights, please submit
+ a notice through the official project contact channels listed on this site and repository.