mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +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
|
.kotlin
|
||||||
.gradle
|
.gradle
|
||||||
**/build/
|
**/build/
|
||||||
|
!composeApp/src/**/kotlin/com/nuvio/app/core/build/
|
||||||
|
!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
|
||||||
xcuserdata
|
xcuserdata
|
||||||
!src/**/build/
|
!src/**/build/
|
||||||
local.properties
|
local.properties
|
||||||
|
|
|
||||||
116
CONTRIBUTING.md
Normal file
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") {
|
sourceSets.getByName("full") {
|
||||||
|
manifest.srcFile("src/androidFull/AndroidManifest.xml")
|
||||||
java.srcDir(fullCommonSourceDir)
|
java.srcDir(fullCommonSourceDir)
|
||||||
}
|
}
|
||||||
packaging {
|
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.TraktCommentsStorage
|
||||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||||
|
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||||
import com.nuvio.app.features.watched.WatchedStorage
|
import com.nuvio.app.features.watched.WatchedStorage
|
||||||
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
||||||
|
|
@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
|
||||||
DownloadsStorage.initialize(applicationContext)
|
DownloadsStorage.initialize(applicationContext)
|
||||||
DownloadsPlatformDownloader.initialize(applicationContext)
|
DownloadsPlatformDownloader.initialize(applicationContext)
|
||||||
DownloadsLiveStatusPlatform.initialize(applicationContext)
|
DownloadsLiveStatusPlatform.initialize(applicationContext)
|
||||||
|
AndroidAppUpdaterPlatform.initialize(applicationContext)
|
||||||
PlatformLocalAccountDataCleaner.initialize(applicationContext)
|
PlatformLocalAccountDataCleaner.initialize(applicationContext)
|
||||||
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
|
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
|
||||||
EpisodeReleaseNotificationPlatform.bindActivity(this)
|
EpisodeReleaseNotificationPlatform.bindActivity(this)
|
||||||
|
|
|
||||||
|
|
@ -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.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||||
import com.nuvio.app.features.trakt.TraktListTab
|
import com.nuvio.app.features.trakt.TraktListTab
|
||||||
|
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||||
|
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
|
|
@ -442,6 +444,7 @@ private fun MainAppContent(
|
||||||
onSwitchProfile: () -> Unit = {},
|
onSwitchProfile: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
val appUpdaterController = rememberAppUpdaterController()
|
||||||
remember {
|
remember {
|
||||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||||
}
|
}
|
||||||
|
|
@ -960,6 +963,16 @@ private fun MainAppContent(
|
||||||
onSupportersContributorsSettingsClick = {
|
onSupportersContributorsSettingsClick = {
|
||||||
navController.navigate(SupportersContributorsSettingsRoute)
|
navController.navigate(SupportersContributorsSettingsRoute)
|
||||||
},
|
},
|
||||||
|
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||||
|
{
|
||||||
|
appUpdaterController.checkForUpdates(
|
||||||
|
force = true,
|
||||||
|
showNoUpdateFeedback = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||||
onFolderClick = { collectionId, folderId ->
|
onFolderClick = { collectionId, folderId ->
|
||||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||||
|
|
@ -1797,6 +1810,13 @@ private fun MainAppContent(
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.zIndex(20f),
|
.zIndex(20f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AppUpdaterHost(
|
||||||
|
controller = appUpdaterController,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.zIndex(25f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1840,6 +1860,7 @@ private fun AppTabHost(
|
||||||
onPluginsSettingsClick: () -> Unit = {},
|
onPluginsSettingsClick: () -> Unit = {},
|
||||||
onAccountSettingsClick: () -> Unit = {},
|
onAccountSettingsClick: () -> Unit = {},
|
||||||
onSupportersContributorsSettingsClick: () -> Unit = {},
|
onSupportersContributorsSettingsClick: () -> Unit = {},
|
||||||
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsSettingsClick: () -> Unit = {},
|
onCollectionsSettingsClick: () -> Unit = {},
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||||
onInitialHomeContentRendered: () -> Unit = {},
|
onInitialHomeContentRendered: () -> Unit = {},
|
||||||
|
|
@ -1890,6 +1911,7 @@ private fun AppTabHost(
|
||||||
onPluginsClick = onPluginsSettingsClick,
|
onPluginsClick = onPluginsSettingsClick,
|
||||||
onAccountClick = onAccountSettingsClick,
|
onAccountClick = onAccountSettingsClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsSettingsClick,
|
onCollectionsClick = onCollectionsSettingsClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.nuvio.app.features.catalog
|
||||||
|
|
||||||
import com.nuvio.app.features.addons.AddonCatalog
|
import com.nuvio.app.features.addons.AddonCatalog
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.home.HomeCatalogParser
|
import com.nuvio.app.features.home.HomeCatalogParser
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
|
@ -122,21 +123,19 @@ internal fun buildCatalogUrl(
|
||||||
search: String?,
|
search: String?,
|
||||||
skip: Int?,
|
skip: Int?,
|
||||||
): String {
|
): String {
|
||||||
val baseUrl = manifestUrl
|
|
||||||
.substringBefore("?")
|
|
||||||
.removeSuffix("/manifest.json")
|
|
||||||
|
|
||||||
val extraParts = buildList {
|
val extraParts = buildList {
|
||||||
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
|
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
|
||||||
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
|
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
|
||||||
if (skip != null && skip > 0) add("skip=$skip")
|
if (skip != null && skip > 0) add("skip=$skip")
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (extraParts.isEmpty()) {
|
return buildAddonResourceUrl(
|
||||||
"$baseUrl/catalog/$type/$catalogId.json"
|
manifestUrl = manifestUrl,
|
||||||
} else {
|
resource = "catalog",
|
||||||
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json"
|
type = type,
|
||||||
}
|
id = catalogId,
|
||||||
|
extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.encodeCatalogExtra(): String =
|
private fun String.encodeCatalogExtra(): String =
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.details
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonManifest
|
import com.nuvio.app.features.addons.AddonManifest
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||||
|
|
@ -217,10 +218,12 @@ object MetaDetailsRepository {
|
||||||
id: String,
|
id: String,
|
||||||
includeMdbList: Boolean,
|
includeMdbList: Boolean,
|
||||||
): MetaDetails? {
|
): MetaDetails? {
|
||||||
val baseUrl = manifest.transportUrl
|
val url = buildAddonResourceUrl(
|
||||||
.substringBefore("?")
|
manifestUrl = manifest.transportUrl,
|
||||||
.removeSuffix("/manifest.json")
|
resource = "meta",
|
||||||
val url = "$baseUrl/meta/$type/$id.json"
|
type = type,
|
||||||
|
id = id,
|
||||||
|
)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||||
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
||||||
import com.nuvio.app.features.watching.domain.playLabel
|
import com.nuvio.app.features.watching.domain.playLabel
|
||||||
import com.nuvio.app.features.watching.domain.resumeLabel
|
import com.nuvio.app.features.watching.domain.resumeLabel
|
||||||
|
import com.nuvio.app.features.watching.domain.shouldSurfaceNextEpisode
|
||||||
import com.nuvio.app.features.watching.domain.upNextLabel
|
import com.nuvio.app.features.watching.domain.upNextLabel
|
||||||
|
|
||||||
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
|
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
|
||||||
|
|
@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
seasonNumber: Int?,
|
seasonNumber: Int?,
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
todayIsoDate: String,
|
todayIsoDate: String,
|
||||||
|
): MetaVideo? {
|
||||||
|
return nextReleasedEpisodeAfter(
|
||||||
|
seasonNumber = seasonNumber,
|
||||||
|
episodeNumber = episodeNumber,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
showUnairedNextUp = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
|
seasonNumber: Int?,
|
||||||
|
episodeNumber: Int?,
|
||||||
|
todayIsoDate: String,
|
||||||
|
showUnairedNextUp: Boolean,
|
||||||
): MetaVideo? {
|
): MetaVideo? {
|
||||||
val sortedEpisodes = sortedPlayableEpisodes()
|
val sortedEpisodes = sortedPlayableEpisodes()
|
||||||
val watchedVideoId = buildPlaybackVideoId(
|
val watchedVideoId = buildPlaybackVideoId(
|
||||||
|
|
@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
}
|
}
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.filter { episode ->
|
.filter { episode ->
|
||||||
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
|
shouldSurfaceNextEpisode(
|
||||||
|
watchedSeasonNumber = seasonNumber,
|
||||||
|
candidateSeasonNumber = episode.season,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
releasedDate = episode.released,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
|
return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.filterUnavailableFutureSeasons
|
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||||
import com.nuvio.app.features.details.sortedPlayableEpisodes
|
|
||||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||||
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
|
|
@ -45,10 +44,8 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
|
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
|
||||||
isCompleted = true,
|
isCompleted = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter(
|
|
||||||
seasonNumber: Int?,
|
|
||||||
episodeNumber: Int?,
|
|
||||||
todayIsoDate: String,
|
|
||||||
showUnairedNextUp: Boolean,
|
|
||||||
): com.nuvio.app.features.details.MetaVideo? {
|
|
||||||
val content = WatchingContentRef(type = type, id = id)
|
|
||||||
val watchedVideoId = buildPlaybackVideoId(
|
|
||||||
content = content,
|
|
||||||
seasonNumber = seasonNumber,
|
|
||||||
episodeNumber = episodeNumber,
|
|
||||||
)
|
|
||||||
|
|
||||||
val ordered = sortedPlayableEpisodes()
|
|
||||||
.dropWhile { episode ->
|
|
||||||
buildPlaybackVideoId(
|
|
||||||
content = content,
|
|
||||||
seasonNumber = episode.season,
|
|
||||||
episodeNumber = episode.episode,
|
|
||||||
fallbackVideoId = episode.id,
|
|
||||||
) != watchedVideoId
|
|
||||||
}
|
|
||||||
.drop(1)
|
|
||||||
.filter { episode -> (episode.season ?: 0) > 0 }
|
|
||||||
.filterUnavailableFutureSeasons(todayIsoDate = todayIsoDate)
|
|
||||||
|
|
||||||
if (showUnairedNextUp) {
|
|
||||||
return ordered.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordered.firstOrNull { episode ->
|
|
||||||
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
|
private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
|
||||||
isNextUp || progressFraction < 0.995f
|
isNextUp || progressFraction < 0.995f
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -21,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Forward10
|
import androidx.compose.material.icons.rounded.Forward10
|
||||||
|
import androidx.compose.material.icons.rounded.Lock
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
import androidx.compose.material.icons.rounded.Replay10
|
import androidx.compose.material.icons.rounded.Replay10
|
||||||
import androidx.compose.material.icons.rounded.Speed
|
import androidx.compose.material.icons.rounded.Speed
|
||||||
import androidx.compose.material.icons.rounded.SwapHoriz
|
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||||
|
|
@ -29,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -61,6 +65,8 @@ internal fun PlayerControlsShell(
|
||||||
displayedPositionMs: Long,
|
displayedPositionMs: Long,
|
||||||
metrics: PlayerLayoutMetrics,
|
metrics: PlayerLayoutMetrics,
|
||||||
resizeMode: PlayerResizeMode,
|
resizeMode: PlayerResizeMode,
|
||||||
|
isLocked: Boolean,
|
||||||
|
onLockToggle: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onTogglePlayback: () -> Unit,
|
onTogglePlayback: () -> Unit,
|
||||||
onSeekBack: () -> Unit,
|
onSeekBack: () -> Unit,
|
||||||
|
|
@ -120,6 +126,8 @@ internal fun PlayerControlsShell(
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
|
isLocked = isLocked,
|
||||||
|
onLockToggle = onLockToggle,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
|
|
@ -175,6 +183,8 @@ private fun PlayerHeader(
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
metrics: PlayerLayoutMetrics,
|
metrics: PlayerLayoutMetrics,
|
||||||
|
isLocked: Boolean,
|
||||||
|
onLockToggle: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -240,6 +250,17 @@ private fun PlayerHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
NuvioBackButton(
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
containerColor = Color.Black.copy(alpha = 0.35f),
|
containerColor = Color.Black.copy(alpha = 0.35f),
|
||||||
|
|
@ -250,6 +271,32 @@ private fun PlayerHeader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerHeaderIconButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
buttonSize: androidx.compose.ui.unit.Dp,
|
||||||
|
iconSize: androidx.compose.ui.unit.Dp,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(buttonSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.35f))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -446,6 +493,105 @@ private fun ProgressControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LockedPlayerOverlay(
|
||||||
|
playbackSnapshot: PlayerPlaybackSnapshot,
|
||||||
|
displayedPositionMs: Long,
|
||||||
|
metrics: PlayerLayoutMetrics,
|
||||||
|
horizontalSafePadding: androidx.compose.ui.unit.Dp,
|
||||||
|
onUnlock: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||||
|
val sliderColors = SliderDefaults.colors(
|
||||||
|
thumbColor = Color.White,
|
||||||
|
activeTrackColor = Color.White,
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.28f),
|
||||||
|
disabledThumbColor = Color.White,
|
||||||
|
disabledActiveTrackColor = Color.White,
|
||||||
|
disabledInactiveTrackColor = Color.White.copy(alpha = 0.28f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(220.dp)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.72f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(78.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.52f))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape)
|
||||||
|
.clickable(onClick = onUnlock),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Lock,
|
||||||
|
contentDescription = "Unlock player controls",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tap to unlock",
|
||||||
|
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color.White.copy(alpha = 0.92f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = horizontalSafePadding + metrics.horizontalPadding)
|
||||||
|
.padding(bottom = metrics.sliderBottomOffset),
|
||||||
|
) {
|
||||||
|
Slider(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(metrics.sliderTouchHeight)
|
||||||
|
.graphicsLayer(scaleY = metrics.sliderScaleY),
|
||||||
|
value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
|
||||||
|
onValueChange = {},
|
||||||
|
onValueChangeFinished = {},
|
||||||
|
valueRange = 0f..durationMs.toFloat(),
|
||||||
|
enabled = false,
|
||||||
|
colors = sliderColors,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
|
||||||
|
TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimePill(
|
private fun TimePill(
|
||||||
text: String,
|
text: String,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import kotlin.math.roundToInt
|
||||||
private const val PlaybackProgressPersistIntervalMs = 60_000L
|
private const val PlaybackProgressPersistIntervalMs = 60_000L
|
||||||
private const val PlayerDoubleTapSeekStepMs = 10_000L
|
private const val PlayerDoubleTapSeekStepMs = 10_000L
|
||||||
private const val PlayerDoubleTapSeekResetDelayMs = 800L
|
private const val PlayerDoubleTapSeekResetDelayMs = 800L
|
||||||
|
private const val PlayerLockedOverlayDurationMs = 2_000L
|
||||||
private const val PlayerLeftGestureBoundary = 0.4f
|
private const val PlayerLeftGestureBoundary = 0.4f
|
||||||
private const val PlayerRightGestureBoundary = 0.6f
|
private const val PlayerRightGestureBoundary = 0.6f
|
||||||
private const val PlayerVerticalGestureSensitivity = 1f
|
private const val PlayerVerticalGestureSensitivity = 1f
|
||||||
|
|
@ -154,6 +155,7 @@ fun PlayerScreen(
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val gestureController = rememberPlayerGestureController()
|
val gestureController = rememberPlayerGestureController()
|
||||||
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
||||||
// Active playback state (mutable to support source/episode switching)
|
// Active playback state (mutable to support source/episode switching)
|
||||||
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
|
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
|
||||||
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
|
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
|
||||||
|
|
@ -189,6 +191,7 @@ fun PlayerScreen(
|
||||||
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
||||||
var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
||||||
var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
|
||||||
|
var lockedOverlayVisible by remember { mutableStateOf(false) }
|
||||||
var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
|
var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
|
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
|
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
|
||||||
|
|
@ -497,6 +500,35 @@ fun PlayerScreen(
|
||||||
liveGestureFeedback = null
|
liveGestureFeedback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun revealLockedOverlay() {
|
||||||
|
controlsVisible = false
|
||||||
|
lockedOverlayVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockPlayerControls() {
|
||||||
|
playerControlsLocked = true
|
||||||
|
controlsVisible = false
|
||||||
|
lockedOverlayVisible = false
|
||||||
|
pausedOverlayVisible = false
|
||||||
|
scrubbingPositionMs = null
|
||||||
|
gestureMessageJob?.cancel()
|
||||||
|
gestureFeedback = null
|
||||||
|
liveGestureFeedback = null
|
||||||
|
renderedGestureFeedback = null
|
||||||
|
showAudioModal = false
|
||||||
|
showSubtitleModal = false
|
||||||
|
showSourcesPanel = false
|
||||||
|
showEpisodesPanel = false
|
||||||
|
episodeStreamsPanelState = EpisodeStreamsPanelState()
|
||||||
|
PlayerStreamsRepository.clearEpisodeStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockPlayerControls() {
|
||||||
|
playerControlsLocked = false
|
||||||
|
lockedOverlayVisible = false
|
||||||
|
controlsVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
|
fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
|
||||||
val seconds = amountMs / 1000L
|
val seconds = amountMs / 1000L
|
||||||
if (seconds <= 0L) return
|
if (seconds <= 0L) return
|
||||||
|
|
@ -659,6 +691,10 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val onSurfaceTap = rememberUpdatedState { offset: Offset ->
|
val onSurfaceTap = rememberUpdatedState { offset: Offset ->
|
||||||
|
if (playerControlsLocked) {
|
||||||
|
revealLockedOverlay()
|
||||||
|
return@rememberUpdatedState
|
||||||
|
}
|
||||||
val centerStart = layoutSize.width * PlayerLeftGestureBoundary
|
val centerStart = layoutSize.width * PlayerLeftGestureBoundary
|
||||||
val centerEnd = layoutSize.width * PlayerRightGestureBoundary
|
val centerEnd = layoutSize.width * PlayerRightGestureBoundary
|
||||||
if (controlsVisible && offset.x in centerStart..centerEnd) {
|
if (controlsVisible && offset.x in centerStart..centerEnd) {
|
||||||
|
|
@ -668,6 +704,10 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
|
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
|
||||||
|
if (playerControlsLocked) {
|
||||||
|
revealLockedOverlay()
|
||||||
|
return@rememberUpdatedState
|
||||||
|
}
|
||||||
when {
|
when {
|
||||||
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
|
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
|
||||||
handleDoubleTapSeek(PlayerSeekDirection.Backward)
|
handleDoubleTapSeek(PlayerSeekDirection.Backward)
|
||||||
|
|
@ -686,7 +726,9 @@ fun PlayerScreen(
|
||||||
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
|
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
|
||||||
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
|
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
|
||||||
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
|
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
|
||||||
|
val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
|
||||||
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
|
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
|
||||||
|
val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
|
||||||
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
|
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
|
||||||
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
|
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
|
||||||
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
|
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
|
||||||
|
|
@ -1002,6 +1044,7 @@ fun PlayerScreen(
|
||||||
scrubbingPositionMs = null
|
scrubbingPositionMs = null
|
||||||
liveGestureFeedback = null
|
liveGestureFeedback = null
|
||||||
renderedGestureFeedback = null
|
renderedGestureFeedback = null
|
||||||
|
lockedOverlayVisible = false
|
||||||
initialLoadCompleted = false
|
initialLoadCompleted = false
|
||||||
lastProgressPersistEpochMs = 0L
|
lastProgressPersistEpochMs = 0L
|
||||||
previousIsPlaying = false
|
previousIsPlaying = false
|
||||||
|
|
@ -1120,6 +1163,14 @@ fun PlayerScreen(
|
||||||
controlsVisible = false
|
controlsVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
|
||||||
|
if (!playerControlsLocked || !lockedOverlayVisible) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(PlayerLockedOverlayDurationMs)
|
||||||
|
lockedOverlayVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
|
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
|
||||||
pausedOverlayVisible = false
|
pausedOverlayVisible = false
|
||||||
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
|
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
|
||||||
|
|
@ -1325,12 +1376,27 @@ fun PlayerScreen(
|
||||||
},
|
},
|
||||||
onTap = { offset -> onSurfaceTap.value(offset) },
|
onTap = { offset -> onSurfaceTap.value(offset) },
|
||||||
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
|
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
|
||||||
onLongPress = { activateHoldToSpeedState.value() },
|
onLongPress = {
|
||||||
|
if (playerControlsLockedState.value) {
|
||||||
|
revealLockedOverlayState.value()
|
||||||
|
} else {
|
||||||
|
activateHoldToSpeedState.value()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.pointerInput(gestureController, layoutSize) {
|
.pointerInput(gestureController, layoutSize) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown()
|
val down = awaitFirstDown()
|
||||||
|
if (playerControlsLockedState.value) {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!change.pressed) break
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
return@awaitEachGesture
|
||||||
|
}
|
||||||
val controller = gestureController
|
val controller = gestureController
|
||||||
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
||||||
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
||||||
|
|
@ -1591,18 +1657,18 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
if (snapshot.isEnded) {
|
if (snapshot.isEnded) {
|
||||||
shouldPlay = false
|
shouldPlay = false
|
||||||
controlsVisible = true
|
controlsVisible = !playerControlsLocked
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { message ->
|
onError = { message ->
|
||||||
errorMessage = message
|
errorMessage = message
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
controlsVisible = true
|
controlsVisible = !playerControlsLocked
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (pausedOverlayVisible && !controlsVisible) {
|
if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) {
|
||||||
PauseMetadataOverlay(
|
PauseMetadataOverlay(
|
||||||
title = title,
|
title = title,
|
||||||
logo = logo,
|
logo = logo,
|
||||||
|
|
@ -1619,7 +1685,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controlsVisible,
|
visible = controlsVisible && !playerControlsLocked,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -1634,6 +1700,14 @@ fun PlayerScreen(
|
||||||
displayedPositionMs = displayedPositionMs,
|
displayedPositionMs = displayedPositionMs,
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
resizeMode = resizeMode,
|
resizeMode = resizeMode,
|
||||||
|
isLocked = playerControlsLocked,
|
||||||
|
onLockToggle = {
|
||||||
|
if (playerControlsLocked) {
|
||||||
|
unlockPlayerControls()
|
||||||
|
} else {
|
||||||
|
lockPlayerControls()
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = onBackWithProgress,
|
onBack = onBackWithProgress,
|
||||||
onTogglePlayback = ::togglePlayback,
|
onTogglePlayback = ::togglePlayback,
|
||||||
onSeekBack = { seekBy(-10_000L) },
|
onSeekBack = { seekBy(-10_000L) },
|
||||||
|
|
@ -1660,6 +1734,21 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = playerControlsLocked && lockedOverlayVisible,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
LockedPlayerOverlay(
|
||||||
|
playbackSnapshot = playbackSnapshot,
|
||||||
|
displayedPositionMs = displayedPositionMs,
|
||||||
|
metrics = metrics,
|
||||||
|
horizontalSafePadding = horizontalSafePadding,
|
||||||
|
onUnlock = ::unlockPlayerControls,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
|
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
|
|
@ -1697,6 +1786,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip intro/recap/outro button
|
// Skip intro/recap/outro button
|
||||||
|
if (!playerControlsLocked) {
|
||||||
SkipIntroButton(
|
SkipIntroButton(
|
||||||
interval = activeSkipInterval,
|
interval = activeSkipInterval,
|
||||||
dismissed = skipIntervalDismissed,
|
dismissed = skipIntervalDismissed,
|
||||||
|
|
@ -1711,9 +1801,10 @@ fun PlayerScreen(
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
|
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Next episode card
|
// Next episode card
|
||||||
if (isSeries) {
|
if (isSeries && !playerControlsLocked) {
|
||||||
NextEpisodeCard(
|
NextEpisodeCard(
|
||||||
nextEpisode = nextEpisodeInfo,
|
nextEpisode = nextEpisodeInfo,
|
||||||
visible = showNextEpisodeCard,
|
visible = showNextEpisodeCard,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package com.nuvio.app.features.player
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
|
import com.nuvio.app.features.plugins.pluginContentId
|
||||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||||
import com.nuvio.app.features.plugins.PluginScraper
|
import com.nuvio.app.features.plugins.PluginScraper
|
||||||
import com.nuvio.app.features.streams.AddonStreamGroup
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
|
|
@ -215,11 +217,12 @@ object PlayerStreamsRepository {
|
||||||
val job = scope.launch {
|
val job = scope.launch {
|
||||||
val addonJobs = streamAddons.map { addon ->
|
val addonJobs = streamAddons.map { addon ->
|
||||||
async {
|
async {
|
||||||
val encodedId = videoId.replace("%", "%25").replace(" ", "%20")
|
val url = buildAddonResourceUrl(
|
||||||
val baseUrl = addon.manifest.transportUrl
|
manifestUrl = addon.manifest.transportUrl,
|
||||||
.substringBefore("?")
|
resource = "stream",
|
||||||
.removeSuffix("/manifest.json")
|
type = type,
|
||||||
val url = "$baseUrl/stream/$type/$encodedId.json"
|
id = videoId,
|
||||||
|
)
|
||||||
|
|
||||||
val displayName = addon.addonName
|
val displayName = addon.addonName
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -241,7 +244,11 @@ object PlayerStreamsRepository {
|
||||||
async {
|
async {
|
||||||
PluginRepository.executeScraper(
|
PluginRepository.executeScraper(
|
||||||
scraper = scraper,
|
scraper = scraper,
|
||||||
tmdbId = videoId.toPluginTmdbId(),
|
tmdbId = pluginContentId(
|
||||||
|
videoId = videoId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
),
|
||||||
mediaType = type,
|
mediaType = type,
|
||||||
season = season,
|
season = season,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
|
|
@ -337,11 +344,3 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toPluginTmdbId(): String {
|
|
||||||
return when {
|
|
||||||
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
|
|
||||||
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
|
|
||||||
else -> this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.player
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -49,8 +50,12 @@ object SubtitleRepository {
|
||||||
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
|
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
|
||||||
if (!prefixMatch) continue
|
if (!prefixMatch) continue
|
||||||
|
|
||||||
val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json")
|
val subtitleUrl = buildAddonResourceUrl(
|
||||||
val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json"
|
manifestUrl = manifest.transportUrl,
|
||||||
|
resource = "subtitles",
|
||||||
|
type = type,
|
||||||
|
id = videoId,
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response = withContext(Dispatchers.Default) {
|
val response = withContext(Dispatchers.Default) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
onIntegrationsClick: () -> Unit,
|
||||||
onTraktClick: () -> Unit,
|
onTraktClick: () -> Unit,
|
||||||
onSupportersContributorsClick: () -> Unit,
|
onSupportersContributorsClick: () -> Unit,
|
||||||
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onDownloadsClick: () -> Unit,
|
onDownloadsClick: () -> Unit,
|
||||||
onAccountClick: () -> Unit,
|
onAccountClick: () -> Unit,
|
||||||
onSwitchProfileClick: (() -> Unit)? = null,
|
onSwitchProfileClick: (() -> Unit)? = null,
|
||||||
|
|
@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onSupportersContributorsClick,
|
onClick = onSupportersContributorsClick,
|
||||||
)
|
)
|
||||||
|
if (onCheckForUpdatesClick != null) {
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = "Check for updates",
|
||||||
|
description = "Check for new versions of the app.",
|
||||||
|
icon = Icons.Rounded.CloudDownload,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = onCheckForUpdatesClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ fun SettingsScreen(
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onAccountClick: () -> Unit = {},
|
onAccountClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
|
|
@ -190,6 +191,7 @@ fun SettingsScreen(
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsClick,
|
onCollectionsClick = onCollectionsClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -233,6 +235,7 @@ fun SettingsScreen(
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onAccountClick = onAccountClick,
|
onAccountClick = onAccountClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsClick,
|
onCollectionsClick = onCollectionsClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onAccountClick: () -> Unit = {},
|
onAccountClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
NuvioScreen {
|
NuvioScreen {
|
||||||
|
|
@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
|
||||||
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
||||||
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onAccountClick = onAccountClick,
|
onAccountClick = onAccountClick,
|
||||||
onSwitchProfileClick = onSwitchProfile,
|
onSwitchProfileClick = onSwitchProfile,
|
||||||
|
|
@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
|
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
|
||||||
|
|
@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
|
||||||
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
||||||
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
||||||
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
|
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||||
onSwitchProfileClick = onSwitchProfile,
|
onSwitchProfileClick = onSwitchProfile,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ package com.nuvio.app.features.streams
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
|
import com.nuvio.app.features.plugins.pluginContentId
|
||||||
import com.nuvio.app.features.plugins.PluginsUiState
|
import com.nuvio.app.features.plugins.PluginsUiState
|
||||||
import com.nuvio.app.features.plugins.PluginRepositoryItem
|
import com.nuvio.app.features.plugins.PluginRepositoryItem
|
||||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||||
|
|
@ -237,11 +239,12 @@ object StreamsRepository {
|
||||||
|
|
||||||
streamAddons.forEach { addon ->
|
streamAddons.forEach { addon ->
|
||||||
launch {
|
launch {
|
||||||
val encodedId = videoId.encodeForPath()
|
val url = buildAddonResourceUrl(
|
||||||
val baseUrl = addon.manifest.transportUrl
|
manifestUrl = addon.manifest.transportUrl,
|
||||||
.substringBefore("?")
|
resource = "stream",
|
||||||
.removeSuffix("/manifest.json")
|
type = type,
|
||||||
val url = "$baseUrl/stream/$type/$encodedId.json"
|
id = videoId,
|
||||||
|
)
|
||||||
log.d { "Fetching streams from: $url" }
|
log.d { "Fetching streams from: $url" }
|
||||||
|
|
||||||
val displayName = addon.addonName
|
val displayName = addon.addonName
|
||||||
|
|
@ -283,7 +286,11 @@ object StreamsRepository {
|
||||||
launch {
|
launch {
|
||||||
val completion = PluginRepository.executeScraper(
|
val completion = PluginRepository.executeScraper(
|
||||||
scraper = scraper,
|
scraper = scraper,
|
||||||
tmdbId = videoId.toPluginTmdbId(),
|
tmdbId = pluginContentId(
|
||||||
|
videoId = videoId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
),
|
||||||
mediaType = type,
|
mediaType = type,
|
||||||
season = season,
|
season = season,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
|
|
@ -420,10 +427,6 @@ object StreamsRepository {
|
||||||
activeRequestKey = null
|
activeRequestKey = null
|
||||||
_uiState.value = StreamsUiState()
|
_uiState.value = StreamsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode id segment so colons and slashes don't break URL path parsing on addons
|
|
||||||
private fun String.encodeForPath(): String =
|
|
||||||
replace("%", "%25").replace(" ", "%20")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InstalledStreamAddonTarget(
|
private data class InstalledStreamAddonTarget(
|
||||||
|
|
@ -488,14 +491,6 @@ private fun List<AddonStreamGroup>.toEmptyStateReason(anyLoading: Boolean): Stre
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toPluginTmdbId(): String {
|
|
||||||
return when {
|
|
||||||
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
|
|
||||||
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
|
|
||||||
else -> this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PluginRuntimeResult.toStreamItem(
|
private fun PluginRuntimeResult.toStreamItem(
|
||||||
scraper: PluginScraper,
|
scraper: PluginScraper,
|
||||||
addonName: String = scraper.name,
|
addonName: String = scraper.name,
|
||||||
|
|
|
||||||
|
|
@ -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.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watched.episodePlaybackId
|
import com.nuvio.app.features.watched.episodePlaybackId
|
||||||
import com.nuvio.app.features.watched.releasedPlayableEpisodes
|
import com.nuvio.app.features.watched.releasedPlayableEpisodes
|
||||||
|
|
@ -106,7 +107,20 @@ object WatchingActions {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onProgressEntryUpdated(entry: WatchProgressEntry) {
|
fun onProgressEntryUpdated(entry: WatchProgressEntry) {
|
||||||
if (!entry.isCompleted || !entry.isEpisode) return
|
if (!entry.isCompleted) return
|
||||||
|
|
||||||
|
val watchedItem = WatchedItem(
|
||||||
|
id = entry.parentMetaId,
|
||||||
|
type = entry.parentMetaType,
|
||||||
|
name = entry.title,
|
||||||
|
poster = entry.poster,
|
||||||
|
season = entry.seasonNumber,
|
||||||
|
episode = entry.episodeNumber,
|
||||||
|
markedAtEpochMs = entry.lastUpdatedEpochMs,
|
||||||
|
)
|
||||||
|
WatchedRepository.markWatched(watchedItem)
|
||||||
|
|
||||||
|
if (!entry.isEpisode) return
|
||||||
actionScope.launch {
|
actionScope.launch {
|
||||||
val meta = runCatching {
|
val meta = runCatching {
|
||||||
MetaDetailsRepository.fetch(
|
MetaDetailsRepository.fetch(
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter(
|
||||||
val candidates = sortedEpisodes
|
val candidates = sortedEpisodes
|
||||||
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
|
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
|
.filter { episode ->
|
||||||
|
shouldSurfaceNextEpisode(
|
||||||
|
watchedSeasonNumber = seasonNumber,
|
||||||
|
candidateSeasonNumber = episode.seasonNumber,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
releasedDate = episode.releasedDate,
|
||||||
|
showUnairedNextUp = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
|
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain
|
||||||
private const val InProgressStartThresholdFraction = 0.02f
|
private const val InProgressStartThresholdFraction = 0.02f
|
||||||
private const val CompletionThresholdFraction = 0.85
|
private const val CompletionThresholdFraction = 0.85
|
||||||
private const val InProgressStartThresholdMinMs = 30_000L
|
private const val InProgressStartThresholdMinMs = 30_000L
|
||||||
|
private const val UpcomingNextSeasonWindowDays = 7
|
||||||
|
|
||||||
fun watchedKey(
|
fun watchedKey(
|
||||||
content: WatchingContentRef,
|
content: WatchingContentRef,
|
||||||
|
|
@ -48,6 +49,81 @@ fun isReleasedBy(
|
||||||
return isoDate <= todayIsoDate
|
return isoDate <= todayIsoDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shouldSurfaceNextEpisode(
|
||||||
|
watchedSeasonNumber: Int?,
|
||||||
|
candidateSeasonNumber: Int?,
|
||||||
|
todayIsoDate: String,
|
||||||
|
releasedDate: String?,
|
||||||
|
showUnairedNextUp: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber)
|
||||||
|
if (!isSeasonRollover) {
|
||||||
|
if (showUnairedNextUp) return true
|
||||||
|
return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!showUnairedNextUp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val daysUntilRelease = daysUntilExplicitRelease(
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
releasedDate = releasedDate,
|
||||||
|
) ?: return false
|
||||||
|
return daysUntilRelease in 0..UpcomingNextSeasonWindowDays
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExplicitlyReleasedBy(
|
||||||
|
todayIsoDate: String,
|
||||||
|
releasedDate: String?,
|
||||||
|
): Boolean {
|
||||||
|
val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false
|
||||||
|
return isoDate <= todayIsoDate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun daysUntilExplicitRelease(
|
||||||
|
todayIsoDate: String,
|
||||||
|
releasedDate: String?,
|
||||||
|
): Int? {
|
||||||
|
val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null
|
||||||
|
val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null
|
||||||
|
return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isoCalendarDateOrNull(value: String?): String? {
|
||||||
|
val datePart = value
|
||||||
|
?.trim()
|
||||||
|
?.substringBefore('T')
|
||||||
|
?.takeIf { it.length == 10 }
|
||||||
|
?: return null
|
||||||
|
val parts = datePart.split('-')
|
||||||
|
if (parts.size != 3) return null
|
||||||
|
val year = parts[0].toIntOrNull() ?: return null
|
||||||
|
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||||
|
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null
|
||||||
|
val normalizedYear = year.toString().padStart(4, '0')
|
||||||
|
val normalizedMonth = month.toString().padStart(2, '0')
|
||||||
|
val normalizedDay = day.toString().padStart(2, '0')
|
||||||
|
return "$normalizedYear-$normalizedMonth-$normalizedDay"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isoEpochDay(date: String): Long {
|
||||||
|
val year = date.substring(0, 4).toLong()
|
||||||
|
val month = date.substring(5, 7).toLong()
|
||||||
|
val day = date.substring(8, 10).toLong()
|
||||||
|
|
||||||
|
val adjustedYear = year - if (month <= 2L) 1L else 0L
|
||||||
|
val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
|
||||||
|
val yearOfEra = adjustedYear - era * 400L
|
||||||
|
val adjustedMonth = month + if (month > 2L) -3L else 9L
|
||||||
|
val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
|
||||||
|
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
|
||||||
|
return era * 146_097L + dayOfEra - 719_468L
|
||||||
|
}
|
||||||
|
|
||||||
fun releasedEpisodes(
|
fun releasedEpisodes(
|
||||||
episodes: List<WatchingReleasedEpisode>,
|
episodes: List<WatchingReleasedEpisode>,
|
||||||
todayIsoDate: String,
|
todayIsoDate: String,
|
||||||
|
|
|
||||||
|
|
@ -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.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
import io.github.jan.supabase.postgrest.postgrest
|
||||||
import io.github.jan.supabase.postgrest.query.Order
|
import io.github.jan.supabase.postgrest.query.Order
|
||||||
import io.github.jan.supabase.postgrest.rpc
|
import io.github.jan.supabase.postgrest.rpc
|
||||||
|
|
@ -314,10 +315,15 @@ actual object PluginRepository {
|
||||||
season: Int?,
|
season: Int?,
|
||||||
episode: Int?,
|
episode: Int?,
|
||||||
): Result<List<PluginRuntimeResult>> {
|
): Result<List<PluginRuntimeResult>> {
|
||||||
|
val resolvedTmdbId = resolvePluginTmdbId(
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
mediaType = mediaType,
|
||||||
|
)
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
PluginRuntime.executePlugin(
|
PluginRuntime.executePlugin(
|
||||||
code = scraper.code,
|
code = scraper.code,
|
||||||
tmdbId = tmdbId,
|
tmdbId = resolvedTmdbId,
|
||||||
mediaType = normalizePluginType(mediaType),
|
mediaType = normalizePluginType(mediaType),
|
||||||
season = season,
|
season = season,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
|
|
@ -327,6 +333,19 @@ actual object PluginRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolvePluginTmdbId(
|
||||||
|
tmdbId: String,
|
||||||
|
mediaType: String,
|
||||||
|
): String {
|
||||||
|
val trimmed = tmdbId.trim()
|
||||||
|
if (trimmed.isBlank()) return tmdbId
|
||||||
|
|
||||||
|
return TmdbService.ensureTmdbId(
|
||||||
|
videoId = trimmed,
|
||||||
|
mediaType = mediaType,
|
||||||
|
) ?: trimmed
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchRepositoryData(
|
private suspend fun fetchRepositoryData(
|
||||||
manifestUrl: String,
|
manifestUrl: String,
|
||||||
previousScrapers: Map<String, PluginScraper>,
|
previousScrapers: Map<String, PluginScraper>,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import com.nuvio.app.core.ui.NuvioInputField
|
||||||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -49,6 +50,10 @@ fun PluginsSettingsPageContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
|
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val tmdbSettings by remember {
|
||||||
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
|
TmdbSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var repositoryUrl by rememberSaveable { mutableStateOf("") }
|
var repositoryUrl by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
@ -61,6 +66,7 @@ fun PluginsSettingsPageContent(
|
||||||
val sortedRepos = remember(uiState.repositories) {
|
val sortedRepos = remember(uiState.repositories) {
|
||||||
uiState.repositories.sortedBy { it.name.lowercase() }
|
uiState.repositories.sortedBy { it.name.lowercase() }
|
||||||
}
|
}
|
||||||
|
val hasTmdbApiKey = tmdbSettings.hasApiKey
|
||||||
val repositoryNameByUrl = remember(sortedRepos) {
|
val repositoryNameByUrl = remember(sortedRepos) {
|
||||||
sortedRepos.associate { it.manifestUrl to it.name }
|
sortedRepos.associate { it.manifestUrl to it.name }
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +94,17 @@ fun PluginsSettingsPageContent(
|
||||||
NuvioInfoBadge(
|
NuvioInfoBadge(
|
||||||
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
|
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
|
||||||
)
|
)
|
||||||
|
NuvioInfoBadge(
|
||||||
|
text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!hasTmdbApiKey) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -355,7 +372,7 @@ fun PluginsSettingsPageContent(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
||||||
enabled = !isTestingThisScraper,
|
enabled = hasTmdbApiKey && !isTestingThisScraper,
|
||||||
onClick = {
|
onClick = {
|
||||||
testingScraperId = scraper.id
|
testingScraperId = scraper.id
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -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=35
|
||||||
CURRENT_PROJECT_VERSION=29
|
MARKETING_VERSION=0.1.4
|
||||||
MARKETING_VERSION=0.1.0
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,94 @@ import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
|
final class RootComposeViewController: UIViewController {
|
||||||
|
private let contentController: UIViewController
|
||||||
|
|
||||||
|
init(contentController: UIViewController) {
|
||||||
|
self.contentController = contentController
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = .black
|
||||||
|
contentController.view.backgroundColor = .black
|
||||||
|
|
||||||
|
addChild(contentController)
|
||||||
|
view.addSubview(contentController.view)
|
||||||
|
contentController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
contentController.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||||
|
immersiveController(in: contentController) ?? contentController
|
||||||
|
}
|
||||||
|
|
||||||
|
override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
|
||||||
|
immersiveController(in: contentController) ?? contentController
|
||||||
|
}
|
||||||
|
|
||||||
|
override var childForStatusBarHidden: UIViewController? {
|
||||||
|
immersiveController(in: contentController) ?? contentController
|
||||||
|
}
|
||||||
|
|
||||||
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||||
|
immersiveController(in: contentController)?.prefersHomeIndicatorAutoHidden ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||||
|
immersiveController(in: contentController)?.preferredScreenEdgesDeferringSystemGestures ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
immersiveController(in: contentController)?.prefersStatusBarHidden ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||||
|
.fade
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshImmersiveSystemUI() {
|
||||||
|
setNeedsUpdateOfHomeIndicatorAutoHidden()
|
||||||
|
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||||
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func immersiveController(in controller: UIViewController?) -> UIViewController? {
|
||||||
|
guard let controller else { return nil }
|
||||||
|
|
||||||
|
if controller.prefersHomeIndicatorAutoHidden ||
|
||||||
|
!controller.preferredScreenEdgesDeferringSystemGestures.isEmpty ||
|
||||||
|
controller.prefersStatusBarHidden {
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
if let presented = immersiveController(in: controller.presentedViewController) {
|
||||||
|
return presented
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in controller.children.reversed() {
|
||||||
|
if let immersiveChild = immersiveController(in: child) {
|
||||||
|
return immersiveChild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
struct ComposeView: UIViewControllerRepresentable {
|
||||||
func makeUIViewController(context: Context) -> UIViewController {
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
// Register MPV player bridge before Compose initializes
|
// Register MPV player bridge before Compose initializes
|
||||||
|
|
@ -9,7 +97,7 @@ struct ComposeView: UIViewControllerRepresentable {
|
||||||
|
|
||||||
let controller = MainViewControllerKt.MainViewController()
|
let controller = MainViewControllerKt.MainViewController()
|
||||||
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
|
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
|
||||||
return controller
|
return RootComposeViewController(contentController: controller)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,22 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
private var _currentErrorMessage: String?
|
private var _currentErrorMessage: String?
|
||||||
|
|
||||||
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||||
|
[.bottom, .left, .right]
|
||||||
|
}
|
||||||
|
|
||||||
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||||
|
.fade
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
|
|
@ -181,6 +197,12 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
|
|
||||||
setupMpv()
|
setupMpv()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
|
refreshImmersiveSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
refreshImmersiveSystemUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
|
|
@ -188,6 +210,16 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
metalLayer.frame = view.bounds
|
metalLayer.frame = view.bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
refreshImmersiveSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
refreshImmersiveSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MPV Setup
|
// MARK: - MPV Setup
|
||||||
|
|
||||||
private func setupMpv() {
|
private func setupMpv() {
|
||||||
|
|
@ -203,12 +235,19 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
|
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
|
||||||
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
|
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
|
||||||
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
|
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
|
||||||
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
|
checkError(mpv_set_option_string(mpv, "hwdec", "auto"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vulkan-async-transfer", "no"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vulkan-disable-interop", "yes"))
|
||||||
checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
|
checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
|
||||||
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
|
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
|
||||||
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
|
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
|
||||||
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
||||||
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
|
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "tone-mapping", "auto"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no"))
|
||||||
|
|
||||||
checkError(mpv_initialize(mpv))
|
checkError(mpv_initialize(mpv))
|
||||||
|
|
||||||
|
|
@ -626,6 +665,23 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
.joined(separator: ",")
|
.joined(separator: ",")
|
||||||
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
|
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshImmersiveSystemUI() {
|
||||||
|
setNeedsUpdateOfHomeIndicatorAutoHidden()
|
||||||
|
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||||
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
|
|
||||||
|
var currentParent = parent
|
||||||
|
while let controller = currentParent {
|
||||||
|
controller.setNeedsUpdateOfHomeIndicatorAutoHidden()
|
||||||
|
controller.setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||||
|
controller.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
if let rootController = controller as? RootComposeViewController {
|
||||||
|
rootController.refreshImmersiveSystemUI()
|
||||||
|
}
|
||||||
|
currentParent = controller.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bridge Creator (implements Kotlin protocol)
|
// MARK: - Bridge Creator (implements Kotlin protocol)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue