Merge cmp-rewrite into desktop

This commit is contained in:
tapframe 2026-04-22 21:21:50 +05:30
commit 1cc4aa698c
51 changed files with 2619 additions and 125 deletions

218
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,218 @@
name: Bug report
description: Report a reproducible bug (one per issue).
title: "[Bug]: short summary here"
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.
Please replace the default title with a short summary of the actual problem.
If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
- 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
- label: This is not a source/stream-specific error (the issue happens regardless of which source is used).
required: true
- type: markdown
attributes:
value: |
## Version & device
- type: input
id: app_version
attributes:
label: App version / commit
description: Release version (e.g. 1.2.3) or commit hash. If unsure, say where you got the build from.
placeholder: "e.g. 1.2.3, or main@abc1234"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Install method
options:
- GitHub Release / App Store / Play Store
- TestFlight
- CI build / Nightly
- Built from source
- Other (please describe below)
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android (phone/tablet)
- iOS (iPhone)
- iOS (iPad)
- macOS (desktop)
- Windows (desktop)
- Linux (desktop)
- 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, MacBook Air M2, etc."
placeholder: "e.g. iPhone 15 Pro"
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
placeholder: "e.g. iOS 18.1, Android 14, macOS 15.2"
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 (download, styling, sync)
- Audio tracks
- Navigation / Gestures
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Downloads / Offline
- Platform-specific (iOS only / Android only / Desktop only)
- 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. Tap / Click ...
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 (required for crash reports)
description: |
Required if the app crashes or force closes.
For other bug reports, logs are optional but still helpful.
**Android:** `adb logcat -d | tail -n 300`
**iOS:** Crash log from Xcode Organizer or Console.app
**Desktop:** Terminal/console output from around the time the issue occurred
render: shell
placeholder: |
Paste logs here
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else? (optional)
description: Screenshots/recordings, related issues, workarounds, etc.
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Downloads / Releases
url: https://github.com/NuvioMedia/NuvioMobile/releases
about: Grab the latest release here.
- name: Documentation
url: https://github.com/NuvioMedia/NuvioMobile/blob/main/README.md
about: Read the README for setup and usage details.

View file

@ -0,0 +1,83 @@
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.
**Any large PR or change that is not a simple bug fix MUST be discussed and approved via a feature request issue first.**
PRs that introduce large changes without a linked, approved feature request **will not be reviewed at all** and will be closed immediately. No exceptions.
- type: dropdown
id: area
attributes:
label: Area (tag)
options:
- Playback
- Streams / Sources
- Next Episode / Auto-play
- Watch Progress
- Subtitles
- Audio
- Navigation / Gestures
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Downloads / Offline
- Platform-specific (iOS only / Android only / Desktop only)
- 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

47
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,47 @@
## Summary
<!-- What changed in this PR? -->
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Translation update
- Approved larger change (link approval below)
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
## Policy check
<!-- ALL boxes must be checked or the PR will be closed without review. -->
- [ ] This PR is not cosmetic-only, unless it is a translation PR.
- [ ] 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 **approved** feature request issue below.
> **Large PRs without a linked, approved feature request issue will be closed immediately without review. No exceptions.**
## Approved feature request (required for large/non-trivial PRs)
<!-- Link the approved feature request issue. Delete this section ONLY for small bug fixes. -->
<!-- Example: Approved in #123 -->
## Testing
<!-- What you tested and how (manual + automated). -->
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
## Linked issues
<!-- Example: Fixes #123 -->

View file

@ -0,0 +1,83 @@
name: Close unlabeled issues
on:
schedule:
- cron: "23 6 * * *" # daily
workflow_dispatch:
inputs:
dry_run:
description: Log matching issues without commenting or closing them
required: false
type: boolean
default: false
permissions:
issues: write
jobs:
close_unlabeled:
runs-on: ubuntu-latest
steps:
- name: Close open issues with no labels
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const inputs = context.payload.inputs || {};
const dryRun = String(inputs.dry_run || "false").toLowerCase() === "true";
const closeMarker = "<!-- nuvio-bot:close-unlabeled -->";
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
q: `repo:${owner}/${repo} is:issue is:open no:label`,
per_page: 100,
});
core.info(`Found ${items.length} open unlabeled issues.`);
for (const item of items) {
const issueNumber = item.number;
if (dryRun) {
core.info(`#${issueNumber}: would comment and close.`);
continue;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyCommented = comments.some(comment =>
(comment.body || "").includes(closeMarker)
);
if (!alreadyCommented) {
const body =
`${closeMarker}\n` +
`Sorry about the churn here, and thanks for taking the time to report this.\n\n` +
`Closing this issue because it does not have any labels.\n\n` +
`Issue labels and triage rules were introduced later, so older unlabeled issues are no longer tracked reliably.\n\n` +
`If you are still facing this problem, please open a new issue using the appropriate issue template and label so it can be triaged correctly.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
}
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "not_planned",
});
core.info(`#${issueNumber}: closed.`);
}

74
.github/workflows/pr-template-check.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: PR Template Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
validate_pr_body:
runs-on: ubuntu-latest
steps:
- name: Validate required PR sections
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const body = (pr.body || "").trim();
function sectionContent(title) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^##\\s+${escaped}\\s*$`, "m");
const match = body.match(re);
if (!match) return null;
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^##\s+/m);
return (next === -1 ? rest : rest.slice(0, next)).trim();
}
const required = ["Summary", "Why", "Testing", "Breaking changes", "Linked issues"];
const missing = [];
const empty = [];
for (const name of required) {
const content = sectionContent(name);
if (content === null) {
missing.push(name);
continue;
}
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
if (
cleaned.length < 4 ||
cleaned === "none" ||
cleaned.includes("what changed in this pr") ||
cleaned.includes("why this change is needed") ||
cleaned.includes("what you tested") ||
cleaned.includes("example: fixes #123")
) {
empty.push(name);
}
}
if (missing.length || empty.length) {
const lines = [
"PR description is missing required detail.",
"",
];
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
lines.push("");
lines.push("Please fill the PR template before merging.");
core.setFailed(lines.join("\n"));
} else {
core.info("PR template check passed.");
}

70
.github/workflows/stale-needs-info.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: Close stale needs-info issues
on:
schedule:
- cron: "17 6 * * *" # daily
workflow_dispatch: {}
permissions:
issues: write
jobs:
close_stale:
runs-on: ubuntu-latest
steps:
- name: Warn then close inactive needs-info
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const NEEDS_INFO = "needs-info";
const WARN_AFTER_DAYS = 14;
const CLOSE_AFTER_DAYS = 21;
const warnMarker = "<!-- nuvio-bot:stale-warning -->";
const closeMarker = "<!-- nuvio-bot:stale-close -->";
const now = Date.now();
const warnCutoff = now - WARN_AFTER_DAYS * 24 * 60 * 60 * 1000;
const closeCutoff = now - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000;
async function listOpenNeedsInfoIssues() {
const q = `repo:${owner}/${repo} is:issue is:open label:"${NEEDS_INFO}"`;
const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 50 });
return res.data.items || [];
}
const items = await listOpenNeedsInfoIssues();
for (const item of items) {
const issue_number = item.number;
const updatedAtMs = new Date(item.updated_at).getTime();
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const hasWarned = comments.some(c => (c.body || "").includes(warnMarker));
const hasClosedComment = comments.some(c => (c.body || "").includes(closeMarker));
if (updatedAtMs <= closeCutoff && hasWarned && !hasClosedComment) {
const body =
`${closeMarker}\n` +
`Closing this for now since we didn't get the requested details.\n\n` +
`If you can share the missing info, reply here and we can reopen.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
await github.rest.issues.update({ owner, repo, issue_number, state: "closed" });
continue;
}
if (updatedAtMs <= warnCutoff && !hasWarned) {
const body =
`${warnMarker}\n` +
`Just a quick ping: this issue is labeled \`${NEEDS_INFO}\` and hasn't had any updates in a bit.\n\n` +
`If you can add the missing details, we can keep going. Otherwise it may be closed after a grace period.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
}

154
.github/workflows/triage-needs-info.yml vendored Normal file
View file

@ -0,0 +1,154 @@
name: Triage (needs-info)
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
needs_info:
runs-on: ubuntu-latest
steps:
- name: Label low-context issues
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
const issue = context.payload.issue;
const title = (issue.title || "").trim();
const body = issue.body || "";
const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
const NEEDS_INFO = "needs-info";
const NEEDS_INFO_COLOR = "d4c5f9";
const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
function hasLabel(name) {
return labels.includes(name.toLowerCase());
}
function extractSection(title) {
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^###\s+/m);
const section = (next === -1 ? rest : rest.slice(0, next));
return section.trim();
}
function extractFirstSection(titles) {
for (const sectionTitle of titles) {
const value = extractSection(sectionTitle);
if (value) return value;
}
return "";
}
function normalizeText(value) {
return (value || "").replace(/\s+/g, " ").trim();
}
function stripIssuePrefix(value) {
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
}
const steps = extractSection("Steps to reproduce");
const expected = extractSection("Expected behavior");
const actual = extractSection("Actual behavior");
const logs = extractFirstSection([
"Logs (required for crash reports)",
"Logs (optional but helpful)",
]);
const extra = extractSection("Anything else? (optional)");
const summaryTitle = stripIssuePrefix(title);
const looksLikeBugForm = !!(steps || expected || actual);
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
const isFeatureIssue =
hasLabel("enhancement") ||
hasLabel("feature") ||
hasLabel("feature request");
if (!isBugIssue || isFeatureIssue) {
return;
}
const problems = [];
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
const numericOnlyTitle = /^#?\d+$/;
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
const isCrashIssue = crashPattern.test(crashContext);
const normalizedLogs = normalizeText(logs);
const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
}
if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
if (!expected || expected.length < 10) problems.push("Expected behavior");
if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
if (isCrashIssue && !hasLogs) {
problems.push("Logs (required for crash reports; include a log snippet or stack trace)");
}
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
try {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
} catch (_) {}
}
}
const hasNeedsInfo = hasLabel(NEEDS_INFO);
if (problems.length > 0) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuvio-bot:needs-info -->";
const commentBody =
`${marker}\n` +
`Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
`Missing / too short:\n` +
problems.map(p => `- ${p}`).join("\n") +
`\n\n` +
`Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` +
`${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: commentBody,
});
}
} else if (hasNeedsInfo) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: NEEDS_INFO });
} catch (_) {}
}

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
.kotlin .kotlin
.gradle .gradle
**/build/ **/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
xcuserdata xcuserdata
!src/**/build/ !src/**/build/
local.properties local.properties

116
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,116 @@
# Contributing
Thanks for helping improve Nuvio.
## Strict rules — read before opening anything
These rules are enforced strictly. Issues and PRs that do not follow them will be closed without review.
---
## PR policy
Pull requests are currently intended for:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Small documentation fixes that improve accuracy
- Translation updates
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
Translation PRs are allowed, as long as they stay focused on translation/localization work and do not bundle unrelated feature or UI changes.
### Large PRs and large changes
**Any large PR or change that is not a simple bug fix must be discussed and approved via a feature request issue first.**
1. Open a **Feature Request** issue describing the change.
2. Wait for explicit maintainer approval on that issue.
3. Link the approved issue in your PR description.
PRs that introduce large changes without a linked, approved feature request **will not be reviewed at all** and will be closed immediately. No exceptions.
This applies to — but is not limited to — UI changes, new features, architecture changes, dependency additions, and large refactors.
---
## 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:
- A short, specific issue title that describes the bug
- App version (release version or commit hash)
- Platform (Android / iOS / Desktop) + device model + OS version
- Install method (release build / TestFlight / CI / built from source)
- Steps to reproduce (exact steps)
- Expected vs actual behavior
- Frequency (always/sometimes/once)
Do not leave the title as just `[Bug]:` or another generic placeholder.
Logs are optional for most issues, but they are **required** for crash / force-close reports.
### How to capture logs (optional)
**Android:**
```sh
adb logcat -d | tail -n 300
```
**iOS:**
Attach a crash log from Xcode Organizer or Console.app, or reproduce while connected to Xcode and copy the relevant log output.
**Desktop:**
Copy the relevant terminal/console output from around the time the issue occurred.
---
## 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.
**Large changes require an approved feature request before any PR is submitted.** See the [Large PRs and large changes](#large-prs-and-large-changes) section above.
---
## 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, unless it is a translation PR
- Not a new major feature unless it was discussed and approved first
- **If large or non-trivial: linked to an approved feature request issue**
PRs that do not fit this policy will 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.

View file

@ -386,6 +386,7 @@ android {
} }
} }
sourceSets.getByName("full") { sourceSets.getByName("full") {
manifest.srcFile("src/androidFull/AndroidManifest.xml")
java.srcDir(fullCommonSourceDir) java.srcDir(fullCommonSourceDir)
} }
packaging { packaging {

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nuvio_updater_file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = true
actual val p2pEnabled: Boolean = true
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
actual val inAppUpdaterEnabled: Boolean = true
}

View file

@ -0,0 +1,138 @@
package com.nuvio.app.features.updater
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
object AndroidAppUpdaterPlatform {
private const val preferencesName = "nuvio_updater"
private const val ignoredTagKey = "ignored_release_tag"
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
private var appContext: Context? = null
fun initialize(context: Context) {
appContext = context.applicationContext
}
fun getSupportedAbis(): List<String> = Build.SUPPORTED_ABIS?.toList().orEmpty()
fun getIgnoredTag(): String? =
preferences().getString(ignoredTagKey, null)
fun setIgnoredTag(tag: String?) {
preferences().edit().apply {
if (tag == null) remove(ignoredTagKey) else putString(ignoredTagKey, tag)
}.apply()
}
suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = withContext(Dispatchers.IO) {
runCatching {
val context = requireContext()
val safeName = assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val destination = File(File(context.cacheDir, "updates"), safeName)
destination.parentFile?.mkdirs()
if (destination.exists()) {
destination.delete()
}
val request = Request.Builder()
.url(assetUrl)
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Download failed with HTTP ${response.code}")
}
val body = response.body ?: error("Empty download body")
val totalBytes = body.contentLength().takeIf { it > 0L }
body.byteStream().use { input ->
FileOutputStream(destination).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloadedBytes = 0L
while (true) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
downloadedBytes += read
onProgress(downloadedBytes, totalBytes)
}
output.flush()
}
}
}
destination.absolutePath
}
}
fun canRequestPackageInstalls(): Boolean {
val context = appContext ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
context.packageManager.canRequestPackageInstalls()
} catch (_: SecurityException) {
true
}
} else {
true
}
}
fun openUnknownSourcesSettings() {
val context = appContext ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
fun installDownloadedApk(path: String): Result<Unit> = runCatching {
val context = requireContext()
val apkFile = File(path)
check(apkFile.exists()) { "Downloaded update file is missing." }
val apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile,
)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(apkUri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
private fun preferences() = requireContext().getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
private fun requireContext(): Context =
requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." }
}

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = true
actual fun getSupportedAbis(): List<String> = AndroidAppUpdaterPlatform.getSupportedAbis()
actual fun getIgnoredTag(): String? = AndroidAppUpdaterPlatform.getIgnoredTag()
actual fun setIgnoredTag(tag: String?) {
AndroidAppUpdaterPlatform.setIgnoredTag(tag)
}
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = AndroidAppUpdaterPlatform.downloadApk(assetUrl, assetName, onProgress)
actual fun canRequestPackageInstalls(): Boolean = AndroidAppUpdaterPlatform.canRequestPackageInstalls()
actual fun openUnknownSourcesSettings() {
AndroidAppUpdaterPlatform.openUnknownSourcesSettings()
}
actual fun installDownloadedApk(path: String): Result<Unit> = AndroidAppUpdaterPlatform.installDownloadedApk(path)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="nuvio_updates"
path="updates/" />
</paths>

View file

@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage
@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
DownloadsStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext) DownloadsPlatformDownloader.initialize(applicationContext)
DownloadsLiveStatusPlatform.initialize(applicationContext) DownloadsLiveStatusPlatform.initialize(applicationContext)
AndroidAppUpdaterPlatform.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext) PlatformLocalAccountDataCleaner.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.initialize(applicationContext) EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this) EpisodeReleaseNotificationPlatform.bindActivity(this)

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.updater
import android.content.Context
object AndroidAppUpdaterPlatform {
fun initialize(context: Context) = Unit
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -154,6 +154,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController
import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -442,6 +444,7 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val appUpdaterController = rememberAppUpdaterController()
remember { remember {
EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded()
} }
@ -960,6 +963,16 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = { onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute) navController.navigate(SupportersContributorsSettingsRoute)
}, },
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId -> onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
@ -1797,6 +1810,13 @@ private fun MainAppContent(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.zIndex(20f), .zIndex(20f),
) )
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier
.align(Alignment.Center)
.zIndex(25f),
)
} }
} }
@ -1840,6 +1860,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {}, onSupportersContributorsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {}, onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {},
@ -1890,6 +1911,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick, onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick, onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick, onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick, onCollectionsClick = onCollectionsSettingsClick,
) )
} }

View file

@ -0,0 +1,13 @@
package com.nuvio.app.core.build
enum class TrailerPlaybackMode {
IN_APP,
EXTERNAL,
}
expect object AppFeaturePolicy {
val pluginsEnabled: Boolean
val p2pEnabled: Boolean
val trailerPlaybackMode: TrailerPlaybackMode
val inAppUpdaterEnabled: Boolean
}

View file

@ -0,0 +1,46 @@
package com.nuvio.app.features.addons
internal fun addonTransportBaseUrl(manifestUrl: String): String =
manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
internal fun buildAddonResourceUrl(
manifestUrl: String,
resource: String,
type: String,
id: String,
extraPathSegment: String? = null,
): String {
val encodedId = id.encodeAddonPathSegment()
val baseUrl = addonTransportBaseUrl(manifestUrl)
return if (extraPathSegment.isNullOrEmpty()) {
"$baseUrl/$resource/$type/$encodedId.json"
} else {
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
}
}
internal fun String.encodeAddonPathSegment(): String =
buildString {
encodeToByteArray().forEach { byte ->
val value = byte.toInt() and 0xFF
val char = value.toChar()
if (
char in 'a'..'z' ||
char in 'A'..'Z' ||
char in '0'..'9' ||
char == '-' ||
char == '_' ||
char == '.' ||
char == '~'
) {
append(char)
} else {
append('%')
append(ADDON_URL_HEX[value shr 4])
append(ADDON_URL_HEX[value and 0x0F])
}
}
}
private const val ADDON_URL_HEX = "0123456789ABCDEF"

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.catalog package com.nuvio.app.features.catalog
import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogParser import com.nuvio.app.features.home.HomeCatalogParser
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
@ -122,21 +123,19 @@ internal fun buildCatalogUrl(
search: String?, search: String?,
skip: Int?, skip: Int?,
): String { ): String {
val baseUrl = manifestUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val extraParts = buildList { val extraParts = buildList {
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}") if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}") if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
if (skip != null && skip > 0) add("skip=$skip") if (skip != null && skip > 0) add("skip=$skip")
} }
return if (extraParts.isEmpty()) { return buildAddonResourceUrl(
"$baseUrl/catalog/$type/$catalogId.json" manifestUrl = manifestUrl,
} else { resource = "catalog",
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json" type = type,
} id = catalogId,
extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null },
)
} }
private fun String.encodeCatalogExtra(): String = private fun String.encodeCatalogExtra(): String =

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
@ -217,10 +218,12 @@ object MetaDetailsRepository {
id: String, id: String,
includeMdbList: Boolean, includeMdbList: Boolean,
): MetaDetails? { ): MetaDetails? {
val baseUrl = manifest.transportUrl val url = buildAddonResourceUrl(
.substringBefore("?") manifestUrl = manifest.transportUrl,
.removeSuffix("/manifest.json") resource = "meta",
val url = "$baseUrl/meta/$type/$id.json" type = type,
id = id,
)
return try { return try {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()

View file

@ -14,6 +14,7 @@ import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
import com.nuvio.app.features.watching.domain.playLabel import com.nuvio.app.features.watching.domain.playLabel
import com.nuvio.app.features.watching.domain.resumeLabel import com.nuvio.app.features.watching.domain.resumeLabel
import com.nuvio.app.features.watching.domain.shouldSurfaceNextEpisode
import com.nuvio.app.features.watching.domain.upNextLabel import com.nuvio.app.features.watching.domain.upNextLabel
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> = internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?, seasonNumber: Int?,
episodeNumber: Int?, episodeNumber: Int?,
todayIsoDate: String, todayIsoDate: String,
): MetaVideo? {
return nextReleasedEpisodeAfter(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = false,
)
}
internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
showUnairedNextUp: Boolean,
): MetaVideo? { ): MetaVideo? {
val sortedEpisodes = sortedPlayableEpisodes() val sortedEpisodes = sortedPlayableEpisodes()
val watchedVideoId = buildPlaybackVideoId( val watchedVideoId = buildPlaybackVideoId(
@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
} }
.drop(1) .drop(1)
.filter { episode -> .filter { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released) shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber,
candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate,
releasedDate = episode.released,
showUnairedNextUp = showUnairedNextUp,
)
} }
return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 } return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
} }

View file

@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.filterUnavailableFutureSeasons import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.details.sortedPlayableEpisodes
import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -45,10 +44,8 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.home.components.HomeCollectionRowSection
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
isCompleted = true, isCompleted = true,
) )
private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
showUnairedNextUp: Boolean,
): com.nuvio.app.features.details.MetaVideo? {
val content = WatchingContentRef(type = type, id = id)
val watchedVideoId = buildPlaybackVideoId(
content = content,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
val ordered = sortedPlayableEpisodes()
.dropWhile { episode ->
buildPlaybackVideoId(
content = content,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
) != watchedVideoId
}
.drop(1)
.filter { episode -> (episode.season ?: 0) > 0 }
.filterUnavailableFutureSeasons(todayIsoDate = todayIsoDate)
if (showUnairedNextUp) {
return ordered.firstOrNull()
}
return ordered.firstOrNull { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
}
}
private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean = private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f isNextUp || progressFraction < 0.995f

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -21,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Forward10 import androidx.compose.material.icons.rounded.Forward10
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.SwapHoriz
@ -29,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -61,6 +65,8 @@ internal fun PlayerControlsShell(
displayedPositionMs: Long, displayedPositionMs: Long,
metrics: PlayerLayoutMetrics, metrics: PlayerLayoutMetrics,
resizeMode: PlayerResizeMode, resizeMode: PlayerResizeMode,
isLocked: Boolean,
onLockToggle: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onTogglePlayback: () -> Unit, onTogglePlayback: () -> Unit,
onSeekBack: () -> Unit, onSeekBack: () -> Unit,
@ -120,6 +126,8 @@ internal fun PlayerControlsShell(
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
metrics = metrics, metrics = metrics,
isLocked = isLocked,
onLockToggle = onLockToggle,
onBack = onBack, onBack = onBack,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
@ -175,6 +183,8 @@ private fun PlayerHeader(
episodeNumber: Int?, episodeNumber: Int?,
episodeTitle: String?, episodeTitle: String?,
metrics: PlayerLayoutMetrics, metrics: PlayerLayoutMetrics,
isLocked: Boolean,
onLockToggle: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -240,18 +250,55 @@ private fun PlayerHeader(
} }
} }
NuvioBackButton( Row(
onClick = onBack, horizontalArrangement = Arrangement.spacedBy(10.dp),
containerColor = Color.Black.copy(alpha = 0.35f), verticalAlignment = Alignment.CenterVertically,
contentColor = Color.White, ) {
buttonSize = metrics.headerIconSize + 16.dp, PlayerHeaderIconButton(
iconSize = metrics.headerIconSize, icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
contentDescription = "Close player", contentDescription = if (isLocked) "Unlock player controls" else "Lock player controls",
) buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize,
onClick = onLockToggle,
)
NuvioBackButton(
onClick = onBack,
containerColor = Color.Black.copy(alpha = 0.35f),
contentColor = Color.White,
buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize,
contentDescription = "Close player",
)
}
} }
} }
} }
@Composable
private fun PlayerHeaderIconButton(
icon: ImageVector,
contentDescription: String,
buttonSize: androidx.compose.ui.unit.Dp,
iconSize: androidx.compose.ui.unit.Dp,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.35f))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(iconSize),
)
}
}
@Composable @Composable
private fun CenterControls( private fun CenterControls(
snapshot: PlayerPlaybackSnapshot, snapshot: PlayerPlaybackSnapshot,
@ -446,6 +493,105 @@ private fun ProgressControls(
} }
} }
@Composable
internal fun LockedPlayerOverlay(
playbackSnapshot: PlayerPlaybackSnapshot,
displayedPositionMs: Long,
metrics: PlayerLayoutMetrics,
horizontalSafePadding: androidx.compose.ui.unit.Dp,
onUnlock: () -> Unit,
modifier: Modifier = Modifier,
) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
val sliderColors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.28f),
disabledThumbColor = Color.White,
disabledActiveTrackColor = Color.White,
disabledInactiveTrackColor = Color.White.copy(alpha = 0.28f),
)
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.72f),
),
),
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(78.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.52f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape)
.clickable(onClick = onUnlock),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = "Unlock player controls",
tint = Color.White,
modifier = Modifier.size(34.dp),
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Tap to unlock",
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
color = Color.White.copy(alpha = 0.92f),
)
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = horizontalSafePadding + metrics.horizontalPadding)
.padding(bottom = metrics.sliderBottomOffset),
) {
Slider(
modifier = Modifier
.fillMaxWidth()
.height(metrics.sliderTouchHeight)
.graphicsLayer(scaleY = metrics.sliderScaleY),
value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
onValueChange = {},
onValueChangeFinished = {},
valueRange = 0f..durationMs.toFloat(),
enabled = false,
colors = sliderColors,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.padding(top = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
}
}
}
}
@Composable @Composable
private fun TimePill( private fun TimePill(
text: String, text: String,

View file

@ -69,6 +69,7 @@ import kotlin.math.roundToInt
private const val PlaybackProgressPersistIntervalMs = 60_000L private const val PlaybackProgressPersistIntervalMs = 60_000L
private const val PlayerDoubleTapSeekStepMs = 10_000L private const val PlayerDoubleTapSeekStepMs = 10_000L
private const val PlayerDoubleTapSeekResetDelayMs = 800L private const val PlayerDoubleTapSeekResetDelayMs = 800L
private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f private const val PlayerVerticalGestureSensitivity = 1f
@ -154,6 +155,7 @@ fun PlayerScreen(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val gestureController = rememberPlayerGestureController() val gestureController = rememberPlayerGestureController()
var controlsVisible by rememberSaveable { mutableStateOf(true) } var controlsVisible by rememberSaveable { mutableStateOf(true) }
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
// Active playback state (mutable to support source/episode switching) // Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
@ -189,6 +191,7 @@ fun PlayerScreen(
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var lockedOverlayVisible by remember { mutableStateOf(false) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) } var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) } var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) } var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
@ -497,6 +500,35 @@ fun PlayerScreen(
liveGestureFeedback = null liveGestureFeedback = null
} }
fun revealLockedOverlay() {
controlsVisible = false
lockedOverlayVisible = true
}
fun lockPlayerControls() {
playerControlsLocked = true
controlsVisible = false
lockedOverlayVisible = false
pausedOverlayVisible = false
scrubbingPositionMs = null
gestureMessageJob?.cancel()
gestureFeedback = null
liveGestureFeedback = null
renderedGestureFeedback = null
showAudioModal = false
showSubtitleModal = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
}
fun unlockPlayerControls() {
playerControlsLocked = false
lockedOverlayVisible = false
controlsVisible = true
}
fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
val seconds = amountMs / 1000L val seconds = amountMs / 1000L
if (seconds <= 0L) return if (seconds <= 0L) return
@ -659,6 +691,10 @@ fun PlayerScreen(
} }
val onSurfaceTap = rememberUpdatedState { offset: Offset -> val onSurfaceTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
val centerStart = layoutSize.width * PlayerLeftGestureBoundary val centerStart = layoutSize.width * PlayerLeftGestureBoundary
val centerEnd = layoutSize.width * PlayerRightGestureBoundary val centerEnd = layoutSize.width * PlayerRightGestureBoundary
if (controlsVisible && offset.x in centerStart..centerEnd) { if (controlsVisible && offset.x in centerStart..centerEnd) {
@ -668,6 +704,10 @@ fun PlayerScreen(
} }
} }
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
when { when {
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Backward) handleDoubleTapSeek(PlayerSeekDirection.Backward)
@ -686,7 +726,9 @@ fun PlayerScreen(
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive) val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
@ -1002,6 +1044,7 @@ fun PlayerScreen(
scrubbingPositionMs = null scrubbingPositionMs = null
liveGestureFeedback = null liveGestureFeedback = null
renderedGestureFeedback = null renderedGestureFeedback = null
lockedOverlayVisible = false
initialLoadCompleted = false initialLoadCompleted = false
lastProgressPersistEpochMs = 0L lastProgressPersistEpochMs = 0L
previousIsPlaying = false previousIsPlaying = false
@ -1120,6 +1163,14 @@ fun PlayerScreen(
controlsVisible = false controlsVisible = false
} }
LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
if (!playerControlsLocked || !lockedOverlayVisible) {
return@LaunchedEffect
}
delay(PlayerLockedOverlayDurationMs)
lockedOverlayVisible = false
}
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
pausedOverlayVisible = false pausedOverlayVisible = false
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
@ -1325,12 +1376,27 @@ fun PlayerScreen(
}, },
onTap = { offset -> onSurfaceTap.value(offset) }, onTap = { offset -> onSurfaceTap.value(offset) },
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
onLongPress = { activateHoldToSpeedState.value() }, onLongPress = {
if (playerControlsLockedState.value) {
revealLockedOverlayState.value()
} else {
activateHoldToSpeedState.value()
}
},
) )
} }
.pointerInput(gestureController, layoutSize) { .pointerInput(gestureController, layoutSize) {
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown() val down = awaitFirstDown()
if (playerControlsLockedState.value) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
change.consume()
}
return@awaitEachGesture
}
val controller = gestureController val controller = gestureController
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
@ -1591,18 +1657,18 @@ fun PlayerScreen(
} }
if (snapshot.isEnded) { if (snapshot.isEnded) {
shouldPlay = false shouldPlay = false
controlsVisible = true controlsVisible = !playerControlsLocked
} }
}, },
onError = { message -> onError = { message ->
errorMessage = message errorMessage = message
if (message != null) { if (message != null) {
controlsVisible = true controlsVisible = !playerControlsLocked
} }
}, },
) )
if (pausedOverlayVisible && !controlsVisible) { if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) {
PauseMetadataOverlay( PauseMetadataOverlay(
title = title, title = title,
logo = logo, logo = logo,
@ -1619,7 +1685,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible, visible = controlsVisible && !playerControlsLocked,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
@ -1634,6 +1700,14 @@ fun PlayerScreen(
displayedPositionMs = displayedPositionMs, displayedPositionMs = displayedPositionMs,
metrics = metrics, metrics = metrics,
resizeMode = resizeMode, resizeMode = resizeMode,
isLocked = playerControlsLocked,
onLockToggle = {
if (playerControlsLocked) {
unlockPlayerControls()
} else {
lockPlayerControls()
}
},
onBack = onBackWithProgress, onBack = onBackWithProgress,
onTogglePlayback = ::togglePlayback, onTogglePlayback = ::togglePlayback,
onSeekBack = { seekBy(-10_000L) }, onSeekBack = { seekBy(-10_000L) },
@ -1660,6 +1734,21 @@ fun PlayerScreen(
) )
} }
AnimatedVisibility(
visible = playerControlsLocked && lockedOverlayVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
LockedPlayerOverlay(
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
onUnlock = ::unlockPlayerControls,
modifier = Modifier.fillMaxSize(),
)
}
AnimatedVisibility( AnimatedVisibility(
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
enter = fadeIn(), enter = fadeIn(),
@ -1697,23 +1786,25 @@ fun PlayerScreen(
} }
// Skip intro/recap/outro button // Skip intro/recap/outro button
SkipIntroButton( if (!playerControlsLocked) {
interval = activeSkipInterval, SkipIntroButton(
dismissed = skipIntervalDismissed, interval = activeSkipInterval,
controlsVisible = controlsVisible, dismissed = skipIntervalDismissed,
onSkip = { controlsVisible = controlsVisible,
val interval = activeSkipInterval ?: return@SkipIntroButton onSkip = {
playerController?.seekTo((interval.endTime * 1000).toLong()) val interval = activeSkipInterval ?: return@SkipIntroButton
skipIntervalDismissed = true playerController?.seekTo((interval.endTime * 1000).toLong())
}, skipIntervalDismissed = true
onDismiss = { skipIntervalDismissed = true }, },
modifier = Modifier onDismiss = { skipIntervalDismissed = true },
.align(Alignment.BottomStart) modifier = Modifier
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding), .align(Alignment.BottomStart)
) .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
)
}
// Next episode card // Next episode card
if (isSeries) { if (isSeries && !playerControlsLocked) {
NextEpisodeCard( NextEpisodeCard(
nextEpisode = nextEpisodeInfo, nextEpisode = nextEpisodeInfo,
visible = showNextEpisodeCard, visible = showNextEpisodeCard,

View file

@ -3,9 +3,11 @@ package com.nuvio.app.features.player
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.AddonStreamGroup
@ -215,11 +217,12 @@ object PlayerStreamsRepository {
val job = scope.launch { val job = scope.launch {
val addonJobs = streamAddons.map { addon -> val addonJobs = streamAddons.map { addon ->
async { async {
val encodedId = videoId.replace("%", "%25").replace(" ", "%20") val url = buildAddonResourceUrl(
val baseUrl = addon.manifest.transportUrl manifestUrl = addon.manifest.transportUrl,
.substringBefore("?") resource = "stream",
.removeSuffix("/manifest.json") type = type,
val url = "$baseUrl/stream/$type/$encodedId.json" id = videoId,
)
val displayName = addon.addonName val displayName = addon.addonName
runCatching { runCatching {
@ -241,7 +244,11 @@ object PlayerStreamsRepository {
async { async {
PluginRepository.executeScraper( PluginRepository.executeScraper(
scraper = scraper, scraper = scraper,
tmdbId = videoId.toPluginTmdbId(), tmdbId = pluginContentId(
videoId = videoId,
season = season,
episode = episode,
),
mediaType = type, mediaType = type,
season = season, season = season,
episode = episode, episode = episode,
@ -337,11 +344,3 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
}, },
) )
} }
private fun String.toPluginTmdbId(): String {
return when {
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
else -> this
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.player package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -49,8 +50,12 @@ object SubtitleRepository {
subtitleResource.idPrefixes.any { videoId.startsWith(it) } subtitleResource.idPrefixes.any { videoId.startsWith(it) }
if (!prefixMatch) continue if (!prefixMatch) continue
val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json") val subtitleUrl = buildAddonResourceUrl(
val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json" manifestUrl = manifest.transportUrl,
resource = "subtitles",
type = type,
id = videoId,
)
try { try {
val response = withContext(Dispatchers.Default) { val response = withContext(Dispatchers.Default) {

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.plugins
internal fun pluginContentId(
videoId: String,
season: Int?,
episode: Int?,
): String {
val trimmed = videoId.trim()
if (trimmed.isBlank()) return videoId
val withoutPrefix = when {
trimmed.startsWith("tmdb:") -> trimmed.removePrefix("tmdb:")
trimmed.startsWith("tmdb/") -> trimmed.removePrefix("tmdb/")
else -> trimmed
}
val withoutEpisodeSuffix = if (season != null && episode != null) {
withoutPrefix.removeSuffix(":$season:$episode")
} else {
withoutPrefix
}
return withoutEpisodeSuffix.substringBefore('/').ifBlank { trimmed }
}

View file

@ -30,6 +30,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit, onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit, onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit, onSupportersContributorsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit, onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit, onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null, onSwitchProfileClick: (() -> Unit)? = null,
@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet, isTablet = isTablet,
onClick = onSupportersContributorsClick, onClick = onSupportersContributorsClick,
) )
if (onCheckForUpdatesClick != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Check for updates",
description = "Check for new versions of the app.",
icon = Icons.Rounded.CloudDownload,
isTablet = isTablet,
onClick = onCheckForUpdatesClick,
)
}
} }
} }
} }

View file

@ -71,6 +71,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {}, onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
BoxWithConstraints( BoxWithConstraints(
@ -190,6 +191,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile, onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick, onCollectionsClick = onCollectionsClick,
) )
} else { } else {
@ -233,6 +235,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick, onCollectionsClick = onCollectionsClick,
) )
} }
@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {}, onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
NuvioScreen { NuvioScreen {
@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile, onSwitchProfileClick = onSwitchProfile,
@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) }, onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile, onSwitchProfileClick = onSwitchProfile,

View file

@ -3,10 +3,12 @@ package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginsUiState import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.PluginRepositoryItem import com.nuvio.app.features.plugins.PluginRepositoryItem
import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginRuntimeResult
@ -237,11 +239,12 @@ object StreamsRepository {
streamAddons.forEach { addon -> streamAddons.forEach { addon ->
launch { launch {
val encodedId = videoId.encodeForPath() val url = buildAddonResourceUrl(
val baseUrl = addon.manifest.transportUrl manifestUrl = addon.manifest.transportUrl,
.substringBefore("?") resource = "stream",
.removeSuffix("/manifest.json") type = type,
val url = "$baseUrl/stream/$type/$encodedId.json" id = videoId,
)
log.d { "Fetching streams from: $url" } log.d { "Fetching streams from: $url" }
val displayName = addon.addonName val displayName = addon.addonName
@ -283,7 +286,11 @@ object StreamsRepository {
launch { launch {
val completion = PluginRepository.executeScraper( val completion = PluginRepository.executeScraper(
scraper = scraper, scraper = scraper,
tmdbId = videoId.toPluginTmdbId(), tmdbId = pluginContentId(
videoId = videoId,
season = season,
episode = episode,
),
mediaType = type, mediaType = type,
season = season, season = season,
episode = episode, episode = episode,
@ -420,10 +427,6 @@ object StreamsRepository {
activeRequestKey = null activeRequestKey = null
_uiState.value = StreamsUiState() _uiState.value = StreamsUiState()
} }
// Encode id segment so colons and slashes don't break URL path parsing on addons
private fun String.encodeForPath(): String =
replace("%", "%25").replace(" ", "%20")
} }
private data class InstalledStreamAddonTarget( private data class InstalledStreamAddonTarget(
@ -488,14 +491,6 @@ private fun List<AddonStreamGroup>.toEmptyStateReason(anyLoading: Boolean): Stre
} }
} }
private fun String.toPluginTmdbId(): String {
return when {
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
else -> this
}
}
private fun PluginRuntimeResult.toStreamItem( private fun PluginRuntimeResult.toStreamItem(
scraper: PluginScraper, scraper: PluginScraper,
addonName: String = scraper.name, addonName: String = scraper.name,

View file

@ -0,0 +1,630 @@
package com.nuvio.app.features.updater
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
private const val gitHubOwner = "NuvioMedia"
private const val gitHubRepo = "NuvioMobile"
private const val gitHubApiBase = "https://api.github.com"
private const val releaseChannelBranch = "cmp-rewrite"
data class AppUpdate(
val tag: String,
val title: String,
val notes: String,
val releaseUrl: String?,
val assetName: String,
val assetUrl: String,
val assetSizeBytes: Long?,
)
data class AppUpdaterUiState(
val isChecking: Boolean = false,
val update: AppUpdate? = null,
val isUpdateAvailable: Boolean = false,
val isDownloading: Boolean = false,
val downloadProgress: Float? = null,
val downloadedApkPath: String? = null,
val showDialog: Boolean = false,
val showUnknownSourcesDialog: Boolean = false,
val errorMessage: String? = null,
)
@Serializable
private data class GitHubReleaseDto(
@SerialName("tag_name") val tagName: String? = null,
val name: String? = null,
val body: String? = null,
val draft: Boolean = false,
val prerelease: Boolean = false,
@SerialName("html_url") val htmlUrl: String? = null,
@SerialName("target_commitish") val targetCommitish: String? = null,
val assets: List<GitHubAssetDto> = emptyList(),
)
@Serializable
private data class GitHubAssetDto(
val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String,
val size: Long? = null,
@SerialName("content_type") val contentType: String? = null,
)
private val appUpdaterJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
private class NoChannelReleaseException : IllegalStateException(
"No cmp-rewrite release has been published yet.",
)
private object VersionUtils {
fun normalize(raw: String?): String {
if (raw.isNullOrBlank()) return ""
return raw.trim().removePrefix("v").removePrefix("V")
}
fun parseVersionParts(raw: String?): List<Int>? {
val normalized = normalize(raw)
if (normalized.isBlank()) return null
val parts = normalized.split('.', '-', '_')
.filter { it.isNotBlank() }
.mapNotNull { token -> token.takeWhile { it.isDigit() }.toIntOrNull() }
return parts.takeIf { it.isNotEmpty() }
}
fun isRemoteNewer(remote: String?, local: String?): Boolean {
val remoteParts = parseVersionParts(remote)
val localParts = parseVersionParts(local)
if (remoteParts == null || localParts == null) {
val remoteValue = normalize(remote)
val localValue = normalize(local)
return remoteValue.isNotBlank() && localValue.isNotBlank() && remoteValue != localValue
}
val maxSize = maxOf(remoteParts.size, localParts.size)
for (index in 0 until maxSize) {
val remoteValue = remoteParts.getOrElse(index) { 0 }
val localValue = localParts.getOrElse(index) { 0 }
if (remoteValue != localValue) return remoteValue > localValue
}
return false
}
}
private object AppUpdaterRepository {
suspend fun getLatestChannelUpdate(): Result<AppUpdate> = runCatching {
val response = httpRequestRaw(
method = "GET",
url = "$gitHubApiBase/repos/$gitHubOwner/$gitHubRepo/releases?per_page=20",
headers = mapOf(
"Accept" to "application/vnd.github+json",
"User-Agent" to "NuvioMobile",
),
body = "",
)
if (response.status !in 200..299) {
error("GitHub releases API error: ${response.status}")
}
val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body)
val release = releases.firstOrNull { it.matchesRequestedChannel() && !it.draft && !it.prerelease }
?: throw NoChannelReleaseException()
val tag = release.tagName?.takeIf { it.isNotBlank() }
?: release.name?.takeIf { it.isNotBlank() }
?: error("Release has no tag or name")
val asset = chooseBestApkAsset(release.assets)
?: error("No APK asset found in the cmp-rewrite release")
AppUpdate(
tag = tag,
title = release.name?.takeIf { it.isNotBlank() } ?: tag,
notes = release.body.orEmpty(),
releaseUrl = release.htmlUrl,
assetName = asset.name,
assetUrl = asset.browserDownloadUrl,
assetSizeBytes = asset.size,
)
}
private fun GitHubReleaseDto.matchesRequestedChannel(): Boolean {
val channel = releaseChannelBranch
if (targetCommitish?.trim()?.equals(channel, ignoreCase = true) == true) {
return true
}
return listOf(tagName, name)
.filterNotNull()
.any { value -> value.contains(channel, ignoreCase = true) }
}
private fun chooseBestApkAsset(assets: List<GitHubAssetDto>): GitHubAssetDto? {
val apkAssets = assets.filter { asset ->
asset.name.endsWith(".apk", ignoreCase = true) ||
asset.contentType == "application/vnd.android.package-archive"
}
if (apkAssets.isEmpty()) return null
if (apkAssets.size == 1) return apkAssets.first()
val supportedAbis = AppUpdaterPlatform.getSupportedAbis()
for (abi in supportedAbis) {
val candidate = apkAssets.firstOrNull { asset ->
asset.name.contains(abi, ignoreCase = true)
}
if (candidate != null) return candidate
}
return apkAssets.firstOrNull { asset ->
val name = asset.name.lowercase()
name.contains("universal") || name.contains("all")
} ?: apkAssets.first()
}
}
class AppUpdaterController internal constructor(
private val scope: CoroutineScope,
) {
private val _uiState = MutableStateFlow(AppUpdaterUiState())
val uiState: StateFlow<AppUpdaterUiState> = _uiState.asStateFlow()
private var autoCheckStarted = false
fun ensureAutoCheckStarted() {
if (autoCheckStarted || !AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
return
}
autoCheckStarted = true
checkForUpdates(force = false, showNoUpdateFeedback = false)
}
fun checkForUpdates(force: Boolean, showNoUpdateFeedback: Boolean) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
if (showNoUpdateFeedback) {
NuvioToastController.show("In-app updates are not available on this build.")
}
return
}
scope.launch {
_uiState.update { state ->
state.copy(
isChecking = true,
errorMessage = null,
showUnknownSourcesDialog = false,
)
}
val ignoredTag = AppUpdaterPlatform.getIgnoredTag()
val result = AppUpdaterRepository.getLatestChannelUpdate()
result.onSuccess { update ->
val remoteNewer = VersionUtils.isRemoteNewer(update.tag, AppVersionConfig.VERSION_NAME)
val ignored = ignoredTag != null && ignoredTag == update.tag
val shouldShowDialog = force || (remoteNewer && !ignored)
_uiState.update { state ->
state.copy(
isChecking = false,
update = update.takeIf { remoteNewer },
isUpdateAvailable = remoteNewer,
isDownloading = false,
downloadProgress = null,
downloadedApkPath = state.downloadedApkPath.takeIf { remoteNewer },
showDialog = shouldShowDialog,
showUnknownSourcesDialog = false,
errorMessage = null,
)
}
if (showNoUpdateFeedback && !remoteNewer) {
NuvioToastController.show("You're using the latest version.")
}
}.onFailure { error ->
_uiState.update { state ->
state.copy(
isChecking = false,
isDownloading = false,
downloadProgress = null,
downloadedApkPath = null,
update = null,
isUpdateAvailable = false,
showDialog = force && error !is NoChannelReleaseException,
showUnknownSourcesDialog = false,
errorMessage = if (force && error !is NoChannelReleaseException) {
error.message ?: "Update check failed"
} else {
null
},
)
}
if (showNoUpdateFeedback || error is NoChannelReleaseException) {
NuvioToastController.show(error.message ?: "Update check failed")
}
}
}
}
fun dismissDialog() {
_uiState.update { state ->
state.copy(
showDialog = false,
showUnknownSourcesDialog = false,
errorMessage = null,
)
}
}
fun ignoreThisVersion() {
val tag = _uiState.value.update?.tag ?: return
AppUpdaterPlatform.setIgnoredTag(tag)
dismissDialog()
}
fun downloadUpdate() {
val update = _uiState.value.update ?: return
scope.launch {
_uiState.update { state ->
state.copy(
isDownloading = true,
downloadProgress = 0f,
errorMessage = null,
)
}
AppUpdaterPlatform.downloadApk(
assetUrl = update.assetUrl,
assetName = update.assetName,
) { downloadedBytes, totalBytes ->
val progress = if (totalBytes != null && totalBytes > 0L) {
(downloadedBytes.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f)
} else {
null
}
_uiState.update { state -> state.copy(downloadProgress = progress) }
}.onSuccess { path ->
_uiState.update { state ->
state.copy(
isDownloading = false,
downloadProgress = 1f,
downloadedApkPath = path,
errorMessage = null,
)
}
installDownloadedUpdate()
}.onFailure { error ->
_uiState.update { state ->
state.copy(
isDownloading = false,
downloadProgress = null,
downloadedApkPath = null,
errorMessage = error.message ?: "Download failed",
showDialog = true,
)
}
}
}
}
fun installDownloadedUpdate() {
val apkPath = _uiState.value.downloadedApkPath ?: return
if (!AppUpdaterPlatform.canRequestPackageInstalls()) {
_uiState.update { state -> state.copy(showUnknownSourcesDialog = true, showDialog = true) }
return
}
AppUpdaterPlatform.installDownloadedApk(apkPath).onSuccess {
_uiState.update { state -> state.copy(showUnknownSourcesDialog = false) }
}.onFailure { error ->
_uiState.update { state ->
state.copy(
errorMessage = error.message ?: "Unable to start installation",
showDialog = true,
)
}
}
}
fun resumeInstallation() {
if (AppUpdaterPlatform.canRequestPackageInstalls()) {
installDownloadedUpdate()
} else {
AppUpdaterPlatform.openUnknownSourcesSettings()
}
}
}
@Composable
fun rememberAppUpdaterController(): AppUpdaterController {
val scope = rememberCoroutineScope()
return remember(scope) { AppUpdaterController(scope) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppUpdaterHost(
controller: AppUpdaterController,
modifier: Modifier = Modifier,
) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
return
}
val state by controller.uiState.collectAsStateWithLifecycle()
LaunchedEffect(controller) {
controller.ensureAutoCheckStarted()
}
if (!state.showDialog) return
val showPrimaryAction =
state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable
BasicAlertDialog(
onDismissRequest = {
if (!state.isDownloading) {
controller.dismissDialog()
}
},
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 16.dp,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = when {
state.showUnknownSourcesDialog -> "Allow installs to continue"
state.isUpdateAvailable -> state.update?.title ?: "Update available"
else -> "Update status"
},
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when {
state.showUnknownSourcesDialog -> "Enable app installs for Nuvio, then come back and continue."
state.isDownloading -> "Downloading update..."
state.isUpdateAvailable -> "A new version is ready to install."
else -> "No updates found."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
state.update?.let { update ->
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isChecking) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = update.tag,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size ->
"$size${update.assetName}"
} ?: update.assetName
Text(
text = assetLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.isDownloading || state.downloadProgress != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (state.downloadProgress != null) {
"Downloading ${((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100)}%"
} else {
"Preparing download"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (update.notes.isNotBlank()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Release notes",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = update.notes,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(14.dp)
.verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (showPrimaryAction) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
when {
state.showUnknownSourcesDialog -> controller.resumeInstallation()
state.downloadedApkPath != null -> controller.installDownloadedUpdate()
else -> controller.downloadUpdate()
}
},
enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
true
} else {
!state.isChecking && !state.isDownloading && state.isUpdateAvailable
},
) {
Text(
when {
state.showUnknownSourcesDialog -> "Continue"
state.downloadedApkPath != null -> "Install"
state.isDownloading -> "Downloading"
else -> "Update"
},
)
}
}
if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = controller::ignoreThisVersion,
) {
Text("Ignore")
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = controller::dismissDialog,
enabled = !state.isDownloading,
) {
Text(if (state.isDownloading) "Downloading" else "Later")
}
}
} else {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = controller::dismissDialog,
enabled = !state.isDownloading,
) {
Text(if (state.isDownloading) "Downloading" else "Later")
}
}
}
}
}
}
}
private fun formatFileSize(sizeBytes: Long): String {
if (sizeBytes <= 0L) return "0 B"
val units = listOf("B", "KB", "MB", "GB")
var value = sizeBytes.toDouble()
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
val roundedValue = if (value >= 10 || unitIndex == 0) {
value.toInt().toString()
} else {
((value * 10).toInt() / 10.0).toString()
}
return "$roundedValue ${units[unitIndex]}"
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.updater
expect object AppUpdaterPlatform {
val isSupported: Boolean
fun getSupportedAbis(): List<String>
fun getIgnoredTag(): String?
fun setIgnoredTag(tag: String?)
suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String>
fun canRequestPackageInstalls(): Boolean
fun openUnknownSourcesSettings()
fun installDownloadedApk(path: String): Result<Unit>
}

View file

@ -4,6 +4,7 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.releasedPlayableEpisodes import com.nuvio.app.features.watched.releasedPlayableEpisodes
@ -106,7 +107,20 @@ object WatchingActions {
} }
fun onProgressEntryUpdated(entry: WatchProgressEntry) { fun onProgressEntryUpdated(entry: WatchProgressEntry) {
if (!entry.isCompleted || !entry.isEpisode) return if (!entry.isCompleted) return
val watchedItem = WatchedItem(
id = entry.parentMetaId,
type = entry.parentMetaType,
name = entry.title,
poster = entry.poster,
season = entry.seasonNumber,
episode = entry.episodeNumber,
markedAtEpochMs = entry.lastUpdatedEpochMs,
)
WatchedRepository.markWatched(watchedItem)
if (!entry.isEpisode) return
actionScope.launch { actionScope.launch {
val meta = runCatching { val meta = runCatching {
MetaDetailsRepository.fetch( MetaDetailsRepository.fetch(

View file

@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter(
val candidates = sortedEpisodes val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
.drop(1) .drop(1)
.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) } .filter { episode ->
shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber,
candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate,
showUnairedNextUp = false,
)
}
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
} }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain
private const val InProgressStartThresholdFraction = 0.02f private const val InProgressStartThresholdFraction = 0.02f
private const val CompletionThresholdFraction = 0.85 private const val CompletionThresholdFraction = 0.85
private const val InProgressStartThresholdMinMs = 30_000L private const val InProgressStartThresholdMinMs = 30_000L
private const val UpcomingNextSeasonWindowDays = 7
fun watchedKey( fun watchedKey(
content: WatchingContentRef, content: WatchingContentRef,
@ -48,6 +49,81 @@ fun isReleasedBy(
return isoDate <= todayIsoDate return isoDate <= todayIsoDate
} }
internal fun shouldSurfaceNextEpisode(
watchedSeasonNumber: Int?,
candidateSeasonNumber: Int?,
todayIsoDate: String,
releasedDate: String?,
showUnairedNextUp: Boolean,
): Boolean {
val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber)
if (!isSeasonRollover) {
if (showUnairedNextUp) return true
return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)
}
if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) {
return true
}
if (!showUnairedNextUp) {
return false
}
val daysUntilRelease = daysUntilExplicitRelease(
todayIsoDate = todayIsoDate,
releasedDate = releasedDate,
) ?: return false
return daysUntilRelease in 0..UpcomingNextSeasonWindowDays
}
private fun isExplicitlyReleasedBy(
todayIsoDate: String,
releasedDate: String?,
): Boolean {
val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false
return isoDate <= todayIsoDate
}
private fun daysUntilExplicitRelease(
todayIsoDate: String,
releasedDate: String?,
): Int? {
val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null
val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null
return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt()
}
private fun isoCalendarDateOrNull(value: String?): String? {
val datePart = value
?.trim()
?.substringBefore('T')
?.takeIf { it.length == 10 }
?: return null
val parts = datePart.split('-')
if (parts.size != 3) return null
val year = parts[0].toIntOrNull() ?: return null
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null
val normalizedYear = year.toString().padStart(4, '0')
val normalizedMonth = month.toString().padStart(2, '0')
val normalizedDay = day.toString().padStart(2, '0')
return "$normalizedYear-$normalizedMonth-$normalizedDay"
}
private fun isoEpochDay(date: String): Long {
val year = date.substring(0, 4).toLong()
val month = date.substring(5, 7).toLong()
val day = date.substring(8, 10).toLong()
val adjustedYear = year - if (month <= 2L) 1L else 0L
val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
val yearOfEra = adjustedYear - era * 400L
val adjustedMonth = month + if (month > 2L) -3L else 9L
val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
return era * 146_097L + dayOfEra - 719_468L
}
fun releasedEpisodes( fun releasedEpisodes(
episodes: List<WatchingReleasedEpisode>, episodes: List<WatchingReleasedEpisode>,
todayIsoDate: String, todayIsoDate: String,

View file

@ -0,0 +1,55 @@
package com.nuvio.app.features.plugins
import kotlin.test.Test
import kotlin.test.assertEquals
class PluginContentIdsTest {
@Test
fun `series playback id strips season episode suffix`() {
assertEquals(
"tt2575988",
pluginContentId(
videoId = "tt2575988:5:8",
season = 5,
episode = 8,
),
)
}
@Test
fun `tmdb prefixed series playback id strips prefix and suffix`() {
assertEquals(
"12345",
pluginContentId(
videoId = "tmdb:12345:2:6",
season = 2,
episode = 6,
),
)
}
@Test
fun `movie id stays unchanged`() {
assertEquals(
"tt0133093",
pluginContentId(
videoId = "tt0133093",
season = null,
episode = null,
),
)
}
@Test
fun `slash prefixed tmdb id keeps base content id`() {
assertEquals(
"999",
pluginContentId(
videoId = "tmdb/999/1/2",
season = 1,
episode = 2,
),
)
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tmdb.TmdbService
import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc import io.github.jan.supabase.postgrest.rpc
@ -314,10 +315,15 @@ actual object PluginRepository {
season: Int?, season: Int?,
episode: Int?, episode: Int?,
): Result<List<PluginRuntimeResult>> { ): Result<List<PluginRuntimeResult>> {
val resolvedTmdbId = resolvePluginTmdbId(
tmdbId = tmdbId,
mediaType = mediaType,
)
return runCatching { return runCatching {
PluginRuntime.executePlugin( PluginRuntime.executePlugin(
code = scraper.code, code = scraper.code,
tmdbId = tmdbId, tmdbId = resolvedTmdbId,
mediaType = normalizePluginType(mediaType), mediaType = normalizePluginType(mediaType),
season = season, season = season,
episode = episode, episode = episode,
@ -327,6 +333,19 @@ actual object PluginRepository {
} }
} }
private suspend fun resolvePluginTmdbId(
tmdbId: String,
mediaType: String,
): String {
val trimmed = tmdbId.trim()
if (trimmed.isBlank()) return tmdbId
return TmdbService.ensureTmdbId(
videoId = trimmed,
mediaType = mediaType,
) ?: trimmed
}
private suspend fun fetchRepositoryData( private suspend fun fetchRepositoryData(
manifestUrl: String, manifestUrl: String,
previousScrapers: Map<String, PluginScraper>, previousScrapers: Map<String, PluginScraper>,

View file

@ -38,6 +38,7 @@ import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -49,6 +50,10 @@ fun PluginsSettingsPageContent(
} }
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle() val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded()
TmdbSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var repositoryUrl by rememberSaveable { mutableStateOf("") } var repositoryUrl by rememberSaveable { mutableStateOf("") }
@ -61,6 +66,7 @@ fun PluginsSettingsPageContent(
val sortedRepos = remember(uiState.repositories) { val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() } uiState.repositories.sortedBy { it.name.lowercase() }
} }
val hasTmdbApiKey = tmdbSettings.hasApiKey
val repositoryNameByUrl = remember(sortedRepos) { val repositoryNameByUrl = remember(sortedRepos) {
sortedRepos.associate { it.manifestUrl to it.name } sortedRepos.associate { it.manifestUrl to it.name }
} }
@ -88,6 +94,17 @@ fun PluginsSettingsPageContent(
NuvioInfoBadge( NuvioInfoBadge(
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled", text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
) )
NuvioInfoBadge(
text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
)
}
if (!hasTmdbApiKey) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( Row(
@ -355,7 +372,7 @@ fun PluginsSettingsPageContent(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
NuvioPrimaryButton( NuvioPrimaryButton(
text = if (isTestingThisScraper) "Testing..." else "Test Provider", text = if (isTestingThisScraper) "Testing..." else "Test Provider",
enabled = !isTestingThisScraper, enabled = hasTmdbApiKey && !isTestingThisScraper,
onClick = { onClick = {
testingScraperId = scraper.id testingScraperId = scraper.id
coroutineScope.launch { coroutineScope.launch {

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = true
actual val p2pEnabled: Boolean = true
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -1,4 +1,2 @@
CURRENT_PROJECT_VERSION=35
CURRENT_PROJECT_VERSION=29 MARKETING_VERSION=0.1.4
MARKETING_VERSION=0.1.0

View file

@ -2,6 +2,94 @@ import UIKit
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
final class RootComposeViewController: UIViewController {
private let contentController: UIViewController
init(contentController: UIViewController) {
self.contentController = contentController
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
contentController.view.backgroundColor = .black
addChild(contentController)
view.addSubview(contentController.view)
contentController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
contentController.didMove(toParent: self)
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
immersiveController(in: contentController) ?? contentController
}
override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
immersiveController(in: contentController) ?? contentController
}
override var childForStatusBarHidden: UIViewController? {
immersiveController(in: contentController) ?? contentController
}
override var prefersHomeIndicatorAutoHidden: Bool {
immersiveController(in: contentController)?.prefersHomeIndicatorAutoHidden ?? false
}
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
immersiveController(in: contentController)?.preferredScreenEdgesDeferringSystemGestures ?? []
}
override var prefersStatusBarHidden: Bool {
immersiveController(in: contentController)?.prefersStatusBarHidden ?? false
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.fade
}
func refreshImmersiveSystemUI() {
setNeedsUpdateOfHomeIndicatorAutoHidden()
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
setNeedsStatusBarAppearanceUpdate()
}
private func immersiveController(in controller: UIViewController?) -> UIViewController? {
guard let controller else { return nil }
if controller.prefersHomeIndicatorAutoHidden ||
!controller.preferredScreenEdgesDeferringSystemGestures.isEmpty ||
controller.prefersStatusBarHidden {
return controller
}
if let presented = immersiveController(in: controller.presentedViewController) {
return presented
}
for child in controller.children.reversed() {
if let immersiveChild = immersiveController(in: child) {
return immersiveChild
}
}
return nil
}
}
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController { func makeUIViewController(context: Context) -> UIViewController {
// Register MPV player bridge before Compose initializes // Register MPV player bridge before Compose initializes
@ -9,7 +97,7 @@ struct ComposeView: UIViewControllerRepresentable {
let controller = MainViewControllerKt.MainViewController() let controller = MainViewControllerKt.MainViewController()
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
return controller return RootComposeViewController(contentController: controller)
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}

View file

@ -167,6 +167,22 @@ final class MPVPlayerViewController: UIViewController {
} }
private var _currentErrorMessage: String? private var _currentErrorMessage: String?
override var prefersHomeIndicatorAutoHidden: Bool {
true
}
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.bottom, .left, .right]
}
override var prefersStatusBarHidden: Bool {
true
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.fade
}
// MARK: - Lifecycle // MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
@ -181,6 +197,12 @@ final class MPVPlayerViewController: UIViewController {
setupMpv() setupMpv()
setupNotifications() setupNotifications()
refreshImmersiveSystemUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshImmersiveSystemUI()
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
@ -188,6 +210,16 @@ final class MPVPlayerViewController: UIViewController {
metalLayer.frame = view.bounds metalLayer.frame = view.bounds
} }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
refreshImmersiveSystemUI()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
refreshImmersiveSystemUI()
}
// MARK: - MPV Setup // MARK: - MPV Setup
private func setupMpv() { private func setupMpv() {
@ -203,12 +235,19 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) checkError(mpv_set_option_string(mpv, "hwdec", "auto"))
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no"))
checkError(mpv_set_option_string(mpv, "vulkan-async-transfer", "no"))
checkError(mpv_set_option_string(mpv, "vulkan-disable-interop", "yes"))
checkError(mpv_set_option_string(mpv, "video-rotate", "no")) checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "keep-open", "yes")) checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
checkError(mpv_set_option_string(mpv, "tone-mapping", "auto"))
checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no"))
checkError(mpv_initialize(mpv)) checkError(mpv_initialize(mpv))
@ -626,6 +665,23 @@ final class MPVPlayerViewController: UIViewController {
.joined(separator: ",") .joined(separator: ",")
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized)) checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
} }
private func refreshImmersiveSystemUI() {
setNeedsUpdateOfHomeIndicatorAutoHidden()
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
setNeedsStatusBarAppearanceUpdate()
var currentParent = parent
while let controller = currentParent {
controller.setNeedsUpdateOfHomeIndicatorAutoHidden()
controller.setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
controller.setNeedsStatusBarAppearanceUpdate()
if let rootController = controller as? RootComposeViewController {
rootController.refreshImmersiveSystemUI()
}
currentParent = controller.parent
}
}
} }
// MARK: - Bridge Creator (implements Kotlin protocol) // MARK: - Bridge Creator (implements Kotlin protocol)