mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge cmp-rewrite into desktop
This commit is contained in:
commit
1cc4aa698c
51 changed files with 2619 additions and 125 deletions
218
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
218
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
83
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
47
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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 -->
|
||||
83
.github/workflows/close-unlabeled-issues.yml
vendored
Normal file
83
.github/workflows/close-unlabeled-issues.yml
vendored
Normal 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
74
.github/workflows/pr-template-check.yml
vendored
Normal 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
70
.github/workflows/stale-needs-info.yml
vendored
Normal 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
154
.github/workflows/triage-needs-info.yml
vendored
Normal 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
2
.gitignore
vendored
|
|
@ -2,6 +2,8 @@
|
|||
.kotlin
|
||||
.gradle
|
||||
**/build/
|
||||
!composeApp/src/**/kotlin/com/nuvio/app/core/build/
|
||||
!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
|
||||
xcuserdata
|
||||
!src/**/build/
|
||||
local.properties
|
||||
|
|
|
|||
116
CONTRIBUTING.md
Normal file
116
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -386,6 +386,7 @@ android {
|
|||
}
|
||||
}
|
||||
sourceSets.getByName("full") {
|
||||
manifest.srcFile("src/androidFull/AndroidManifest.xml")
|
||||
java.srcDir(fullCommonSourceDir)
|
||||
}
|
||||
packaging {
|
||||
|
|
|
|||
18
composeApp/src/androidFull/AndroidManifest.xml
Normal file
18
composeApp/src/androidFull/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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." }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="nuvio_updates"
|
||||
path="updates/" />
|
||||
</paths>
|
||||
|
|
@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
|
|||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||
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.features.watched.WatchedStorage
|
||||
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
||||
|
|
@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
|
|||
DownloadsStorage.initialize(applicationContext)
|
||||
DownloadsPlatformDownloader.initialize(applicationContext)
|
||||
DownloadsLiveStatusPlatform.initialize(applicationContext)
|
||||
AndroidAppUpdaterPlatform.initialize(applicationContext)
|
||||
PlatformLocalAccountDataCleaner.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.bindActivity(this)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AndroidAppUpdaterPlatform {
|
||||
fun initialize(context: Context) = Unit
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -154,6 +154,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
|
|||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||
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.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -442,6 +444,7 @@ private fun MainAppContent(
|
|||
onSwitchProfile: () -> Unit = {},
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val appUpdaterController = rememberAppUpdaterController()
|
||||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
|
|
@ -960,6 +963,16 @@ private fun MainAppContent(
|
|||
onSupportersContributorsSettingsClick = {
|
||||
navController.navigate(SupportersContributorsSettingsRoute)
|
||||
},
|
||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||
{
|
||||
appUpdaterController.checkForUpdates(
|
||||
force = true,
|
||||
showNoUpdateFeedback = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
|
|
@ -1797,6 +1810,13 @@ private fun MainAppContent(
|
|||
.align(Alignment.TopCenter)
|
||||
.zIndex(20f),
|
||||
)
|
||||
|
||||
AppUpdaterHost(
|
||||
controller = appUpdaterController,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.zIndex(25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1840,6 +1860,7 @@ private fun AppTabHost(
|
|||
onPluginsSettingsClick: () -> Unit = {},
|
||||
onAccountSettingsClick: () -> Unit = {},
|
||||
onSupportersContributorsSettingsClick: () -> Unit = {},
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onCollectionsSettingsClick: () -> Unit = {},
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||
onInitialHomeContentRendered: () -> Unit = {},
|
||||
|
|
@ -1890,6 +1911,7 @@ private fun AppTabHost(
|
|||
onPluginsClick = onPluginsSettingsClick,
|
||||
onAccountClick = onAccountSettingsClick,
|
||||
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onCollectionsClick = onCollectionsSettingsClick,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
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.home.HomeCatalogParser
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
|
|
@ -122,21 +123,19 @@ internal fun buildCatalogUrl(
|
|||
search: String?,
|
||||
skip: Int?,
|
||||
): String {
|
||||
val baseUrl = manifestUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
|
||||
val extraParts = buildList {
|
||||
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
|
||||
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
|
||||
if (skip != null && skip > 0) add("skip=$skip")
|
||||
}
|
||||
|
||||
return if (extraParts.isEmpty()) {
|
||||
"$baseUrl/catalog/$type/$catalogId.json"
|
||||
} else {
|
||||
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json"
|
||||
}
|
||||
return buildAddonResourceUrl(
|
||||
manifestUrl = manifestUrl,
|
||||
resource = "catalog",
|
||||
type = type,
|
||||
id = catalogId,
|
||||
extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.encodeCatalogExtra(): String =
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.details
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.AddonManifest
|
||||
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.mdblist.MdbListMetadataService
|
||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||
|
|
@ -217,10 +218,12 @@ object MetaDetailsRepository {
|
|||
id: String,
|
||||
includeMdbList: Boolean,
|
||||
): MetaDetails? {
|
||||
val baseUrl = manifest.transportUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
val url = "$baseUrl/meta/$type/$id.json"
|
||||
val url = buildAddonResourceUrl(
|
||||
manifestUrl = manifest.transportUrl,
|
||||
resource = "meta",
|
||||
type = type,
|
||||
id = id,
|
||||
)
|
||||
|
||||
return try {
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
|
|
|
|||
|
|
@ -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.playLabel
|
||||
import com.nuvio.app.features.watching.domain.resumeLabel
|
||||
import com.nuvio.app.features.watching.domain.shouldSurfaceNextEpisode
|
||||
import com.nuvio.app.features.watching.domain.upNextLabel
|
||||
|
||||
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
|
||||
|
|
@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
|||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
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? {
|
||||
val sortedEpisodes = sortedPlayableEpisodes()
|
||||
val watchedVideoId = buildPlaybackVideoId(
|
||||
|
|
@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
|||
}
|
||||
.drop(1)
|
||||
.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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
|
|||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.filterUnavailableFutureSeasons
|
||||
import com.nuvio.app.features.details.sortedPlayableEpisodes
|
||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
||||
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.watching.application.WatchingState
|
||||
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.home.components.HomeCollectionRowSection
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
|
|||
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 =
|
||||
isNextUp || progressFraction < 0.995f
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
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.material.icons.Icons
|
||||
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.Speed
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -61,6 +65,8 @@ internal fun PlayerControlsShell(
|
|||
displayedPositionMs: Long,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
resizeMode: PlayerResizeMode,
|
||||
isLocked: Boolean,
|
||||
onLockToggle: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekBack: () -> Unit,
|
||||
|
|
@ -120,6 +126,8 @@ internal fun PlayerControlsShell(
|
|||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
metrics = metrics,
|
||||
isLocked = isLocked,
|
||||
onLockToggle = onLockToggle,
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
|
|
@ -175,6 +183,8 @@ private fun PlayerHeader(
|
|||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
isLocked: Boolean,
|
||||
onLockToggle: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -240,18 +250,55 @@ private fun PlayerHeader(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
containerColor = Color.Black.copy(alpha = 0.35f),
|
||||
contentColor = Color.White,
|
||||
buttonSize = metrics.headerIconSize + 16.dp,
|
||||
iconSize = metrics.headerIconSize,
|
||||
contentDescription = "Close player",
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayerHeaderIconButton(
|
||||
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||
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
|
||||
private fun CenterControls(
|
||||
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
|
||||
private fun TimePill(
|
||||
text: String,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import kotlin.math.roundToInt
|
|||
private const val PlaybackProgressPersistIntervalMs = 60_000L
|
||||
private const val PlayerDoubleTapSeekStepMs = 10_000L
|
||||
private const val PlayerDoubleTapSeekResetDelayMs = 800L
|
||||
private const val PlayerLockedOverlayDurationMs = 2_000L
|
||||
private const val PlayerLeftGestureBoundary = 0.4f
|
||||
private const val PlayerRightGestureBoundary = 0.6f
|
||||
private const val PlayerVerticalGestureSensitivity = 1f
|
||||
|
|
@ -154,6 +155,7 @@ fun PlayerScreen(
|
|||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val gestureController = rememberPlayerGestureController()
|
||||
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
||||
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
||||
// Active playback state (mutable to support source/episode switching)
|
||||
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
|
||||
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
|
||||
|
|
@ -189,6 +191,7 @@ fun PlayerScreen(
|
|||
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
||||
var liveGestureFeedback 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 accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
|
||||
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
|
||||
|
|
@ -497,6 +500,35 @@ fun PlayerScreen(
|
|||
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) {
|
||||
val seconds = amountMs / 1000L
|
||||
if (seconds <= 0L) return
|
||||
|
|
@ -659,6 +691,10 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
val onSurfaceTap = rememberUpdatedState { offset: Offset ->
|
||||
if (playerControlsLocked) {
|
||||
revealLockedOverlay()
|
||||
return@rememberUpdatedState
|
||||
}
|
||||
val centerStart = layoutSize.width * PlayerLeftGestureBoundary
|
||||
val centerEnd = layoutSize.width * PlayerRightGestureBoundary
|
||||
if (controlsVisible && offset.x in centerStart..centerEnd) {
|
||||
|
|
@ -668,6 +704,10 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
|
||||
if (playerControlsLocked) {
|
||||
revealLockedOverlay()
|
||||
return@rememberUpdatedState
|
||||
}
|
||||
when {
|
||||
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
|
||||
handleDoubleTapSeek(PlayerSeekDirection.Backward)
|
||||
|
|
@ -686,7 +726,9 @@ fun PlayerScreen(
|
|||
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
|
||||
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
|
||||
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
|
||||
val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
|
||||
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
|
||||
val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
|
||||
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
|
||||
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
|
||||
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
|
||||
|
|
@ -1002,6 +1044,7 @@ fun PlayerScreen(
|
|||
scrubbingPositionMs = null
|
||||
liveGestureFeedback = null
|
||||
renderedGestureFeedback = null
|
||||
lockedOverlayVisible = false
|
||||
initialLoadCompleted = false
|
||||
lastProgressPersistEpochMs = 0L
|
||||
previousIsPlaying = false
|
||||
|
|
@ -1120,6 +1163,14 @@ fun PlayerScreen(
|
|||
controlsVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
|
||||
if (!playerControlsLocked || !lockedOverlayVisible) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(PlayerLockedOverlayDurationMs)
|
||||
lockedOverlayVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
|
||||
pausedOverlayVisible = false
|
||||
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
|
||||
|
|
@ -1325,12 +1376,27 @@ fun PlayerScreen(
|
|||
},
|
||||
onTap = { offset -> onSurfaceTap.value(offset) },
|
||||
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
|
||||
onLongPress = { activateHoldToSpeedState.value() },
|
||||
onLongPress = {
|
||||
if (playerControlsLockedState.value) {
|
||||
revealLockedOverlayState.value()
|
||||
} else {
|
||||
activateHoldToSpeedState.value()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.pointerInput(gestureController, layoutSize) {
|
||||
awaitEachGesture {
|
||||
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 width = size.width.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) {
|
||||
shouldPlay = false
|
||||
controlsVisible = true
|
||||
controlsVisible = !playerControlsLocked
|
||||
}
|
||||
},
|
||||
onError = { message ->
|
||||
errorMessage = message
|
||||
if (message != null) {
|
||||
controlsVisible = true
|
||||
controlsVisible = !playerControlsLocked
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (pausedOverlayVisible && !controlsVisible) {
|
||||
if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) {
|
||||
PauseMetadataOverlay(
|
||||
title = title,
|
||||
logo = logo,
|
||||
|
|
@ -1619,7 +1685,7 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible,
|
||||
visible = controlsVisible && !playerControlsLocked,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
|
|
@ -1634,6 +1700,14 @@ fun PlayerScreen(
|
|||
displayedPositionMs = displayedPositionMs,
|
||||
metrics = metrics,
|
||||
resizeMode = resizeMode,
|
||||
isLocked = playerControlsLocked,
|
||||
onLockToggle = {
|
||||
if (playerControlsLocked) {
|
||||
unlockPlayerControls()
|
||||
} else {
|
||||
lockPlayerControls()
|
||||
}
|
||||
},
|
||||
onBack = onBackWithProgress,
|
||||
onTogglePlayback = ::togglePlayback,
|
||||
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(
|
||||
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
|
||||
enter = fadeIn(),
|
||||
|
|
@ -1697,23 +1786,25 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
// Skip intro/recap/outro button
|
||||
SkipIntroButton(
|
||||
interval = activeSkipInterval,
|
||||
dismissed = skipIntervalDismissed,
|
||||
controlsVisible = controlsVisible,
|
||||
onSkip = {
|
||||
val interval = activeSkipInterval ?: return@SkipIntroButton
|
||||
playerController?.seekTo((interval.endTime * 1000).toLong())
|
||||
skipIntervalDismissed = true
|
||||
},
|
||||
onDismiss = { skipIntervalDismissed = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
|
||||
)
|
||||
if (!playerControlsLocked) {
|
||||
SkipIntroButton(
|
||||
interval = activeSkipInterval,
|
||||
dismissed = skipIntervalDismissed,
|
||||
controlsVisible = controlsVisible,
|
||||
onSkip = {
|
||||
val interval = activeSkipInterval ?: return@SkipIntroButton
|
||||
playerController?.seekTo((interval.endTime * 1000).toLong())
|
||||
skipIntervalDismissed = true
|
||||
},
|
||||
onDismiss = { skipIntervalDismissed = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
|
||||
)
|
||||
}
|
||||
|
||||
// Next episode card
|
||||
if (isSeries) {
|
||||
if (isSeries && !playerControlsLocked) {
|
||||
NextEpisodeCard(
|
||||
nextEpisode = nextEpisodeInfo,
|
||||
visible = showNextEpisodeCard,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ package com.nuvio.app.features.player
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
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.details.MetaDetailsRepository
|
||||
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.PluginScraper
|
||||
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||
|
|
@ -215,11 +217,12 @@ object PlayerStreamsRepository {
|
|||
val job = scope.launch {
|
||||
val addonJobs = streamAddons.map { addon ->
|
||||
async {
|
||||
val encodedId = videoId.replace("%", "%25").replace(" ", "%20")
|
||||
val baseUrl = addon.manifest.transportUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
val url = "$baseUrl/stream/$type/$encodedId.json"
|
||||
val url = buildAddonResourceUrl(
|
||||
manifestUrl = addon.manifest.transportUrl,
|
||||
resource = "stream",
|
||||
type = type,
|
||||
id = videoId,
|
||||
)
|
||||
|
||||
val displayName = addon.addonName
|
||||
runCatching {
|
||||
|
|
@ -241,7 +244,11 @@ object PlayerStreamsRepository {
|
|||
async {
|
||||
PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = videoId.toPluginTmdbId(),
|
||||
tmdbId = pluginContentId(
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
),
|
||||
mediaType = type,
|
||||
season = season,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -49,8 +50,12 @@ object SubtitleRepository {
|
|||
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
|
||||
if (!prefixMatch) continue
|
||||
|
||||
val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json")
|
||||
val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json"
|
||||
val subtitleUrl = buildAddonResourceUrl(
|
||||
manifestUrl = manifest.transportUrl,
|
||||
resource = "subtitles",
|
||||
type = type,
|
||||
id = videoId,
|
||||
)
|
||||
|
||||
try {
|
||||
val response = withContext(Dispatchers.Default) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ internal fun LazyListScope.settingsRootContent(
|
|||
onIntegrationsClick: () -> Unit,
|
||||
onTraktClick: () -> Unit,
|
||||
onSupportersContributorsClick: () -> Unit,
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onDownloadsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onSwitchProfileClick: (() -> Unit)? = null,
|
||||
|
|
@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
|
|||
isTablet = isTablet,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ fun SettingsScreen(
|
|||
onDownloadsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onSupportersContributorsClick: () -> Unit = {},
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
|
|
@ -190,6 +191,7 @@ fun SettingsScreen(
|
|||
onSwitchProfile = onSwitchProfile,
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -233,6 +235,7 @@ fun SettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
|
|||
onDownloadsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onSupportersContributorsClick: () -> Unit = {},
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
NuvioScreen {
|
||||
|
|
@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
|
|||
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
||||
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
|
|
@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
|
|||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onDownloadsClick: () -> Unit = {},
|
||||
onSupportersContributorsClick: () -> Unit = {},
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
|
||||
|
|
@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
|
|||
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
||||
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
||||
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ package com.nuvio.app.features.streams
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
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.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
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.PluginRepositoryItem
|
||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||
|
|
@ -237,11 +239,12 @@ object StreamsRepository {
|
|||
|
||||
streamAddons.forEach { addon ->
|
||||
launch {
|
||||
val encodedId = videoId.encodeForPath()
|
||||
val baseUrl = addon.manifest.transportUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
val url = "$baseUrl/stream/$type/$encodedId.json"
|
||||
val url = buildAddonResourceUrl(
|
||||
manifestUrl = addon.manifest.transportUrl,
|
||||
resource = "stream",
|
||||
type = type,
|
||||
id = videoId,
|
||||
)
|
||||
log.d { "Fetching streams from: $url" }
|
||||
|
||||
val displayName = addon.addonName
|
||||
|
|
@ -283,7 +286,11 @@ object StreamsRepository {
|
|||
launch {
|
||||
val completion = PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = videoId.toPluginTmdbId(),
|
||||
tmdbId = pluginContentId(
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
),
|
||||
mediaType = type,
|
||||
season = season,
|
||||
episode = episode,
|
||||
|
|
@ -420,10 +427,6 @@ object StreamsRepository {
|
|||
activeRequestKey = null
|
||||
_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(
|
||||
|
|
@ -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(
|
||||
scraper: PluginScraper,
|
||||
addonName: String = scraper.name,
|
||||
|
|
|
|||
|
|
@ -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]}"
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.nuvio.app.features.details.MetaDetails
|
|||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
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.episodePlaybackId
|
||||
import com.nuvio.app.features.watched.releasedPlayableEpisodes
|
||||
|
|
@ -106,7 +107,20 @@ object WatchingActions {
|
|||
}
|
||||
|
||||
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 {
|
||||
val meta = runCatching {
|
||||
MetaDetailsRepository.fetch(
|
||||
|
|
|
|||
|
|
@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter(
|
|||
val candidates = sortedEpisodes
|
||||
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
|
||||
.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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain
|
|||
private const val InProgressStartThresholdFraction = 0.02f
|
||||
private const val CompletionThresholdFraction = 0.85
|
||||
private const val InProgressStartThresholdMinMs = 30_000L
|
||||
private const val UpcomingNextSeasonWindowDays = 7
|
||||
|
||||
fun watchedKey(
|
||||
content: WatchingContentRef,
|
||||
|
|
@ -48,6 +49,81 @@ fun isReleasedBy(
|
|||
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(
|
||||
episodes: List<WatchingReleasedEpisode>,
|
||||
todayIsoDate: String,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
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.query.Order
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
|
|
@ -314,10 +315,15 @@ actual object PluginRepository {
|
|||
season: Int?,
|
||||
episode: Int?,
|
||||
): Result<List<PluginRuntimeResult>> {
|
||||
val resolvedTmdbId = resolvePluginTmdbId(
|
||||
tmdbId = tmdbId,
|
||||
mediaType = mediaType,
|
||||
)
|
||||
|
||||
return runCatching {
|
||||
PluginRuntime.executePlugin(
|
||||
code = scraper.code,
|
||||
tmdbId = tmdbId,
|
||||
tmdbId = resolvedTmdbId,
|
||||
mediaType = normalizePluginType(mediaType),
|
||||
season = season,
|
||||
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(
|
||||
manifestUrl: String,
|
||||
previousScrapers: Map<String, PluginScraper>,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import com.nuvio.app.core.ui.NuvioInputField
|
|||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -49,6 +50,10 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
|
||||
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
|
||||
val tmdbSettings by remember {
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
TmdbSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var repositoryUrl by rememberSaveable { mutableStateOf("") }
|
||||
|
|
@ -61,6 +66,7 @@ fun PluginsSettingsPageContent(
|
|||
val sortedRepos = remember(uiState.repositories) {
|
||||
uiState.repositories.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
val hasTmdbApiKey = tmdbSettings.hasApiKey
|
||||
val repositoryNameByUrl = remember(sortedRepos) {
|
||||
sortedRepos.associate { it.manifestUrl to it.name }
|
||||
}
|
||||
|
|
@ -88,6 +94,17 @@ fun PluginsSettingsPageContent(
|
|||
NuvioInfoBadge(
|
||||
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))
|
||||
Row(
|
||||
|
|
@ -355,7 +372,7 @@ fun PluginsSettingsPageContent(
|
|||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
||||
enabled = !isTestingThisScraper,
|
||||
enabled = hasTmdbApiKey && !isTestingThisScraper,
|
||||
onClick = {
|
||||
testingScraperId = scraper.id
|
||||
coroutineScope.launch {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -1,4 +1,2 @@
|
|||
|
||||
CURRENT_PROJECT_VERSION=29
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
CURRENT_PROJECT_VERSION=35
|
||||
MARKETING_VERSION=0.1.4
|
||||
|
|
|
|||
|
|
@ -2,6 +2,94 @@ import UIKit
|
|||
import SwiftUI
|
||||
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 {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
// Register MPV player bridge before Compose initializes
|
||||
|
|
@ -9,7 +97,7 @@ struct ComposeView: UIViewControllerRepresentable {
|
|||
|
||||
let controller = MainViewControllerKt.MainViewController()
|
||||
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) {}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,22 @@ final class MPVPlayerViewController: UIViewController {
|
|||
}
|
||||
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
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
|
@ -181,6 +197,12 @@ final class MPVPlayerViewController: UIViewController {
|
|||
|
||||
setupMpv()
|
||||
setupNotifications()
|
||||
refreshImmersiveSystemUI()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
refreshImmersiveSystemUI()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
|
|
@ -188,6 +210,16 @@ final class MPVPlayerViewController: UIViewController {
|
|||
metalLayer.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
refreshImmersiveSystemUI()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
refreshImmersiveSystemUI()
|
||||
}
|
||||
|
||||
// MARK: - MPV Setup
|
||||
|
||||
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, "gpu-api", "vulkan"))
|
||||
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, "subs-match-os-language", "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, "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))
|
||||
|
||||
|
|
@ -626,6 +665,23 @@ final class MPVPlayerViewController: UIViewController {
|
|||
.joined(separator: ",")
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue