kmp init
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
*.iml
|
||||
.kotlin
|
||||
.gradle
|
||||
**/build/
|
||||
xcuserdata
|
||||
!src/**/build/
|
||||
local.properties
|
||||
.idea
|
||||
.DS_Store
|
||||
captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
*.xcodeproj/*
|
||||
!*.xcodeproj/project.pbxproj
|
||||
!*.xcodeproj/xcshareddata/
|
||||
!*.xcodeproj/project.xcworkspace/
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
152
Docs/Stremio addons refer/README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Getting Started
|
||||
|
||||
A NodeJS SDK for making and publishing Stremio addons
|
||||
|
||||
## Example
|
||||
|
||||
**This arbitrary example creates an addon that provides a stream for Big Buck Bunny and outputs a HTTP address where you can access it**
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { addonBuilder, serveHTTP, publishToCentral } = require('stremio-addon-sdk')
|
||||
|
||||
const builder = new addonBuilder({
|
||||
id: 'org.myexampleaddon',
|
||||
version: '1.0.0',
|
||||
|
||||
name: 'simple example',
|
||||
|
||||
// Properties that determine when Stremio picks this addon
|
||||
// this means your addon will be used for streams of the type movie
|
||||
resources: ['stream'],
|
||||
types: ['movie'],
|
||||
idPrefixes: ['tt']
|
||||
})
|
||||
|
||||
// takes function(args), returns Promise
|
||||
builder.defineStreamHandler(function(args) {
|
||||
if (args.type === 'movie' && args.id === 'tt1254207') {
|
||||
// serve one stream to big buck bunny
|
||||
// return addonSDK.Stream({ url: '...' })
|
||||
const stream = { url: 'http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_30fps_normal.mp4' }
|
||||
return Promise.resolve({ streams: [stream] })
|
||||
} else {
|
||||
// otherwise return no streams
|
||||
return Promise.resolve({ streams: [] })
|
||||
}
|
||||
})
|
||||
|
||||
serveHTTP(builder.getInterface(), { port: 7000 })
|
||||
|
||||
// If you want this addon to appear in the addon catalogs, call .publishToCentral() with the publically available URL to your manifest
|
||||
//publishToCentral('https://my-addon.com/manifest.json')
|
||||
|
||||
```
|
||||
|
||||
Save this as `addon.js` and run:
|
||||
|
||||
```bash
|
||||
npm install stremio-addon-sdk
|
||||
node ./addon.js
|
||||
```
|
||||
|
||||
It will output a URL that you can use to [install the addon in Stremio](./testing.md#how-to-install-addon-in-stremio)
|
||||
|
||||
## Documentation
|
||||
|
||||
**To get familiar with the resources and their roles, [read this](./api/README.md).**
|
||||
|
||||
#### `const { addonBuilder, serveHTTP, getRouter, publishToCentral } = require('stremio-addon-sdk')`
|
||||
|
||||
Imports everything the SDK provides:
|
||||
|
||||
* `addonBuilder`, which you'll need to define the addon
|
||||
* `serveHTTP`, which you will need to serve a HTTP server for this addon
|
||||
* `getRouter`, converts an `addonInterface` to an express router
|
||||
* `publishToCentral`: publishes an addon URL to the public addon catalog
|
||||
|
||||
|
||||
#### `const builder = new addonBuilder(manifest)`
|
||||
|
||||
Creates an addon builder object with a given manifest. This will throw if the manifest is not valid.
|
||||
|
||||
The manifest will determine the basic information of your addon (name, description, images), but most importantly, it will determine **when and how** your addon will be invoked by Stremio.
|
||||
|
||||
[Manifest Object Definition](./api/responses/manifest.md)
|
||||
|
||||
|
||||
#### `builder.defineCatalogHandler(function handler(args) { })`
|
||||
|
||||
Handles catalog requests, including search.
|
||||
|
||||
[Catalog Request Parameters and Example](./api/requests/defineCatalogHandler.md)
|
||||
|
||||
|
||||
#### `builder.defineMetaHandler(function handler(args) { })`
|
||||
|
||||
Handles metadata requests. (title, releaseInfo, poster, background, etc.)
|
||||
|
||||
[Meta Request Parameters and Example](./api/requests/defineMetaHandler.md)
|
||||
|
||||
|
||||
#### `builder.defineStreamHandler(function handler(args) { })`
|
||||
|
||||
Handles stream requests.
|
||||
|
||||
[Stream Request Parameters and Example](./api/requests/defineStreamHandler.md)
|
||||
|
||||
|
||||
#### `builder.defineSubtitlesHandler(function handler(args) { })`
|
||||
|
||||
Handles subtitle requests.
|
||||
|
||||
[Subtitle Request Parameters and Example](./api/requests/defineSubtitlesHandler.md)
|
||||
|
||||
|
||||
#### `builder.defineResourceHandler('addon_catalog', function handler(args) { })`
|
||||
|
||||
Handles addon catalog requests, this can be used by an addon to just send a list of other addon manifests.
|
||||
|
||||
[Addon Catalog Request Parameters and Example](./api/requests/defineResourceHandler.md)
|
||||
|
||||
**The JSON format of the response to these resources is described [here](./api/responses).**
|
||||
|
||||
|
||||
#### `builder.getInterface()`: returns an `addonInterface`
|
||||
|
||||
Turns the `addon` into an `addonInterface`, which is an immutable (frozen) object that has `{manifest, get}`; manifest is a regular [manifest object](./api/responses/manifest.md), while `get` is a function that takes one argument of the form `{ resource, type, id, extra }`, and returns a `Promise`
|
||||
|
||||
|
||||
#### `getRouter(addonInterface)`
|
||||
|
||||
Turns the `addonInterface` into an express router, that serves the addon according to [the protocol](./protocol.md), and a landing page on the root (`/`)
|
||||
|
||||
|
||||
#### `publishToCentral(url)`
|
||||
|
||||
This method expects a string with the url to your `manifest.json` file.
|
||||
|
||||
Publish your addon to the central server. After using this method your addon will be available in the Community Addons list in Stremio for users to install and use. Your addon needs to be publicly available with a URL in order for this to happen, as local addons that are not publicly available cannot be used by other Stremio users.
|
||||
|
||||
|
||||
#### `serveHTTP(addonInterface, options)`
|
||||
|
||||
Starts the addon server. `options` is an object that contains:
|
||||
|
||||
* `port`
|
||||
* `cacheMaxAge` (in seconds); `cacheMaxAge` means the `Cache-Control` header being set to `max-age=$cacheMaxAge`
|
||||
* `static`: path to a directory of static files to be served; e.g. `/public`
|
||||
|
||||
This method is also special in that it will react to certain process arguments, such as:
|
||||
|
||||
* `--launch`: launches Stremio in the web browser, and automatically installs/upgrades the addon
|
||||
* `--install`: installs the addon in the desktop version of Stremio
|
||||
|
||||
|
||||
#### `addonInterface`
|
||||
|
||||
The `addonInterface`, as returned from `builder.getInterface()`, has two properties:
|
||||
|
||||
* `get({ resource, type, id, extra })` - returns a Promise
|
||||
* `manifest`: [manifest object](./api/responses/manifest.md)
|
||||
429
Docs/Stremio addons refer/advanced.md
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
# Advanced Usage
|
||||
|
||||
- [Understanding Catalogs](#understanding-catalogs) (searching, filtering, paginating)
|
||||
- [Understanding Cinemeta](#understanding-cinemeta)
|
||||
- [Adding Stream Results to Cinemeta Items](#adding-stream-results-to-cinemeta-items)
|
||||
- [Getting Metadata from Cinemeta](#getting-metadata-from-cinemeta)
|
||||
- [Resolving Movie / Series names to IMDB ID](#resolving-movie--series-names-to-imdb-id)
|
||||
- [Using User Data in Addons](#using-user-data-in-addons)
|
||||
- [Using Deep Links in Addons](#using-deep-links-in-addons)
|
||||
- [Proxying Other Addons](#proxying-other-addons)
|
||||
- [Crawler (Scraping) Addons](#crawler--scraping-addons)
|
||||
|
||||
|
||||
## Understanding Catalogs
|
||||
|
||||
The `catalog` resource in Stremio addons can be used to:
|
||||
- show one or more catalogs in the Board and Discover pages, these responses can also be filtered and paginated
|
||||
- show search results from catalogs
|
||||
|
||||
Let's first look at how `catalog` is declared in the [manifest](./api/responses/manifest.md):
|
||||
```json
|
||||
{
|
||||
"resources": ["catalog"],
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is normally all you'd need to make a standard catalog, but it won't support filtering, paginating and it won't be searchable.
|
||||
|
||||
|
||||
### Searching in Catalogs
|
||||
|
||||
To state that your catalog supports searching, you'd need to set it in the `extra` property:
|
||||
|
||||
```json
|
||||
catalogs: [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie",
|
||||
"extra": [
|
||||
{
|
||||
"name": "search",
|
||||
"isRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
But then, what if you want your catalog to support only search (as in, not show in the Board or Discover pages at all)?
|
||||
|
||||
Then you'd need to state that your catalog supports only searching, and you can do that with:
|
||||
|
||||
```json
|
||||
catalogs: [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie",
|
||||
"extra": [
|
||||
{
|
||||
"name": "search",
|
||||
"isRequired": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Once you've set `search` in `extra`, your catalog handler will receive `args.extra.search` as the search query (if it is a search request), so here's an example of a search response:
|
||||
|
||||
```javascript
|
||||
const meta = {
|
||||
id: 'tt1254207',
|
||||
name: 'Big Buck Bunny',
|
||||
releaseInfo: '2008',
|
||||
poster: 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg',
|
||||
posterShape: 'poster',
|
||||
banner: 'https://image.tmdb.org/t/p/original/aHLST0g8sOE1ixCxRDgM35SKwwp.jpg',
|
||||
type: 'movie'
|
||||
}
|
||||
builder.defineCatalogHandler(function(args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (args.id == 'testcatalog') {
|
||||
// this is a request to our catalog id
|
||||
if (args.extra.search) {
|
||||
// this is a search request
|
||||
if (args.extra.search == 'big buck bunny') {
|
||||
// if someone searched for "big buck bunny" (exact match)
|
||||
// respond with our meta item
|
||||
resolve({ metas: [meta] })
|
||||
} else {
|
||||
reject(new Error('No search results found'))
|
||||
}
|
||||
} else {
|
||||
// this is a standard catalog request
|
||||
// just respond with our meta item
|
||||
resolve({ metas: [meta] })
|
||||
}
|
||||
} else {
|
||||
reject(new Error('Unknown catalog request'))
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
### Filtering in Catalogs
|
||||
|
||||
Maybe you would like your catalog to be filtered by `genre`, in this case, we'll set that in the catalog definition:
|
||||
|
||||
```json
|
||||
catalogs: [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie",
|
||||
"extra": [
|
||||
{
|
||||
"name": "genre",
|
||||
"options": [ "Drama", "Action" ],
|
||||
"isRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Now we'll receive `genre` in our catalog handler:
|
||||
|
||||
```javascript
|
||||
const meta = {
|
||||
id: 'tt1254207',
|
||||
name: 'Big Buck Bunny',
|
||||
releaseInfo: '2008',
|
||||
poster: 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg',
|
||||
posterShape: 'poster',
|
||||
banner: 'https://image.tmdb.org/t/p/original/aHLST0g8sOE1ixCxRDgM35SKwwp.jpg',
|
||||
type: 'movie'
|
||||
}
|
||||
builder.defineCatalogHandler(function(args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (args.id == 'testcatalog') {
|
||||
// this is a request to our catalog id
|
||||
if (args.extra.genre) {
|
||||
// this is a filter request
|
||||
if (args.extra.genre == "Action") {
|
||||
// in this example we'll only respon with our
|
||||
// meta item if the genre is "Action"
|
||||
resolve({ metas: [meta] })
|
||||
} else {
|
||||
// otherwise with no meta item
|
||||
resolve({ metas: [] })
|
||||
}
|
||||
} else {
|
||||
// this is a standard catalog request
|
||||
// just respond with our meta item
|
||||
resolve({ metas: [meta] })
|
||||
}
|
||||
} else {
|
||||
reject(new Error('Unknown catalog request'))
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Pagination in Catalogs
|
||||
|
||||
If we want our catalogs to be paginated, we can use `skip` as follows:
|
||||
|
||||
```json
|
||||
catalogs: [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie",
|
||||
"extra": [
|
||||
{
|
||||
"name": "skip",
|
||||
"isRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Optionally, we can also set the steps in which the catalog will request the next items, for example:
|
||||
|
||||
```json
|
||||
catalogs: [
|
||||
{
|
||||
"id": "testcatalog",
|
||||
"type": "movie",
|
||||
"extra": [
|
||||
{
|
||||
"name": "skip",
|
||||
"options": ["0", "100", "200"],
|
||||
"isRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This is not a requirement though, as if we don't set `options` Stremio will request `skip` once it comes to the end of your catalog.
|
||||
|
||||
Here's an example of using `skip`:
|
||||
|
||||
```javascript
|
||||
// we only have one meta item
|
||||
const meta = {
|
||||
id: 'tt1254207',
|
||||
name: 'Big Buck Bunny',
|
||||
releaseInfo: '2008',
|
||||
poster: 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg',
|
||||
posterShape: 'poster',
|
||||
banner: 'https://image.tmdb.org/t/p/original/aHLST0g8sOE1ixCxRDgM35SKwwp.jpg',
|
||||
type: 'movie'
|
||||
}
|
||||
|
||||
const metaList = []
|
||||
|
||||
// but we'll make an array that includes our meta 60 times
|
||||
for (let i = 0; i++; i < 60) {
|
||||
metaList.push(meta)
|
||||
}
|
||||
|
||||
builder.defineCatalogHandler(function(args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (args.id == 'testcatalog') {
|
||||
// we'll slice our meta list using
|
||||
// skip as the starting point
|
||||
const skip = args.extra.skip || 0
|
||||
resolve({ metas: metaList.slice(skip, skip + 20) })
|
||||
} else {
|
||||
reject(new Error('Unknown catalog request'))
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Understanding Cinemeta
|
||||
|
||||
Cinemeta is the primary addon that Stremio uses to show Movie, Series and Anime items. Other addons can choose to create their own catalogs of items or respond with streams to the Cinemeta items.
|
||||
|
||||
Cinemeta uses IMDB IDs for their metadata, to understand it's pattern:
|
||||
- `tt0111161` is the meta ID (and video ID) of a movie
|
||||
- `tt3107288` is the meta ID of a series, and `tt3107288:1:1` is the video ID for season 1, episode 1 of the series with the `tt3107288` meta ID
|
||||
|
||||
|
||||
## Adding Stream Results to Cinemeta Items
|
||||
|
||||
To add only stream results to Cinemeta items, you will first need to state that your addons id prefix is `tt` (as for IMDB IDs).
|
||||
|
||||
Add these to your [manifest](./api/responses/manifest.md):
|
||||
- `resources: ["stream"]`
|
||||
- `idPrefixes: ["tt"]`
|
||||
|
||||
Now here is an example of returning stream responses for Cinemeta items:
|
||||
|
||||
```javascript
|
||||
builder.defineStreamHandler(function(args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (args.type === 'movie' && args.id === 'tt1254207') {
|
||||
// serve one stream for big buck bunny
|
||||
const stream = { url: 'http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_30fps_normal.mp4' }
|
||||
resolve({ streams: [stream] })
|
||||
} else if (args.type === 'series' && args.id === 'tt8633518:1:1') {
|
||||
// return one stream for the series Weird City, Season 1 Episode 1
|
||||
// (free Youtube Originals series)
|
||||
const stream = { id: 'yt_id::fMnq5v8yZp4' }
|
||||
resolve({ streams: [stream] })
|
||||
} else {
|
||||
reject(new Error('No streams available for: ' + args.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Getting Metadata from Cinemeta
|
||||
|
||||
There might be cases where you would need metadata based on IMDB ID. To do this, you will need both IMDB ID and the type of the item (either `movie` or `series`).
|
||||
|
||||
Because Cinemeta is also an addon, you can request the metadata from it.
|
||||
|
||||
Here is an example using `needle` to do a HTTP request to Cinemeta for metadata:
|
||||
|
||||
```javascript
|
||||
var needle = require('needle')
|
||||
|
||||
// we will get metadata for the movie: Big Buck Bunny
|
||||
|
||||
var itemType = 'movie'
|
||||
var itemImdbId = 'tt1254207'
|
||||
|
||||
needle.get('https://v3-cinemeta.strem.io/meta/' + itemType + '/' + itemImdbId + '.json', function(err, resp, body) {
|
||||
|
||||
if (body && body.meta) {
|
||||
|
||||
// log Big Buck Bunny's metadata
|
||||
console.log(body.meta)
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Resolving Movie / Series names to IMDB ID
|
||||
|
||||
What if you have a movie or series name, but you need it's IMDB ID?
|
||||
|
||||
We recommend using [name-to-imdb](https://github.com/Ivshti/name-to-imdb) in this case, and it's really easy to use:
|
||||
|
||||
```javascript
|
||||
var nameToImdb = require("name-to-imdb");
|
||||
|
||||
nameToImdb({ name: "south park" }, function(err, res, inf) {
|
||||
console.log(res); // prints "tt0121955"
|
||||
console.log(inf); // inf contains info on where we matched that name - e.g. metadata, or on imdb
|
||||
})
|
||||
```
|
||||
|
||||
Also setting the `type` and `year` in the request helps on ensuring that the IMDB ID that is returned is correct.
|
||||
|
||||
|
||||
## Using User Data in Addons
|
||||
|
||||
**The Addon SDK now [supports user data](./api/responses/manifest.md#user-data), this part of the docs will remain here as it is valid for use with the Express module.**
|
||||
|
||||
This example does not use the Stremio Addon SDK, it uses Node.js and Express to serve replies.
|
||||
|
||||
User data is passed in the Addon Repository URL, so instead of users installing addons from the normal manifest url (for example: `https://www.mydomain.com/manifest.json`), users will also need to add the data they want to pass to the addon in the URL (for example: `https://www.mydomain.com/c9y2kz0c26c3w4csaqne71eu4jqko7e1/manifest.json`, where `c9y2kz0c26c3w4csaqne71eu4jqko7e1` could be their API Authentication Token)
|
||||
|
||||
Simplistic Example:
|
||||
|
||||
```javascript
|
||||
const express = require('express')
|
||||
const addon = express()
|
||||
|
||||
addon.get('/:someParameter/manifest.json', function (req, res) {
|
||||
res.send({
|
||||
id: 'org.parameterized.'+req.params.someParameter,
|
||||
name: 'addon for '+req.params.someParameter,
|
||||
resources: ['stream'],
|
||||
types: ['series'],
|
||||
})
|
||||
})
|
||||
|
||||
addon.get('/:someParameter/stream/:type/:id.json', function(req, res) {
|
||||
// @TODO do something depending on req.params.someParameter
|
||||
res.send({ streams: [] })
|
||||
})
|
||||
|
||||
addon.listen(7000, function() {
|
||||
console.log('http://127.0.0.1:7000/[someParameter]/manifest.json')
|
||||
})
|
||||
```
|
||||
|
||||
This is not a working example, it simply shows how data can be inserted by users in the Addon Repository URL so addons can then make use of it.
|
||||
|
||||
For working examples, you can check these addons:
|
||||
- [IMDB Lists](https://github.com/jaruba/stremio-imdb-list)
|
||||
- [IMDB Watchlist](https://github.com/jaruba/stremio-imdb-watchlist)
|
||||
- [Jackett Addon for Stremio](https://github.com/BoredLama/stremio-jackett-addon) (community built)
|
||||
|
||||
Another use case for passing user data through the Addon Repository URL is creating proxy addons. This case presumes that the id of a different addon is sent in the Addon Repository URL, then the proxy addon connects to the addon of which the id it got, requests streams, passes the stream url to some API (for example Real Debrid, Premiumize, etc) to get a different streaming url that it then responds with for Stremio.
|
||||
|
||||
|
||||
## Creating Addon Configuration Pages
|
||||
|
||||
This guide extends [Using User Data in Addons](#using-user-data-in-addons) by explaining how to create an Addon Configuration Page.
|
||||
|
||||
In order to allow Addons to request user data, you will first need to create a web page on the `/configure` path of your addon and set `manifest.behaviorHints.configurable` to `true`. (you can also set `manifest.behaviorHints.configurationRequired` to `true` if your addon cannot be installed without user data)
|
||||
|
||||
The `/configure` web page should include a form with all required data, the form data should be set within the Addon Repository URL (as explained in the [Using User Data in Addons](#using-user-data-in-addons)) section.
|
||||
|
||||
An "Install Addon" button should be available on the page that will use the `stremio://` protocol, for example, if your Addon Repository URL is `https://my.addon.com/some-user-data/manifest.json`, the "Install Addon" button should point to `stremio://my.addon.com/some-user-data/manifest.json`. (the `stremio://` protocol links will open or focus the Stremio app with a prompt to install the addon)
|
||||
|
||||
|
||||
## Using Deep Links in Addons
|
||||
|
||||
Stremio supports [deep links](./deep-links.md), such links can also be used in addons to link internally to Stremio.
|
||||
|
||||
First, set the `stream` resource in your [manifest](./api/responses/manifest.md):
|
||||
- `resources: ["stream"]`
|
||||
|
||||
Here's an example:
|
||||
|
||||
```javascript
|
||||
// this responds with one stream for the Big Buck Bunny
|
||||
// movie, that if clicked, will redirect Stremio to the
|
||||
// Board page
|
||||
builder.defineStreamHandler(function(args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (args.type === 'movie' && args.id === 'tt1254207') {
|
||||
// serve one stream for big buck bunny
|
||||
const stream = { externalUrl: 'stremio:///board' }
|
||||
resolve({ streams: [stream] })
|
||||
} else {
|
||||
reject(new Error('No streams found for: ' + args.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Proxying Other Addons
|
||||
|
||||
Stremio addons use a HTTP server to handle requests and responses, this means that other addons can also request their responses.
|
||||
|
||||
This can be useful for many reasons, a guide on how this can be done is included in the readme of the [IMDB Watchlist](https://github.com/jaruba/stremio-imdb-watchlist) Addon which proxies the [IMDB Lists](https://github.com/jaruba/stremio-imdb-list) addon to get the IMDB List for a particular IMDB user.
|
||||
|
||||
IMDB Watchlist only proxies the catalog from IMDB Lists, to proxy other resources you can use the same pattern as IMDB Watchlist does, and check the endpoints and patterns for other resources on the [Protocol Documentation](./protocol.md) page.
|
||||
|
||||
|
||||
|
||||
## Crawler / Scraping Addons
|
||||
|
||||
Scraping HTML pages presumes downloading the HTML source of a web page in order to get specific data from it.
|
||||
|
||||
A guide showing a simplistic version of doing this is in the readme of the [IMDB Watchlist Addon](https://github.com/jaruba/stremio-imdb-watchlist). The addon uses [needle](https://www.npmjs.com/package/needle) to request the HTML source and [cheerio](https://www.npmjs.com/package/cheerio) to start a jQuery instance in order to simplify getting the desired information.
|
||||
|
||||
Cheerio is not the only module that can help with crawling / scraping though, other modules that can aid in this: [jsdom](https://www.npmjs.com/package/jsdom), [xpath](https://www.npmjs.com/package/xpath), etc
|
||||
41
Docs/Stremio addons refer/api/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Resources
|
||||
|
||||
In order for Stremio to display addon data, the addon must first supply the resource for it. There are different types of resources, the only mandatory resource is the `manifest`, addons can choose to supply either one or more of the other available resources.
|
||||
|
||||
|
||||
| **Resource** | **Handler** | **Response** | **Description** |
|
||||
| ------------- | ------------- | ------------- | ------------- |
|
||||
| **manifest** | - | [manifest](./responses/manifest.md) | The addon description and capabilities. |
|
||||
| **catalog** | [defineCatalogHandler](./requests/defineCatalogHandler.md) | [meta_preview](./responses/meta.md#meta-preview-object) | Summarized collection of meta preview items. Catalogs are displayed on the Stremio's Board, Discover and Search. |
|
||||
| **metadata** | [defineMetaHandler](./requests/defineMetaHandler.md) | [meta](./responses/meta.md) | Detailed description of meta item. This description is displayed when the user selects an item form the catalog. |
|
||||
| **streams** | [defineStreamHandler](./requests/defineStreamHandler.md) | [stream](./responses/stream.md) | Tells Stremio how to obtain the media content. It may be torrent info hash, HTTP URL, etc |
|
||||
| **subtitles** | [defineSubtitlesHandler](./requests/defineSubtitlesHandler.md) | [subtitles](./responses/subtitles.md) | Subtitles resource for the chosen media. |
|
||||
| **addon_catalog** | [defineResourceHandler](./requests/defineResourceHandler.md) | [addon_catalog](./responses/addon_catalog.md) | A catalog (list) of other addon manifests. |
|
||||
|
||||
|
||||
The structure of those resources in Stremio is as follows:
|
||||
|
||||
```
|
||||
+-- Catalog
|
||||
+-- Meta Item
|
||||
+-- Videos (part of Meta Item)
|
||||
+---+-- Streams
|
||||
+---+---+-- Subtitles
|
||||
```
|
||||
|
||||
When the user opens the Discover/Board section, catalogs from all installed addons are loaded. Catalog responses include [meta preview objects](./responses/meta.md#meta-preview-object), which are just stripped down versions of the full meta object.
|
||||
|
||||
Once a user clicks on a specific item, the Detail page is opened, and the full meta object is loaded by requesting all relevant (see [filtering](#filtering)) addons.
|
||||
|
||||
Then, the user will pick a video (e.g. episode) from that meta object, which will trigger loading streams from all relevant addons. If the meta item has only one video object (as is the case with movies), streams will be requested as soon as the Detail page is opened.
|
||||
|
||||
|
||||
## Filtering
|
||||
|
||||
We determine whether an addon is relevant by comparing the request against it's manifest.
|
||||
|
||||
For catalogs, we usually request all catalogs from all addons, that are compatible with the `extra` properties that we're looking for. For example, to load the Board, we'd load all catalogs that have no `extra` properties that are required. But, if we're loading Search, we'd load all catalogs that have `search` as a supported property in their `extra` definition.
|
||||
|
||||
For other requests (meta, stream, subtitles), we apply the `types` and the optional `idPrefixes` filters (which can also be defined per-resource). For example, for `/meta/movie/tt1254207`, we'd try to load meta from all addons that have `"movie"` in `manifest.types` (or have `{ name: "meta", types: ["movie'] }` in `manifest.resources`). If `manifest.idPrefixes` is defined, `["tt"]` will match this request, but something different (e.g. `["yt_id:"]`) won't. This helps you ensure your addon does not get irrelevant requests.
|
||||
|
||||
For the full spec, see [manifest - filtering properties](./responses/manifest.md#filtering-properties).
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
## defineCatalogHandler
|
||||
|
||||
This method handles catalog requests, including search.
|
||||
|
||||
|
||||
### Arguments:
|
||||
|
||||
`args` - request object; parameters described below
|
||||
|
||||
### Returns:
|
||||
|
||||
A promise that resolves to an object containing `{ metas: [] }` with an array of [Meta Preview Object](../responses/meta.md#meta-preview-object)
|
||||
|
||||
The resolving object can also include the following cache related properties:
|
||||
|
||||
- `{ cacheMaxAge: int }` (in seconds) which sets the `Cache-Control` header to `max-age=$cacheMaxAge` and overwrites the global cache time set in `serveHTTP` [options](../../README.md#servehttpaddoninterface-options)
|
||||
|
||||
- `{ staleRevalidate: int }` (in seconds) which sets the `Cache-Control` header to `stale-while-revalidate=$staleRevalidate`
|
||||
|
||||
- `{ staleError: int }` (in seconds) which sets the `Cache-Control` header to `stale-if-error=$staleError`
|
||||
|
||||
|
||||
## Request Parameters
|
||||
|
||||
``type`` - type of the catalog's content; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](../responses/content.types.md))
|
||||
|
||||
``id`` - string id of the catalog that is requested; these are set in the [Manifest Object](../responses/manifest.md)
|
||||
|
||||
``extra`` - object that holds additional properties; defined below
|
||||
|
||||
``config`` - object with user settings, see [Manifest - User Data](../responses/manifest.md#user-data)
|
||||
|
||||
|
||||
## Extra Parameters
|
||||
|
||||
If you wish to use these parameters, you'll need to specify them in `extra` for the catalog in the [addon manifest](../responses/manifest.md#extra-properties)
|
||||
|
||||
``search`` - set in the `extra` object; string to search for in the catalog
|
||||
|
||||
``genre`` - set in the `extra` object; a string to filter the feed or search results by genres
|
||||
|
||||
``skip`` - set in the `extra` object; used for catalog pagination, refers to the number of items skipped from the beginning of the catalog; the standard page size in Stremio is 100, so the `skip` value will be a multiple of 100; if you return less than 100 items, Stremio will consider this to be the end of the catalog
|
||||
|
||||
|
||||
## Basic Example
|
||||
|
||||
|
||||
```javascript
|
||||
builder.defineCatalogHandler(function(args) {
|
||||
if (args.type === 'movie' && args.id === 'top') {
|
||||
|
||||
// we will only respond with Big Buck Bunny
|
||||
// to both feed and search requests
|
||||
|
||||
const meta = {
|
||||
id: 'tt1254207',
|
||||
name: 'Big Buck Bunny',
|
||||
releaseInfo: '2008',
|
||||
poster: 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg',
|
||||
posterShape: 'poster',
|
||||
banner: 'https://image.tmdb.org/t/p/original/aHLST0g8sOE1ixCxRDgM35SKwwp.jpg',
|
||||
type: 'movie'
|
||||
}
|
||||
|
||||
if (args.extra && args.extra.search) {
|
||||
|
||||
// catalog search request
|
||||
|
||||
if (args.extra.search == 'big buck bunny') {
|
||||
return Promise.resolve({ metas: [meta] })
|
||||
} else {
|
||||
return Promise.resolve({ metas: [] })
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// catalog feed request
|
||||
|
||||
return Promise.resolve({ metas: [meta] })
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
// otherwise return empty catalog
|
||||
return Promise.resolve({ metas: [] })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
[Meta Preview Object Definition](../responses/meta.md#meta-preview-object)
|
||||
53
Docs/Stremio addons refer/api/requests/defineMetaHandler.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
## defineMetaHandler
|
||||
|
||||
This method handles metadata requests. (title, releaseInfo, poster, background, etc.)
|
||||
|
||||
### Arguments:
|
||||
|
||||
`args` - request object; parameters described below
|
||||
|
||||
### Returns:
|
||||
|
||||
A promise resolving to an object containing `{ meta: {} }` with a [Meta Object](../responses/meta.md)
|
||||
|
||||
The resolving object can also include the following cache related properties:
|
||||
|
||||
- `{ cacheMaxAge: int }` (in seconds) which sets the `Cache-Control` header to `max-age=$cacheMaxAge` and overwrites the global cache time set in `serveHTTP` [options](../../README.md#servehttpaddoninterface-options)
|
||||
|
||||
- `{ staleRevalidate: int }` (in seconds) which sets the `Cache-Control` header to `stale-while-revalidate=$staleRevalidate`
|
||||
|
||||
- `{ staleError: int }` (in seconds) which sets the `Cache-Control` header to `stale-if-error=$staleError`
|
||||
|
||||
|
||||
## Request Parameters
|
||||
|
||||
``type`` - type of the item; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](../responses/content.types.md))
|
||||
|
||||
``id`` - string id of the meta item that is requested; these are set in the [Meta Preview Object](../responses/meta.md#meta-preview-object)
|
||||
|
||||
``config`` - object with user settings, see [Manifest - User Data](../responses/manifest.md#user-data)
|
||||
|
||||
|
||||
## Basic Example
|
||||
|
||||
```javascript
|
||||
builder.defineMetaHandler(function(args) {
|
||||
if (args.type === 'movie' && args.id === 'tt1254207') {
|
||||
// serve metadata for Big Buck Bunny
|
||||
const metaObj = {
|
||||
id: 'tt1254207',
|
||||
name: 'Big Buck Bunny',
|
||||
releaseInfo: '2008',
|
||||
poster: 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg',
|
||||
posterShape: 'poster',
|
||||
type: 'movie'
|
||||
}
|
||||
return Promise.resolve({ meta: metaObj })
|
||||
} else {
|
||||
// otherwise return no meta
|
||||
return Promise.resolve({ meta: {} })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
[Meta Object Definition](../responses/meta.md)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
## defineResourceHandler
|
||||
|
||||
This method currently handles addon catalog requests. As opposed to `defineCatalogHandler()` which handles meta catalogs, this method handles catalogs of addon manifests. This means that an addon can be used to just pass a list of other addons that can be installed in Stremio.
|
||||
|
||||
|
||||
### Arguments:
|
||||
|
||||
`args` - request object; parameters described below
|
||||
|
||||
### Returns:
|
||||
|
||||
A promise that resolves to an object containing `{ addons: [] }` with an array of [Catalog Addon Object](../responses/addon_catalog.md)
|
||||
|
||||
The resolving object can also include the following cache related properties:
|
||||
|
||||
- `{ cacheMaxAge: int }` (in seconds) which sets the `Cache-Control` header to `max-age=$cacheMaxAge` and overwrites the global cache time set in `serveHTTP` [options](../../README.md#servehttpaddoninterface-options)
|
||||
|
||||
- `{ staleRevalidate: int }` (in seconds) which sets the `Cache-Control` header to `stale-while-revalidate=$staleRevalidate`
|
||||
|
||||
- `{ staleError: int }` (in seconds) which sets the `Cache-Control` header to `stale-if-error=$staleError`
|
||||
|
||||
|
||||
## Request Parameters
|
||||
|
||||
``type`` - type of the catalog's content; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](../responses/content.types.md))
|
||||
|
||||
``id`` - string id of the catalog that is requested; these are set in the [Manifest Object](../responses/manifest.md)
|
||||
|
||||
``config`` - object with user settings, see [Manifest - User Data](../responses/manifest.md#user-data)
|
||||
|
||||
|
||||
## Basic Example
|
||||
|
||||
|
||||
```javascript
|
||||
builder.defineResourceHandler('addon_catalog', function(args) {
|
||||
return Promise.resolve({
|
||||
addons: [
|
||||
{
|
||||
transportName: 'http',
|
||||
transportUrl: 'https://example.addon.org/manifest.json',
|
||||
manifest: {
|
||||
id: 'org.myexampleaddon',
|
||||
version: '1.0.0',
|
||||
name: 'simple example',
|
||||
catalogs: [],
|
||||
resources: ['stream'],
|
||||
types: ['movie'],
|
||||
idPrefixes: ['tt']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
[Catalog Addon Object Definition](../responses/addon_catalog.md)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
## defineStreamHandler
|
||||
|
||||
This method handles stream requests.
|
||||
|
||||
### Arguments:
|
||||
|
||||
`args` - request object; parameters described below
|
||||
|
||||
### Returns:
|
||||
|
||||
A promise resolving to an an object containing `{ streams: [] }` with an array of [Stream Objects](../responses/stream.md). The streams should be ordered from highest to lowest quality
|
||||
|
||||
The resolving object can also include the following cache related properties:
|
||||
|
||||
- `{ cacheMaxAge: int }` (in seconds) which sets the `Cache-Control` header to `max-age=$cacheMaxAge` and overwrites the global cache time set in `serveHTTP` [options](../../README.md#servehttpaddoninterface-options)
|
||||
|
||||
- `{ staleRevalidate: int }` (in seconds) which sets the `Cache-Control` header to `stale-while-revalidate=$staleRevalidate`
|
||||
|
||||
- `{ staleError: int }` (in seconds) which sets the `Cache-Control` header to `stale-if-error=$staleError`
|
||||
|
||||
|
||||
## Request Parameters
|
||||
|
||||
``type`` - type of the item that we're requesting streams for; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](../responses/content.types.md))
|
||||
|
||||
``id`` - a Video ID as described in the [Video Object](../responses/meta.md#video-object)
|
||||
|
||||
**The Video ID is the same as the Meta ID for movies**.
|
||||
|
||||
For IMDb series (provided by Cinemeta), the video ID is formed by joining the Meta ID, season and episode with a colon (e.g. `"tt0898266:9:17"`).
|
||||
|
||||
``config`` - object with user settings, see [Manifest - User Data](../responses/manifest.md#user-data)
|
||||
|
||||
## Basic Example
|
||||
|
||||
```javascript
|
||||
builder.defineStreamHandler(function(args) {
|
||||
if (args.type === 'movie' && args.id === 'tt1254207') {
|
||||
// serve one stream for big buck bunny
|
||||
const stream = { url: 'http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_30fps_normal.mp4' }
|
||||
return Promise.resolve({ streams: [stream] })
|
||||
} else {
|
||||
// otherwise return no streams
|
||||
return Promise.resolve({ streams: [] })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
[Stream Object Definition](../responses/stream.md)
|
||||
|
||||
_Note: You may require additional metadata for the requested item (such as name, releaseInfo, etc), if the requested ID is a IMDB ID (Cinemeta, for example, uses only IMDB IDs), then please refer to [Getting Metadata from Cinemeta](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/advanced.md#getting-metadata-from-cinemeta) for this purpose._
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
## defineSubtitlesHandler
|
||||
|
||||
This method handles subtitle requests.
|
||||
|
||||
### Arguments:
|
||||
|
||||
`args` - request object; parameters defined below
|
||||
|
||||
### Returns:
|
||||
|
||||
A promise resolving to an object containing `{ subtitles: [] }` with an array of [Subtitle Objects](../responses/subtitles.md).
|
||||
|
||||
The resolving object can also include the following cache related properties:
|
||||
|
||||
- `{ cacheMaxAge: int }` (in seconds) which sets the `Cache-Control` header to `max-age=$cacheMaxAge` and overwrites the global cache time set in `serveHTTP` [options](../../README.md#servehttpaddoninterface-options)
|
||||
|
||||
- `{ staleRevalidate: int }` (in seconds) which sets the `Cache-Control` header to `stale-while-revalidate=$staleRevalidate`
|
||||
|
||||
- `{ staleError: int }` (in seconds) which sets the `Cache-Control` header to `stale-if-error=$staleError`
|
||||
|
||||
|
||||
## Request Parameters
|
||||
|
||||
``type`` - type of the item that we're requesting subtitles for; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](../responses/content.types.md))
|
||||
|
||||
``id`` - string id of the video that we're requesting subtitles for (videoId); see [Meta Object](../responses/meta.md)
|
||||
|
||||
``extra`` - object that holds additional properties; parameters defined below
|
||||
|
||||
``config`` - object with user settings, see [Manifest - User Data](../responses/manifest.md#user-data)
|
||||
|
||||
|
||||
## Extra Parameters
|
||||
|
||||
``videoHash`` - string [OpenSubtitles file hash](http://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes) for the video
|
||||
|
||||
``videoSize`` - size of the video file in bytes
|
||||
|
||||
``filename`` - filename of the video file
|
||||
|
||||
|
||||
## Basic Example
|
||||
|
||||
```javascript
|
||||
builder.defineSubtitlesHandler(function(args) {
|
||||
if (args.id === 'tt1254207') {
|
||||
// serve one subtitle for big buck bunny
|
||||
const subtitle = {
|
||||
url: 'https://mkvtoolnix.download/samples/vsshort-en.srt',
|
||||
lang: 'eng'
|
||||
}
|
||||
return Promise.resolve({ subtitles: [subtitle] })
|
||||
} else {
|
||||
// otherwise return no subtitles
|
||||
return Promise.resolve({ subtitles: [] })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
[Subtitle Object Definition](../responses/subtitles.md)
|
||||
9
Docs/Stremio addons refer/api/responses/addon_catalog.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
## Addon Catalog Object
|
||||
|
||||
Used as a response for [`defineResourceHandler`](../requests/defineResourceHandler.md)
|
||||
|
||||
### Properties
|
||||
|
||||
* ``transportName`` - **required** - string, only `http` is currently officially supported
|
||||
* ``transportUrl`` - **required** - string, the URL of the addon's `manifest.json` file
|
||||
* ``manifest`` - **required** - object representing the addon's [Manifest Object](./manifest.md)
|
||||
10
Docs/Stremio addons refer/api/responses/content.types.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
## Content types
|
||||
|
||||
**Stremio supports the following content types as of Apr 2016:**
|
||||
|
||||
* ``movie`` - movie type - has metadata like name, genre, description, director, actors, images, etc.
|
||||
* ``series`` - series type - has all the metadata a movie has, plus an array of episodes
|
||||
* ``channel`` - channel type - created to cover YouTube channels; has name, description and an array of uploaded videos
|
||||
* ``tv`` - tv type - has name, description, genre; streams for ``tv`` should be live (without duration)
|
||||
|
||||
**If you think Stremio should add another content type, feel free to open an issue on this repository.**
|
||||
180
Docs/Stremio addons refer/api/responses/manifest.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
### Manifest format
|
||||
|
||||
The first thing to define for your addon is the manifest, which describes it's name, purpose and some technical details.
|
||||
|
||||
Valid properties are:
|
||||
|
||||
|
||||
## Basic information
|
||||
|
||||
``id`` - **required** - string, identifier, dot-separated, e.g. "com.stremio.filmon"
|
||||
|
||||
``name`` - **required** - string, human readable name
|
||||
|
||||
``description`` - **required** - string, human readable description
|
||||
|
||||
``version`` - **required** - string, [semantic version](https://semver.org/) of the addon
|
||||
|
||||
|
||||
## Filtering properties
|
||||
|
||||
**NOTE:** In order to understand the next properties better, please check out the [protocol documentation](../../protocol.md) and keep in mind requests to addons are formed in the format of `/{resource}/{type}/{id}`
|
||||
|
||||
``resources`` - **required** - array of objects or strings, supported resources - for example ``["catalog", "meta", "stream", "subtitles", "addon_catalog"]``, resources can also be added as objects instead of strings, for additional details on how they should be requested, example: `{ "name": "stream", "types": [ "movie" ], "idPrefixes": [ "tt" ] }` (see the **ADVANCED** note)
|
||||
|
||||
``types`` - **required** - array of strings, supported types, from all the [``Content Types``](./content.types.md). If you wish to provide different sets of types for different resources, see the **ADVANCED** note.
|
||||
|
||||
``idPrefixes`` - _optional_ - array of strings, use this if you want your addon to be called only for specific content IDs - for example, if you set this to `["yt_id:", "tt"]`, your addon will only be called for `id` values that start with `yt_id:` or `tt`. If you wish to provide different sets of `idPrefixes` for different resources, see the **ADVANCED** note.
|
||||
|
||||
### Advanced
|
||||
|
||||
A resource may either be a string (e.g. `"meta"`) or an object of the format `{ name, types, idPrefixes? }`.
|
||||
|
||||
The latter can be used to provide different `types` and `idPrefixes` for a particular resource. Those properties work in the same way as if you put them in the manifest directly.
|
||||
|
||||
Keep in mind, `idPrefixes` is always optional, and if you use the full notation without it (e.g. `{ name: "stream", types: ["movie"] }`), this would mean matching on all those types and all possible IDs.
|
||||
|
||||
If you just provide a string, the `types` and `idPrefixes` from the manifest will be applied for the resource.
|
||||
|
||||
The local addon is an example of a [complex resource description](https://github.com/Stremio/stremio-local-addon/blob/master/lib/manifest.js).
|
||||
|
||||
Please note, the `idPrefixes` filtering does not matter for the `"catalog"` resource, since this is a special case that will always be requested if defined in `catalogs`.
|
||||
|
||||
## Content catalogs
|
||||
|
||||
**NOTE:** Leave this an empty array (``[]``) if your addon does not provide the `catalog` resource.
|
||||
|
||||
``catalogs`` - **required** - array of [``Catalog objects``](#catalog-format), a list of the content catalogs your addon provides
|
||||
|
||||
### Catalog format
|
||||
|
||||
``type`` - **required** - string, this is the content type of the catalog
|
||||
|
||||
``id`` - **required** - string, the id of the catalog, can be any unique string describing the catalog (unique per addon, as an addon can have many catalogs), for example: if the catalog name is "Favourite Youtube Videos", the id can be `"fav_youtube_videos"`
|
||||
|
||||
``name`` - **required** - string, human readable name of the catalog
|
||||
|
||||
``extra`` - _optional_ - array of [``Extra objects``](#extra-properties), all extra properties related to this catalog; should be set to an array of `{ name, isRequired, options, optionsLimit }`
|
||||
|
||||
|
||||
#### Extra properties
|
||||
|
||||
Stremio can invoke `/catalog/{type}/{id}.json` for catalogs specified in `catalogs` in order to get the feed of [Meta Preview Objects](./meta.md#meta-preview-object).
|
||||
|
||||
It can also invoke `/catalog/{type}/{id}/{extraProps}.json` in which case `{extraProps}` will contain other properties such as a search query in order to search the catalog for a list of [Meta Preview Object](./meta.md#meta-preview-object) results.
|
||||
|
||||
``extra`` only needs to be set in certain cases, for example, these don't need to be set if your catalog only supports giving a feed of items, but not search them.
|
||||
|
||||
If your catalog supports full text searching, set `extra: [{ name: "search", isRequired: false }]`, if your catalog supports filtering by `genre`, set `extra: [{ name: "genre", isRequired: false }]`. But what if your catalog supports only searching, but not giving a feed? Then set `extra: [{ name: "search", isRequired: true }]` and your catalog will only be requested for searching, nothing else.
|
||||
|
||||
The format of `extra` is an array of `{ name, isRequired, options, optionsLimit }`, where:
|
||||
|
||||
* `name` - **required** - string, is the name of the property; this name will be used in the `extraProps` argument itself
|
||||
|
||||
* `isRequired` - _optional_ - boolean, set to true if this property must always be passed
|
||||
|
||||
* `options` - _optional_ - array of strings, possible values for this property; this is useful for things like genres, where you need the user to select from a pre-set list of options (e.g. `{ name: "genre", options: ["Action", "Comedy", "Drama"] }`);
|
||||
|
||||
* `optionsLimit` - _optional_ - number, the limit of values a user may select from the pre-set `options` list; by default, this is set to 1
|
||||
|
||||
|
||||
For a complete list of extra catalog properties that Stremio pays attention to, check the [Catalog Handler Definition](../requests/defineCatalogHandler.md)
|
||||
|
||||
If you're looking for the legacy way of setting extra properties (also called "short"), [check out the old docs](https://github.com/Stremio/stremio-addon-sdk/blob/b11bd517f8ce3b24a843de320ec8ac193611e9a0/docs/api/responses/manifest.md#catalog-format)
|
||||
|
||||
## Addon catalogs
|
||||
|
||||
``addonCatalogs`` - _optional_ - array of [``Catalog objects``](#addon-catalog-format), a list of other addon manifests, this can be used for an addon to act just as a catalog of other addons.
|
||||
|
||||
### Addon Catalog format
|
||||
|
||||
``type`` - **required** - string, this is the content type of the catalog
|
||||
|
||||
``id`` - **required** - string, the id of the catalog, can be any unique string describing the catalog (unique per addon, as an addon can have many catalogs), for example: if the catalog name is "Favourite Youtube Videos", the id can be `"fav_youtube_videos"`
|
||||
|
||||
``name`` - **required** - string, human readable name of the catalog
|
||||
|
||||
## User data
|
||||
|
||||
You can choose to accept user data for your addon, to do this you will need to first set `manifest.behaviorHints.configurable` to `true`, then set the `manifest.config` property.
|
||||
|
||||
When setting the `manifest.config` property, the landing page will redirect to `/configure` where there will be an auto-generated configuration page.
|
||||
|
||||
***TIP* - you can also set `manifest.behaviorHints.configurationRequired` to state that your addon does not work without user data**
|
||||
|
||||
* ``config`` - _optional_ - array of [``Config objects``](#config-format), a list of settings that users can set for your addon
|
||||
|
||||
### Config format
|
||||
|
||||
``key`` - _required_ - string, a key that will identify the user chosen value
|
||||
|
||||
``type`` - _required_ - string, can be "text", "number", "password", "checkbox" or "select"
|
||||
|
||||
``default`` - _optional_ - string, the default value, for `type: "boolean"` this can be set to "checked" to default to enabled
|
||||
|
||||
``title`` - _optional_ - string, the title of the setting
|
||||
|
||||
``options`` - _optional_ - array, the list of (string) choices for `type: "select"`
|
||||
|
||||
``required`` - _optional_ - boolean, if the value is required or not, only applies to the following types: "string", "number" (default is `false`)
|
||||
|
||||
***TIP* - if you require a more advanced configuration page, you can also [create this page yourself](../../advanced.md#using-user-data-in-addons) instead of using the Addon SDK.**
|
||||
|
||||
## Other metadata
|
||||
|
||||
``background`` - _optional_ - string, background image for the addon; URL to png/jpg, at least 1024x786 resolution
|
||||
|
||||
``logo`` - _optional_ - string, logo icon, URL to png, monochrome, 256x256
|
||||
|
||||
``contactEmail`` - _optional_ - string, contact email for addon issues; used for the Report button in the app; also, the Stremio team may reach you on this email for anything relating your addon
|
||||
|
||||
``behaviorHints`` - _all are optional_ - object, supports the properties:
|
||||
|
||||
- ``adult`` - boolean, if the addon includes adult content, default is `false`; used to provide an adequate warning to the user
|
||||
|
||||
- ``p2p`` - boolean, if the addon includes P2P content, such as BitTorrent, which may reveal the user's IP to other streaming parties; used to provide an adequate warning to the user
|
||||
|
||||
- ``configurable`` - boolean, default is `false`, if the addon supports settings, will add a button next to "Install" in Stremio that will point to the `/configure` path on the addon's domain, for more information read [User Data](#user-data) (or if you are not using the Addon SDK, read: [Advanced User Data](../../advanced.md#using-user-data-in-addons) and [Creating Addon Configuration Pages](../..//advanced.md#creating-addon-configuration-pages))
|
||||
|
||||
- ``configurationRequired`` - boolean, default is `false`, if set to `true` the "Install" button will not show for your addon in Stremio, instead a "Configure" button will show pointing to the `/configure` path on the addon's domain, for more information read [User Data](#user-data) (or if you are not using the Addon SDK, read: [Advanced User Data](../../advanced.md#using-user-data-in-addons) and [Creating Addon Configuration Pages](../..//advanced.md#creating-addon-configuration-pages))
|
||||
|
||||
|
||||
***TIP* - to implement sources where streams are geo-restricted, see [``Stream objects``](./stream.md) `geos`**
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```javascript
|
||||
{
|
||||
"id": "org.stremio.example",
|
||||
"version": "0.0.1",
|
||||
"description": "Example Stremio Addon",
|
||||
"name": "Example Addon",
|
||||
"resources": [
|
||||
"catalog",
|
||||
"stream"
|
||||
],
|
||||
"types": [
|
||||
"movie",
|
||||
"series"
|
||||
],
|
||||
"catalogs": [
|
||||
{
|
||||
"type": "movie",
|
||||
"id": "moviecatalog"
|
||||
}
|
||||
],
|
||||
"idPrefixes": ["tt"]
|
||||
}
|
||||
```
|
||||
|
||||
This manifest example is for an addon that:
|
||||
- provides streams and catalogs
|
||||
- has one catalog that includes movies
|
||||
- will receive stream requests for meta items that have an id that starts with `tt` (imdb id, example: `tt0068646`), for both movies and series
|
||||
|
||||
For more examples of addon manifests, see:
|
||||
|
||||
* https://github.com/Stremio/stremio-local-addon/blob/master/lib/manifest.js
|
||||
* https://stremio-public-domain-foreign.now.sh/manifest.json
|
||||
* https://v3-cinemeta.strem.io/manifest.json
|
||||
13
Docs/Stremio addons refer/api/responses/meta.links.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
## Meta Links
|
||||
|
||||
**Stremio supports the following meta links as of Dec 2019:**
|
||||
|
||||
* ``stremio:///search?search=${query}`` - opens the Search page with the set `${query}`
|
||||
|
||||
* ``stremio:///discover/${transportUrl}/${type}/${catalogId}?${extra}`` - opens the Discover page for the addon that has `${transportUrl}` (URL to an addon's [``Manifest``](./manifest.md)), with the [``Catalog``](./manifest.md#catalog-format) which has the id `${catalogId}` and the type `${type}`, the `?${extra}` is optional and refers to [``Catalog Extra Parameters``](../requests/defineCatalogHandler.md#extra-parameters) that should be passed as a [``Query String``](https://en.wikipedia.org/wiki/Query_string)
|
||||
|
||||
* ``stremio:///detail/${type}/${id}`` - opens the Detail page for the [``Meta Object``](./meta.md) with the id `${id}` and the type `${type}`
|
||||
|
||||
* ``stremio:///detail/${type}/${id}/${videoId}`` - opens the Detail page with Streams open for the [``Video``](./meta.md#video-object) with the id `${videoId}` for the [``Meta Object``](./meta.md) with the id `${id}` and the type `${type}`
|
||||
|
||||
**If you think Stremio should add another meta link, feel free to open an issue on this repository.**
|
||||
141
Docs/Stremio addons refer/api/responses/meta.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
## Meta Object
|
||||
|
||||
Used as a response for [`defineMetaHandler`](../requests/defineMetaHandler.md)
|
||||
|
||||
``id`` - **required** - string, universal identifier; you may use a [prefix](./manifest.md##filtering-properties) unique to your addon, for example `yt_id:UCrDkAvwZum-UTjHmzDI2iIw`
|
||||
|
||||
``type`` - **required** - string, type of the content; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](./content.types.md))
|
||||
|
||||
``name`` - **required** - string, name of the content
|
||||
|
||||
``genres`` - _optional_ - array of strings, genre/categories of the content; e.g. ``["Thriller", "Horror"]`` (warning: this will soon be deprecated in favor of ``links``)
|
||||
|
||||
``poster`` - _optional_ - string, URL to png of poster; accepted aspect ratios: 1:0.675 (IMDb poster type) or 1:1 (square) ; you can use any resolution, as long as the file size is below 100kb; below 50kb is recommended
|
||||
|
||||
``posterShape`` - _optional_ - string, can be `square` (1:1 aspect) or `poster` (1:0.675) or `landscape` (1:1.77). If you don't pass this, `poster` is assumed
|
||||
|
||||
``background`` - _optional_ - string, the background shown on the stremio detail page ; heavily encouraged if you want your content to look good; URL to PNG, max file size 500kb
|
||||
|
||||
``logo`` - _optional_ - string, the logo shown on the stremio detail page ; encouraged if you want your content to look good; URL to PNG
|
||||
|
||||
``description`` - _optional_ - string, a few sentences describing your content
|
||||
|
||||
``releaseInfo`` - _optional_ - string, year the content came out ; if it's ``series`` or ``channel``, use a start and end years split by a tide - e.g. ``"2000-2014"``. If it's still running, use a format like ``"2000-"``
|
||||
|
||||
``director``, ``cast`` - _optional_ - directors and cast, both arrays of names (string) (warning: this will soon be deprecated in favor of ``links``)
|
||||
|
||||
``imdbRating`` - _optional_ - string, IMDb rating, a number from 0.0 to 10.0 ; use if applicable
|
||||
|
||||
``released`` - _optional_ - string, ISO 8601, initial release date; for movies, this is the cinema debut, e.g. "2010-12-06T05:00:00.000Z"
|
||||
|
||||
``trailers`` - _optional_ - array, containing objects in the form of `{ "source": "P6AaSMfXHbA", "type": "Trailer" }`, where `source` is a YouTube Video ID and `type` can be either `Trailer` or `Clip` (warning: this will soon be deprecated in favor of `meta.trailers` being an array of [``Stream Objects``](./stream.md))
|
||||
|
||||
``links`` - _optional_ - array of [``Meta Link objects``](#meta-link-object), can be used to link to internal pages of Stremio, example usage: array of actor / genre / director links
|
||||
|
||||
``videos`` - _optional_ - array of [``Video objects``](#video-object), used for ``channel`` and ``series``; if you do not provide this (e.g. for ``movie``), Stremio assumes this meta item has one video, and it's ID is equal to the meta item `id`
|
||||
|
||||
``runtime`` - _optional_ - string, human-readable expected runtime - e.g. "120m"
|
||||
|
||||
``language`` - _optional_ - string, spoken language
|
||||
|
||||
``country`` - _optional_ - string, official country of origin
|
||||
|
||||
``awards`` - _optional_ - string, human-readable that describes all the significant awards
|
||||
|
||||
``website`` - _optional_ - string, URL to official website
|
||||
|
||||
``behaviorHints`` - _all are optional_ - object, supports the properties:
|
||||
|
||||
- ``defaultVideoId`` - string, set to a [``Video Object``](#video-object) id in order to open the Detail page directly to that video's streams
|
||||
|
||||
|
||||
#### Meta Link object
|
||||
|
||||
``name`` - **required** - string, human readable name for the link
|
||||
|
||||
``category`` - **required** - string, any unique category name, links are grouped based on their category, some recommended categories are: `actor`, `director`, `writer`, while the following categories are reserved and should not be used: `imdb`, `share`, `similar`
|
||||
|
||||
``url`` - **required** - string, an external URL or [``Meta Link``](./meta.links.md)
|
||||
|
||||
|
||||
#### Video object
|
||||
|
||||
``id`` - **required** - string, ID of the video
|
||||
|
||||
``title`` - **required** - string, title of the video
|
||||
|
||||
``released`` - **required** - string, ISO 8601, publish date of the video; for episodes, this should be the initial air date, e.g. "2010-12-06T05:00:00.000Z"
|
||||
|
||||
``thumbnail`` - _optional_ - string, URL to png of the video thumbnail, in the video's aspect ratio, max file size 5kb
|
||||
|
||||
``streams`` - _optional_ - array of [``Stream Objects``](./stream.md), in case you can return links to streams while forming meta response, **you can pass and array of [``Stream Objects``](./stream.md)** to point the video to a HTTP URL, BitTorrent, YouTube or any other stremio-supported transport protocol; note that this is exclusive: passing `video.streams` means that **Stremio will not** request any streams from other addons for that video; if you return streams that way, it is still recommended to implement the `streams` resource
|
||||
|
||||
``available`` - _optional_ - boolean, set to ``true`` to explicitly state that this video is available for streaming, from your addon; no need to use this if you've passed ``streams``
|
||||
|
||||
``episode`` - _optional_ - number, episode number, if applicable
|
||||
|
||||
``season`` - _optional_ - number, season number, if applicable
|
||||
|
||||
``trailers`` - _optional_ - array, containing [``Stream Objects``](./stream.md)
|
||||
|
||||
``overview`` - _optional_ - string, video overview/summary
|
||||
|
||||
|
||||
##### Video object - series example
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "tt0108778:1:1",
|
||||
title: "Pilot",
|
||||
released: new Date("1994-09-22 20:00 UTC+02"),
|
||||
season: 1,
|
||||
episode: 1,
|
||||
overview: "Monica and the gang introduce Rachel to the real world after she leaves her fiancé at the altar."
|
||||
}
|
||||
```
|
||||
|
||||
You can see a comprehensive example of how detailed Meta objects with videos are returned [here, on the Cinemeta addon](https://v3-cinemeta.strem.io/meta/series/tt0386676/lastVideos=1.json)
|
||||
|
||||
##### Video object - YouTube video example (channels)
|
||||
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "yt_id:UCrDkAvwZum-UTjHmzDI2iIw:9bZkp7q19f0",
|
||||
title: "PSY - GANGNAM STYLE",
|
||||
released: new Date("2012-07-15 20:00 UTC+02"),
|
||||
thumbnail: "https://i.ytimg.com/vi/9bZkp7q19f0/hqdefault.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
## Meta Preview Object
|
||||
|
||||
This is a shorter variant of the previously described [Meta Object](#meta-object)
|
||||
|
||||
Used as a response for [`defineCatalogHandler`](../requests/defineCatalogHandler.md)
|
||||
|
||||
``id`` - **required** - string, universal identifier; you may use a [prefix](./manifest.md##filtering-properties) unique to your addon, for example `yt_id:UCrDkAvwZum-UTjHmzDI2iIw`
|
||||
|
||||
``type`` - **required** - string, type of the content; e.g. `movie`, `series`, `channel`, `tv` (see [Content Types](./content.types.md))
|
||||
|
||||
``name`` - **required** - string, name of the content
|
||||
|
||||
``poster`` - **required** - string, URL to png of poster; accepted aspect ratios: 1:0.675 (IMDb poster type) or 1:1 (square); you can use any resolution, as long as the file size is below 100kb; below 50kb is recommended; also used as the background shown on the stremio discover page in the sidebar
|
||||
|
||||
``posterShape`` - _optional_ - string, can be `square` (1:1 aspect) or `poster` (1:0.675) or `landscape` (1:1.77). If you don't pass this, `poster` is assumed
|
||||
|
||||
#### Additional Parameters that are used for the Discover Page Sidebar:
|
||||
|
||||
``genres`` - _optional_ - array of strings, genre/categories of the content; e.g. ``["Thriller", "Horror"]`` (warning: this will soon be deprecated in favor of ``links``)
|
||||
|
||||
``imdbRating`` - _optional_ - string, IMDb rating, a number from 0.0 to 10.0 ; use if applicable
|
||||
|
||||
``releaseInfo`` - _optional_ - string, year the content came out ; if it's ``series`` or ``channel``, use a start and end years split by a tide - e.g. ``"2000-2014"``. If it's still running, use a format like ``"2000-"``
|
||||
|
||||
``director``, ``cast`` - _optional_ - directors and cast, both arrays of names (string) (warning: this will soon be deprecated in favor of ``links``)
|
||||
|
||||
``links`` - _optional_ - array of [``Meta Link objects``](#meta-link-object), can be used to link to internal pages of Stremio, example usage: array of actor / genre / director links
|
||||
|
||||
``description`` - _optional_ - string, a few sentances describing your content
|
||||
|
||||
``trailers`` - _optional_ - array, containing objects in the form of `{ "source": "P6AaSMfXHbA", "type": "Trailer" }`, where `source` is a YouTube Video ID and `type` can be either `Trailer` or `Clip` (warning: this will soon be deprecated in favor of `meta.trailers` being an array of [``Stream Objects``](./stream.md))
|
||||
58
Docs/Stremio addons refer/api/responses/stream.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
## Stream Object
|
||||
|
||||
Used as a response for [`defineStreamHandler`](../requests/defineStreamHandler.md)
|
||||
|
||||
**One of the following must be passed** to point to the stream itself
|
||||
|
||||
* ``url`` - string, direct http(s)/ftp(s)/rtmp link to a video stream (protocol support can vary depending on client app capabilities)
|
||||
* ``ytId`` - string, youtube video ID, plays using the built-in YouTube player
|
||||
* ``infoHash`` - string, info hash of a torrent file, and `fileIdx` is the index of the video file within the torrent; **if fileIdx is not specified, the largest file in the torrent will be selected**
|
||||
* ``fileIdx`` - number, the index of the video file within the torrent (from `infoHash`), nzb (from `nzbUrl`), rar (from `rarUrls`, zip (from `zipUrls`), 7zip (from `7zipUrls`), tgz (from `tgzUrls`), tar (from `tarUrls`)); (for torrents if fileIdx is not specified, the largest file will be selected)
|
||||
* ``fileMustInclude`` - string, a string representing a regex (example `"/.mkv$|.mp4$|.avi$|.ts$/i"`) to match the video file within the nzb (from `nzbUrl`), rar (from `rarUrls`, zip (from `zipUrls`), 7zip (from `7zipUrls`), tgz (from `tgzUrls`), tar (from `tarUrls`)); (not supported for torrents yet)
|
||||
* ``nzbUrl`` - string, http(s) or ftp(s) link to a NZB (usenet) file (this source will also unpack any known archive files), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``servers`` - array, a list of strings that each represent a connection to a NNTP (usenet) server (for `nzbUrl`) in the form of `nntp(s)://{user}:{pass}@{nntpDomain}:{nntpPort}/{nntpConnections}` (`nntps` = SSL; `nntp` = no encryption) (example: `nntps://myuser:mypass@news.example.com:563/4`)
|
||||
* ``rarUrls`` - array, a list of [``Source Objects``](#source-objects) that lead to rar files (multi-volume supported), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``zipUrls`` - array, a list of [``Source Objects``](#source-objects) that lead to zip files (multi-volume supported), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``7zipUrls`` - array, a list of [``Source Objects``](#source-objects) that lead to 7z files (multi-volume supported), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``tgzUrls`` - array, a list of [``Source Objects``](#source-objects) that lead to tgz files (multi-volume supported), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``tarUrls`` - array, a list of [``Source Objects``](#source-objects) that lead to tar files (TAR does not support multi-volume), also see: [``Source Limitations``](#source-limitations)
|
||||
* ``externalUrl`` - string, [``Meta Link``](./meta.links.md) or an external URL to the video, which should be opened in a browser (webpage), e.g. link to Netflix
|
||||
|
||||
### Additional properties to provide information / behaviour flags
|
||||
|
||||
- ``name`` - _optional_ - string, name of the stream; usually used for stream quality
|
||||
|
||||
- ``title`` - _optional_ - string, description of the stream (warning: this will soon be deprecated in favor of `stream.description`)
|
||||
|
||||
- ``description`` - _optional_ - string, description of the stream (previously `stream.title`)
|
||||
|
||||
- ``subtitles`` - _optional_ - array of [``Subtitle objects``](./subtitles.md) representing subtitles for this stream
|
||||
|
||||
- ``sources`` - _optional_ - array of strings, represents a list of torrent tracker URLs and DHT network nodes. This attribute can be used to provide additional peer discovery options when `infoHash` is also specified, but it is not required. If used, each element can be a tracker URL (`tracker:<protocol>://<host>:<port>`) where `<protocol>` can be either `http` or `udp`. A DHT node (`dht:<node_id/info_hash>`) can also be included.
|
||||
> **WARNING**: Use of DHT may be prohibited by some private trackers as it exposes torrent activity to a broader network, potentially finding more peers.
|
||||
|
||||
- `behaviorHints` (all are optional)
|
||||
- `countryWhitelist`: which hints it's restricted to particular countries - array of ISO 3166-1 alpha-3 country codes **in lowercase** in which the stream is accessible
|
||||
- `notWebReady`: applies if the protocol of the url is http(s); needs to be set to `true` if the URL does not support https or is not an MP4 file
|
||||
- `bingeGroup`: if defined, addons with the same `behaviorHints.bingeGroup` will be chosen automatically for binge watching; this should be something that identifies the stream's nature within your addon: for example, if your addon is called "gobsAddon", and the stream is 720p, the bingeGroup should be "gobsAddon-720p"; if the next episode has a stream with the same `bingeGroup`, stremio should select that stream implicitly
|
||||
- `proxyHeaders`: only applies to `url`s; **When using this property, you must also set `stream.behaviorHints.notWebReady: true`**; This is an object containing `request` and `response` which include the headers that should be used for the stream (example value: `{ "request": { "User-Agent": "Stremio" } }`)
|
||||
- `videoHash`: - string, the calculated [OpenSubtitles hash](http://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes) of the video, this will be used when the streaming server is not connected (so the hash cannot be calculated locally), this value is passed to subtitle addons to identify correct subtitles
|
||||
- `videoSize`: - number, size of the video file in bytes, this value is passed to the subtitle addons to identify correct subtitles
|
||||
- `filename`: - string, filename of the video file, although optional, it is highly recommended to set it when using `stream.url` (when possible) in order to identify correct subtitles (addon sdk will show a warning if it is not set in this case), this value is passed to the subtitle addons to identify correct subtitles
|
||||
|
||||
### Source Objects
|
||||
|
||||
An object representing a streaming source, supports the following properties:
|
||||
|
||||
* ``url`` - **required** - string, direct http(s)/ftp(s) link to a file (depending on context: zip, rar, 7z, tar, tgz)
|
||||
* ``bytes`` - _optional_ - integer, size of the file in bytes - while optional adding this can speed up the initial buffering
|
||||
|
||||
### Source Limitations
|
||||
|
||||
- nzb: will also unpack all known archive types, a nzb files cannot be streamed unless both the first and last segment of the video file are retrievable, PAR2 recovery is not supported
|
||||
- rar: multi-volume and seeking in the video supported, decompression is not supported (decompression is not normally required for audio / video files)
|
||||
- zip: multi-volume and decompression are supported, it does not support seeking in the video
|
||||
- 7zip: multi-volume and LZMA decompression are support, it supports seeking only when compression is not used (decompression is not normally required for audio / video files)
|
||||
- tar: does not support multi-volume and decompression by design (tar only merges multiple files into one without compressing), seeking is supported
|
||||
- tgz: multi-volume and decompression are supported, it does not support seeking in the video
|
||||
|
||||
15
Docs/Stremio addons refer/api/responses/subtitles.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
## Subtitles Object
|
||||
|
||||
Used as a response for [`defineSubtitlesHandler`](../requests/defineSubtitlesHandler.md)
|
||||
|
||||
``id`` - **required** - string, unique identifier for each subtitle, if you have more than one subtitle with the same language, the id will differentiate them
|
||||
|
||||
``url`` - **required** - string, url to the subtitle file
|
||||
|
||||
``lang`` - **required** - string, language code for the subtitle, if a valid ISO 639-2 code is not sent, the text of this value will be used instead
|
||||
|
||||
|
||||
### Tips
|
||||
|
||||
- When creating subtitle addons, incorrectly encoded subtitles may be an issue, in this case you can set the `url` response to `http://127.0.0.1:11470/subtitles.vtt?from=` followed by the URL to the subtitle file, this will force the local streaming server to guess the subtitle encoding when loading it
|
||||
- You can also link to subtitle files from inside torrents, but you need to know the file index of the subtitle files from the torrent file list. An example of a link pointing to a subtitle inside a torrent is `http://127.0.0.1:11470/6366e0a6d44d49c8fa09c04669375c024e42bf7e/3`, where `6366e0a6d44d49c8fa09c04669375c024e42bf7e` is the torrent infohash, and `3` is the file index of the subtitle file in the torrent. When linking to subtitle files inside torrents it is recommended to use the `subtitles` property from the [Stream Object](./stream.md)
|
||||
71
Docs/Stremio addons refer/deep-links.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Stremio - Deep links
|
||||
|
||||
Stremio supports two types of deep links through the `stremio://` protocol
|
||||
|
||||
**NOTE:** GitHub does not allow links with a custom protocol, so just copy-paste the examples links in your browser's address bar and press Enter.
|
||||
|
||||
**Support for intents varies depending on platform.**
|
||||
|
||||
## To addons
|
||||
|
||||
Simply take a normal URL to a Stremio addon manifest, e.g. `https://watchhub-us.strem.io/manifest.json`, and replace the leading `https://` with `stremio://`
|
||||
|
||||
E.g. [stremio://watchhub-us.strem.io/manifest.json](stremio://watchhub-us.strem.io/manifest.json)
|
||||
|
||||
|
||||
## To a page
|
||||
|
||||
|
||||
### Board
|
||||
|
||||
[stremio:///board](stremio:///board)
|
||||
|
||||
|
||||
### Discover
|
||||
|
||||
[stremio:///discover](stremio:///discover)
|
||||
|
||||
`stremio:///discover/{catalogAddonUrl}/{type}/{id}?genre={genre}`
|
||||
|
||||
* `catalogAddonUrl` - URL to manifest of the addon (URI encoded)
|
||||
* `type` the addon type, see [content types](./api/responses/content.types.md)
|
||||
* `id` [catalog id](./api/responses/manifest.md#catalog-format) from the addon
|
||||
* `genre` the filter genre, see [catalog extra properties](./api/responses/manifest.md#extra-properties)
|
||||
|
||||
|
||||
## Library
|
||||
|
||||
[stremio:///library](stremio:///library)
|
||||
|
||||
|
||||
## Search
|
||||
|
||||
`stremio:///search?search={query}`
|
||||
|
||||
* `query` the search query (URI encoded)
|
||||
|
||||
|
||||
### Detail
|
||||
|
||||
`stremio:///detail/{type}/{id}/{videoId}?autoPlay={autoPlay}`
|
||||
|
||||
* `type` corresponds to [content types](./api/responses/content.types.md)
|
||||
|
||||
* `id` is the [meta object ID](./api/responses/meta.md#meta-object)
|
||||
|
||||
* `videoID` is the [video object ID](./api/responses/meta.md#video-object); leave this empty if you only wish to show the list of episodes/videos (not applicable for one-video types, such as `movie` and `tv`)
|
||||
|
||||
* `autoPlay` can be `true` or `false`, optional, attempt playing the video with `videoID`, success depends on if the user played that video or a video from that meta before (in which case a stream url for the video, or a stream [bingeGroup](./api/responses/stream.md#additional-properties-to-provide-information--behaviour-flags) may be already available), currently only supported in the Android TV app
|
||||
|
||||
In the Cinemeta addon, the `videoID` is the same as the `id` for movies, and for series it's formed as `{id}:{season}:episode`
|
||||
|
||||
In the Channels addon, the `videoID` is formed as `{id}:{youtube video ID}`
|
||||
|
||||
Examples:
|
||||
|
||||
[stremio:///detail/movie/tt0066921/tt0066921](stremio:///detail/movie/tt0066921/tt0066921)
|
||||
|
||||
[stremio:///detail/series/tt0108778/tt0108778:1:1](stremio:///detail/series/tt0108778:1:1)
|
||||
|
||||
[stremio:///detail/channel/yt_id:UCrDkAvwZum-UTjHmzDI2iIw](stremio:///detail/channel/yt_id:UCrDkAvwZum-UTjHmzDI2iIw)
|
||||
|
||||
34
Docs/Stremio addons refer/deploying/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
## Deploying your Addon
|
||||
|
||||
**Note:** Although deploying is recommended, there is also the alternative of using [localtunnel](https://github.com/localtunnel/localtunnel) to host your addons locally.
|
||||
|
||||
Stremio addons require hosting in order to be published. You will need a NodeJS hosting solution, as Stremio Addons made with the Stremio Addon SDK are NodeJS apps.
|
||||
|
||||
We recommend:
|
||||
|
||||
- [Beamup](./beamup.md) - free
|
||||
- [Heroku](https://www.heroku.com) - [free with some restrictions](https://www.heroku.com/pricing)
|
||||
- [Fleek](https://fleek.co/) - [free with some restricitions](https://fleek.co/pricing/)
|
||||
- [Glitch](https://glitch.com/) - [free with some restrictions](https://glitch.com/help/restrictions/)
|
||||
- [cloudno.de](https://cloudno.de) - [free for up to 150k requests/month](https://cloudno.de/pricing)
|
||||
- [Evennode](https://www.evennode.com) - [free for 7 days trial](https://www.evennode.com/pricing)
|
||||
|
||||
You can also check this very comprehensive [guide by nodejs](https://github.com/nodejs/node-v0.x-archive/wiki/node-hosting).
|
||||
|
||||
Stremio addons are deployed just like regular nodejs apps, so follow the nodejs instructions provided by your particular service provider.
|
||||
|
||||
If you've built a great addon, and need help with hosting your addon, you are welcome to contact us at [addons@stremio.com](addons@stremio.com)
|
||||
|
||||
**NOTE:** we used to recommend now.sh, but after getting reports from multiple developers of now.sh suspending accounts without good reason, we no longer recommend it
|
||||
|
||||
### Publishing to Stremio
|
||||
|
||||
If you want your addon to appear in the list of Community addons in Stremio, check out [publishToCentral](../README.md#publishtocentralurl)
|
||||
|
||||
If you are not using the Addon SDK to create your addon, you can publish your addon in the list of Community addons in Stremio by submitting it on [this site](https://stremio.github.io/stremio-publish-addon/index.html)
|
||||
|
||||
### Guides
|
||||
|
||||
- [Deploying to Beamup](./beamup.md)
|
||||
- [Deploying to Glitch.com with CloudFlare](./glitch.md)
|
||||
- [Deploying to Now.sh with CloudFlare](./now.md) - note that deploying to now.sh is no longer recommended, but this guide includes useful information applicable for other serverless platforms
|
||||
38
Docs/Stremio addons refer/deploying/beamup.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Deploying to Beamup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In order to deploy you will need:
|
||||
- [Node.js](https://nodejs.org/en/download/) installed on your system
|
||||
- a GitHub account
|
||||
- your SSH key added to your GitHub account
|
||||
|
||||
## Install the Client
|
||||
|
||||
- `npm install beamup-cli -g`
|
||||
- `beamup config` required after install and whenever the github keys are modified.
|
||||
|
||||
## Usage
|
||||
|
||||
- go to the project directory that you want to deploy
|
||||
- use the `beamup` command
|
||||
|
||||
The `beamup` command is a universal command, it will handle both initial setup and deploying projects.
|
||||
|
||||
## One Time Setup
|
||||
|
||||
When you run `beamup` for the first time, it will:
|
||||
- ask you for a host, use `a.baby-beamup.club`
|
||||
- ask you for your GitHub username
|
||||
|
||||
Once you've added this information, it will save it and not ask you again. If you ever want to change these settings, use `beamup config`.
|
||||
|
||||
### Good to Know
|
||||
|
||||
- you can use `git push beamup master` to update your projects as well
|
||||
- your project must support using the `PORT` process environment variable (if available) as the http server port
|
||||
- your project repo must suppport one of the Heroku buildpacks or must have a `Dockerfile`; with Nodejs, simply having a `package.json` in the repo should be sufficient
|
||||
- it's based on [Dokku](http://dokku.viewdocs.io/dokku/), so whatever you can deploy there you can also deploy on Beamup (it's using the same build system); however, some features are not supported such as custom NGINX config
|
||||
- currently only projects using Dokku 'Herokuish' buildpack are supported; an ugly workaround to deploy a project built with Dokku 'Dockerfile' buildpack is to include 'docker' in the project name
|
||||
- the Node.js dependency can be avoided by downloading a prebuilt version of `beamup-cli` from the [releases page](https://github.com/Stremio/stremio-beamup-cli/releases/)
|
||||
- Beamup supports any programming language, the use of Node.js is not a requirement to build the addon
|
||||
53
Docs/Stremio addons refer/deploying/glitch.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Deploying to Glitch with CloudFlare
|
||||
|
||||
The plan:
|
||||
- deploy your node.js addon to [Glitch.com](https://glitch.com)
|
||||
- create a free domain (for 12 months) on [my.ga](https://my.ga)
|
||||
- use [Fly.io](https://fly.io) to connect the custom domain to the glitch project
|
||||
- use [CloudFlare](https://cloudflare.com) to cache the addon responses
|
||||
|
||||
|
||||
## 1. Deploying to Glitch.com
|
||||
|
||||
[Glitch](https://glitch.com) offers 4000 requests per hour and sends your app to sleep after 5 minutes of inactivity.
|
||||
|
||||
Deploying to Glitch takes seconds if you have your addon on GitHub, just go on the site, create a new user (if you don't have one already), and import your project based on your Github's Git link (find it by pressing the "Clone or download" button on your Github's project page)
|
||||
|
||||
A few pointers:
|
||||
- use `process.env.PORT` as your HTTP server's port
|
||||
- you can avoid letting your app go to sleep (which is recommended), by using:
|
||||
```javascript
|
||||
const https = require('https')
|
||||
|
||||
setInterval(() => {
|
||||
https.get('https://my-project.glitch.me/manifest.json')
|
||||
}, 299000)
|
||||
```
|
||||
and replacing `my-project` in the URL with your own glitch project name
|
||||
|
||||
|
||||
## 2. Create a free domain on my.ga
|
||||
|
||||
Go to [my.ga](https://my.ga) and create a new free domain, once you go to the cart, make sure to select "12 Months", as the page uses "3 Months" by default.
|
||||
|
||||
Don't set any redirect or DNS settings for now, continue on the page and create an account if you don't have one.
|
||||
|
||||
|
||||
## 3. Use Fly.io to connect a custom domain to Glitch
|
||||
|
||||
Go on [Fly.io](https://fly.io) and create an account if you don't have one, after you are logged in, go to [this page](https://fly.io/sites) (this page shows a 404 error if you are not logged in) and press "Add New Site".
|
||||
|
||||
Select "Glitch" and enter your Glitch project Live URL. This will bring you to a page that asks you to set DNS records, keep this page open and continue with the tutorial.
|
||||
|
||||
|
||||
## 4. Use CloudFlare to cache the addon responses
|
||||
|
||||
Go to [CloudFlare](cloudflare.com) and create a new account if you don't have one. Then add a new site with the .ga domain you registered in Step 2, CloudFlare will tell you that it couldn't find any DNS records for it, don't worry, that's fine. Create a new CNAME record with the Name set to `@` and the Target set to what the page from Step 3 shows next to CNAME (it should be a URL that ends with `.shw.io`). Now press "Add Rule" and confirm the changes.
|
||||
|
||||
This will bring you to a page that tells you to change your domain's nameservers, to do that, go to [Freenom](https://freenom.com) (where you made your .ga domain), log in with the account you used to create your domain, in the top menu press "Services", then "My Domains", then "Manage Domain" next to your domain name, now press "Management Tools", then "Nameservers", press "Use custom nameservers" and paste your CloudFlare nameservers.
|
||||
|
||||
Now go back to the Fly.io page from Step 3 and press "Next". With this done, CloudFlare is now connected to Fly.io, which is connected to your Glitch project.
|
||||
|
||||
There's one more thing to do, CloudFlare doesn't cache JSON by default, so we need to create a Page Rule for that. Go to your CloudFlare account, select your .ga domain, press "Page Rules" (from the top menu), press "Create Page Rule", in the top input field write your domain name and end it with `/*`, then press "+ Add a Setting", select "Cache Level", then select "Cache Everything".
|
||||
|
||||
You're done!
|
||||
65
Docs/Stremio addons refer/deploying/now.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Deploying to Now.sh with Caching
|
||||
|
||||
The plan:
|
||||
- deploy to [Now.sh](https://now.sh) with serverless
|
||||
- use Now.sh CDN cache
|
||||
|
||||
# 1. Deploying to Now.sh
|
||||
|
||||
First let's make sure your project is serverless.
|
||||
|
||||
Presuming your code is in `index.js`, instead of ending your code with `addon.serveHTTP`, use:
|
||||
|
||||
```javascript
|
||||
module.exports = addon.getInterface()
|
||||
```
|
||||
|
||||
where `addon` is:
|
||||
```javascript
|
||||
const { addonBuilder } = require("stremio-addon-sdk")
|
||||
const addon = new addonBuilder(manifest);
|
||||
```
|
||||
|
||||
Create `serverless.js`, which includes:
|
||||
|
||||
```javascript
|
||||
const { getRouter } = require("stremio-addon-sdk");
|
||||
const addonInterface = require("./addon");
|
||||
const router = getRouter(addonInterface);
|
||||
module.exports = function(req, res) {
|
||||
router(req, res, function() {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Create a `now.json` file that includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{ "src": "serverless.js", "use": "@now/node" }
|
||||
],
|
||||
"routes": [
|
||||
{ "src": "/.*", "dest": "/serverless.js" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Now go to [now.sh](https://now.sh) and create an account if you don't have one, then install the `now` cli tool with:
|
||||
```
|
||||
npm install -g now
|
||||
```
|
||||
|
||||
Open a terminal window, go to your project's directory and simply write `now`, this will prompt for login, after you login you'll get your Now.sh URL.
|
||||
|
||||
|
||||
## 2. Use Now.sh CDN Caching
|
||||
|
||||
It is important to set the `cacheMaxAge` parameter in the responses with the number of seconds you want the responses cached by Now.sh's CDN. Caching is important to reduce the number of requests and ensuring the longevity of your addon.
|
||||
|
||||
The `cacheMaxAge` parameter is documented for all [request handlers](../api/requests).
|
||||
|
||||
You're done!
|
||||
34
Docs/Stremio addons refer/examples.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Stremio Addon Examples
|
||||
|
||||
|
||||
### Examples using SDK
|
||||
|
||||
- [Hello World Addon](https://github.com/Stremio/addon-helloworld): also includes a step by step tutorial
|
||||
- [IGDB Addon](https://github.com/Stremio/stremio-igdb-addon/tree/tutorial)
|
||||
|
||||
### Examples not using this SDK
|
||||
|
||||
- [PHP Addon Example & Tutorial](https://github.com/Stremio/stremio-php-addon-example)
|
||||
- [Laravel (PHP) Addon Template](https://github.com/rleroi/Stremio-Laravel)
|
||||
- [Go Addon Example](https://github.com/Stremio/addon-helloworld-go)
|
||||
- [Go Addon Examples Using Unofficial SDK](https://github.com/Deflix-tv/go-stremio/tree/master/examples)
|
||||
- [Python Addon Example & Tutorial](https://github.com/Stremio/addon-helloworld-python)
|
||||
- [Ruby Addon Example & Tutorial](https://github.com/Stremio/addon-helloworld-ruby)
|
||||
- [C# Addon Example](https://github.com/Stremio/addon-helloworld-csharp)
|
||||
- [Rust Addon Example Using Unofficial SDK](https://github.com/sleeyax/stremio-addon-sdk/tree/master/example-addon)
|
||||
- [Node.js Express Addon Example & Tutorial](https://github.com/Stremio/addon-helloworld-express)
|
||||
- [Node.js Express Addon Example Using User Data](./advanced.md)
|
||||
- [IMDB Lists - Node.js Express Addon Using User Data and Ajax Calls](https://github.com/jaruba/stremio-imdb-list)
|
||||
- [IMDB Watchlist - Node.js Express Addon Using User Data and Proxying Another Stremio Addon](https://github.com/jaruba/stremio-imdb-watchlist)
|
||||
- [Jackett Addon - Node.js Express Addon Using User Data](https://github.com/BoredLama/stremio-jackett-addon)
|
||||
|
||||
|
||||
### Guides
|
||||
|
||||
- [Official SDK guide](https://stremio.github.io/stremio-addon-guide/sdk-guide/prelude)
|
||||
- [Official generic guide](https://stremio.github.io/stremio-addon-guide/basics)
|
||||
|
||||
|
||||
### Video tutorials
|
||||
|
||||
- [Building a Stremio addon](https://www.youtube.com/watch?v=HqTkQeRKF-c&list=PLhslIqdUyoB-8olXVaYQxDLJIIOcSQU3H)
|
||||
194
Docs/Stremio addons refer/protocol.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
|
||||
## Stremio Addon Protocol
|
||||
|
||||
**If you're creating an addon, we recommend you build it using our [addon-sdk](https://github.com/Stremio/stremio-addon-sdk), which will provide a convenient abstraction to the protocol, as well as an easy way of publishing your addons.**
|
||||
|
||||
The Stremio addon protocol defines a universal interface to describe multimedia content. It can describe catalogs, detailed metadata and streams related to multimedia content.
|
||||
|
||||
It is typically transported over HTTP or IPFS, and follows a paradigm similar to REST.
|
||||
|
||||
This allows Stremio or other similar applications to aggregate content seamlessly from different sources, for example YouTube, Twitch, iTunes, Netflix, DTube and others. It also allows developers to build such addons with minimal skill.
|
||||
|
||||
To define a minimal addon, you only need an HTTP server/endpoint serving a `/manifest.json` file and responding to resource requests at `/{resource}/{type}/{id}.json`.
|
||||
|
||||
Currently used resources are: `catalog`, `meta`, `stream`, `subtitles`.
|
||||
|
||||
`/catalog/{type}/{id}.json` - catalogs of media items; `type` denotes the type, such as `movie`, `series`, `channel`, `tv`, and `id` denotes the catalog ID, which is custom and specified in your manifest, `id` is required as an addon can hold multiple catalogs
|
||||
|
||||
`/meta/{type}/{id}.json` - detailed metadata about a particular item; `type` again denotes the type, and `id` is the ID of the particular item, as found in the catalog
|
||||
|
||||
`/stream/{type}/{videoID}.json` - list of all streams for a particular item; `type` again denotes the type, and `videoID` is the video ID: a single metadata object may contain mutiple videos, for example a YouTube channel or a TV series; for single-video items (such as movies), the video ID is equal to the item ID
|
||||
|
||||
`/subtitles/{type}/{id}.json` - list of all subtitles for a particular item; `type` again denotes the type, the `id` in this case is the Open Subtitles file hash, while `extraArgs` (read below) is used for `videoID` (the ID of the particular item, as found in the catalog or a video ID) and `videoSize` (video file size in bytes)
|
||||
|
||||
The JSON format of the response to these resources is described [here](./api/responses/).
|
||||
|
||||
To pass extra args, such as the ones needed for `catalog` resources (e.g. `search`, `skip`), you should define a route of the format `/{resource}/{type}/{id}/{extraArgs}.json` where `extraArgs` is the query string stringified object of extra arguments (for example `"search=game%20of%20thrones&skip=100"`)
|
||||
|
||||
For the HTTP transport, each route, including `/manifest.json`, must serve CORS headers that allow all origins.
|
||||
|
||||
**NOTE: Your addon may selectively provide any number of resources. It must provide at least 1 resource and a manifest.**
|
||||
|
||||
## Transports, URLs
|
||||
|
||||
The abstractions that are used to actually request data from addons are called "transports".
|
||||
|
||||
A client library normally implements the following transports: HTTP, legacy and IPFS. "legacy" is a special type of transport that maps the resource requests (in the form of `{ resource, type, id, extraArgs }`) to requests to the legacy v1/v2 versions of the addon protocol.
|
||||
|
||||
"Transport URL" refers to the URL to the addon. Depending on the protocol of the URL and suffix of the pathname, the relevant transport will be selected:
|
||||
|
||||
* `https://.../manifest.json`: HTTP transport
|
||||
* `https://.../stremio/v1`: legacy transport
|
||||
* `ipfs://.../manifest.json` or `ipns://.../manifest.json`: IPFS transport
|
||||
|
||||
For more details regarding the concepts used in the client library, go to https://github.com/stremio/stremio-addon-client/
|
||||
|
||||
## Minimal example
|
||||
|
||||
Create a directory called `example-addon`
|
||||
|
||||
|
||||
`manifest.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"id": "org.myexampleaddon",
|
||||
"version": "1.0.0",
|
||||
"name": "simple Big Buck Bunny example",
|
||||
"types": [ "movie" ],
|
||||
"catalogs": [ { "type": "movie", "id": "bbbcatalog" } ],
|
||||
"resources": [
|
||||
"catalog",
|
||||
{
|
||||
"name": "stream",
|
||||
"types": [ "movie" ],
|
||||
"idPrefixes": [ "tt" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Manifest Object Definition](./api/responses/manifest.md)
|
||||
|
||||
In this simple example, we set the id prefix of the streams to `tt`, which is the prefix for IMDB IDs (example: `tt1254207`).
|
||||
Because we're using IMDB IDs, we do not need to handle the Meta requests, as all IMDB IDs from all addons are handled
|
||||
internally by Stremio's Cinemeta Addon.
|
||||
|
||||
|
||||
`/catalog/movie/bbbcatalog.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"metas": [
|
||||
{
|
||||
"id": "tt1254207",
|
||||
"type": "movie",
|
||||
"name": "Big Buck Bunny",
|
||||
"poster": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Meta Object Definition](./api/responses/meta.md)
|
||||
|
||||
|
||||
`/stream/movie/tt1254207.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"streams": [
|
||||
{
|
||||
"name": "", // name is optional
|
||||
"url": "http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_30fps_normal.mp4"
|
||||
},
|
||||
// add more streams:
|
||||
{
|
||||
"name": "",
|
||||
"url": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Stream Object Definition](./api/responses/stream.md)
|
||||
|
||||
That's it! By handling "catalog" and "stream" with the IMDB ID prefix ("tt"), we have a complete addon serving just one stream
|
||||
for Big Buck Bunny that has it's own catalog that has only Big Buck Bunny in it. Also, because this is based on IMDB ID, the
|
||||
stream will also be available if users open Big Buck Bunny from other addons that use IMDB IDs too.
|
||||
|
||||
|
||||
If we were to also want subtitles for this addon, we could add: `{ "name": "subtitles", "type": "movie", idPrefixes: [ "tt" ] }`
|
||||
to the addon manifest's `resources` and then handle subtitle requests with:
|
||||
|
||||
`/subtitle/movie/tt1254207.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"subtitles": [
|
||||
{
|
||||
url: "https://mkvtoolnix.download/samples/vsshort-en.srt",
|
||||
lang: "en"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Subtitle Object Definition](./api/responses/subtitles.md)
|
||||
|
||||
|
||||
Alternatively, if we were to not use the IMDB ID prefix ("tt"), and would use a different prefix, such as "exampleid", then "Big Buck
|
||||
Bunny" would have a presumable ID of "exampleid1". In this case, we could add `{ "name": "meta", "type": "movie", idPrefixes: [ "exampleid" ]}`
|
||||
to the addon manifest's `resources` and then handle meta requests too with:
|
||||
|
||||
|
||||
`/meta/movie/exampleid1.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"meta": [
|
||||
{
|
||||
"id": "exampleid1",
|
||||
"type": "movie",
|
||||
"name": "Big Buck Bunny",
|
||||
"poster": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/uVEFQvFMMsg4e6yb03xOfVsDz4o.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Meta Object Definition](./api/responses/meta.md)
|
||||
|
||||
Meta requests are done when Stremio opens a Details page for a movie (series, channel, etc.) The response is used to create the Details page, it can also handle "background", "logo", "releaseInfo" and other extended information about the movie / series / etc. As previously mentioned, the meta request does not need to be handled at all when using the IMDB
|
||||
ID prefix, as that is handled internally by Stremio.
|
||||
|
||||
|
||||
This addon is so simple that it can actually be hosted statically on GitHub pages!
|
||||
|
||||
[See Example Static Addon](https://github.com/Stremio/stremio-static-addon-example)
|
||||
|
||||
|
||||
## Objects
|
||||
|
||||
[Metadata](./api/responses/meta.md)
|
||||
|
||||
[Stream](./api/responses/stream.md)
|
||||
|
||||
[Subtitles](./api/responses/subtitles.md)
|
||||
|
||||
|
||||
## Next steps
|
||||
|
||||
Check out the following tutorials for different languages:
|
||||
|
||||
**If in doubt, and you know JavaScript, use the Node.js SDK**
|
||||
|
||||
* [Creating an addon with the NodeJS Stremio Addon SDK](https://github.com/Stremio/addon-helloworld)
|
||||
* [Creating an addon with NodeJS and express](https://github.com/Stremio/addon-helloworld-express)
|
||||
* [Creating an addon with PHP](https://github.com/Stremio/stremio-php-addon-example)
|
||||
* [Creating an addon with Python](https://github.com/stremio/addon-helloworld-python)
|
||||
* [Creating an addon with Ruby](https://github.com/stremio/addon-helloworld-ruby)
|
||||
* [Creating an addon with Go](https://github.com/stremio/addon-helloworld-go)
|
||||
* [Creating an addon with C#](https://github.com/stremio/addon-helloworld-csharp)
|
||||
* [Creating an addon with Java](https://github.com/stremio/addon-helloworld-java)
|
||||
41
Docs/Stremio addons refer/testing.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
## Testing your Addon
|
||||
|
||||
To test your addon, you will need to add the addon manifest URL to a client.
|
||||
|
||||
There are currently two such clients that you can test with:
|
||||
|
||||
- Stremio v4.4.10+
|
||||
|
||||
- Stremio Web Version
|
||||
|
||||
**Note:** if you want to load an addon by URL in Stremio, the URL must either be accessed on `127.0.0.1` or support HTTPS.
|
||||
|
||||
|
||||
### Starting/launching shortcuts
|
||||
|
||||
If you're using the [`serveHTTP`](/docs/README.md#servehttpaddoninterface-options) method, there are two shortcuts that you can use:
|
||||
|
||||
If you launch your addon with `npm start -- --launch`, it will open a web version of Stremio with the addon pre-installed.
|
||||
|
||||
Another shortcut is to use `npm start -- --install`, which will open the desktop version of Stremio and a prompt to install the addon.
|
||||
|
||||
|
||||
### Testing in Stremio App
|
||||
|
||||
Testing in Stremio is easy, simply [download Stremio](https://www.stremio.com/downloads) v4.4.10+ (latest beta from the site)
|
||||
|
||||
|
||||
### Testing in Stremio Web Version
|
||||
|
||||
Open the web version of Stremio at: https://app.strem.io/shell-v4.4/
|
||||
|
||||
If you use `npm start -- --launch`, the addon will launch at https://staging.strem.io, which is a staging (development) version of Stremio.
|
||||
|
||||
**Note: Torrents will not work in Stremio's Web Version.**
|
||||
|
||||
|
||||
### How to Install Addon in Stremio
|
||||
|
||||
Follow the 2 steps showcased in this image:
|
||||
|
||||

|
||||
35
README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
This is a Kotlin Multiplatform project targeting Android, iOS.
|
||||
|
||||
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
|
||||
It contains several subfolders:
|
||||
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets.
|
||||
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
|
||||
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
|
||||
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
|
||||
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
|
||||
folder is the appropriate location.
|
||||
|
||||
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
|
||||
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
||||
|
||||
### Build and Run Android Application
|
||||
|
||||
To build and run the development version of the Android app, use the run configuration from the run widget
|
||||
in your IDE’s toolbar or build it directly from the terminal:
|
||||
- on macOS/Linux
|
||||
```shell
|
||||
./gradlew :composeApp:assembleDebug
|
||||
```
|
||||
- on Windows
|
||||
```shell
|
||||
.\gradlew.bat :composeApp:assembleDebug
|
||||
```
|
||||
|
||||
### Build and Run iOS Application
|
||||
|
||||
To build and run the development version of the iOS app, use the run configuration from the run widget
|
||||
in your IDE’s toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
|
||||
|
||||
---
|
||||
|
||||
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
|
||||
9
build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
plugins {
|
||||
// this is necessary to avoid the plugins to be loaded multiple times
|
||||
// in each subproject's classloader
|
||||
alias(libs.plugins.androidApplication) apply false
|
||||
alias(libs.plugins.androidLibrary) apply false
|
||||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
}
|
||||
80
composeApp/build.gradle.kts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidTarget {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.components.resources)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nuvio.app"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.nuvio.app"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
}
|
||||
24
composeApp/src/androidMain/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.features.addons.AddonStorage
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
AddonStorage.initialize(applicationContext)
|
||||
|
||||
setContent {
|
||||
AndroidAppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
AndroidAppRoot()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AndroidAppRoot() {
|
||||
NuvioTheme {
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Addons) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
AppScreen(
|
||||
tab = selectedTab,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
onClick = { selectedTab = AppScreenTab.Home },
|
||||
icon = { Icon(Icons.Rounded.Home, contentDescription = null) },
|
||||
label = { Text("Home") },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == AppScreenTab.Addons,
|
||||
onClick = { selectedTab = AppScreenTab.Addons },
|
||||
icon = { Icon(Icons.Rounded.Extension, contentDescription = null) },
|
||||
label = { Text("Addons") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import android.os.Build
|
||||
|
||||
class AndroidPlatform : Platform {
|
||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
actual object AddonStorage {
|
||||
private const val preferencesName = "nuvio_addons"
|
||||
private const val addonUrlsKey = "installed_manifest_urls"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadInstalledAddonUrls(): List<String> =
|
||||
preferences
|
||||
?.getString(addonUrlsKey, null)
|
||||
.orEmpty()
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
actual fun saveInstalledAddonUrls(urls: List<String>) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(addonUrlsKey, urls.joinToString(separator = "\n"))
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun httpGetText(url: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
|
||||
try {
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream ?: connection.inputStream
|
||||
}
|
||||
val payload = stream.bufferedReader().use(BufferedReader::readText)
|
||||
if (statusCode !in 200..299) {
|
||||
error("Request failed with HTTP $statusCode")
|
||||
}
|
||||
payload
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
3
composeApp/src/androidMain/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Nuvio</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="450dp"
|
||||
android:height="450dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
<path
|
||||
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
|
||||
android:fillColor="#6075f2"/>
|
||||
<path
|
||||
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
|
||||
android:fillColor="#6b57ff"/>
|
||||
<path
|
||||
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:centerX="23.131"
|
||||
android:centerY="18.441"
|
||||
android:gradientRadius="42.132"
|
||||
android:type="radial">
|
||||
<item android:offset="0" android:color="#FF5383EC"/>
|
||||
<item android:offset="0.867" android:color="#FF7F52FF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="44.172"
|
||||
android:startY="4.377"
|
||||
android:endX="17.973"
|
||||
android:endY="34.035"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF33C3FF"/>
|
||||
<item android:offset="0.878" android:color="#FF5383EC"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
32
composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.features.addons.AddonsScreen
|
||||
import com.nuvio.app.features.home.HomeScreen
|
||||
|
||||
enum class AppScreenTab {
|
||||
Home,
|
||||
Addons,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
tab: AppScreenTab,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (tab) {
|
||||
AppScreenTab.Home -> HomeScreen(modifier = modifier)
|
||||
AppScreenTab.Addons -> AddonsScreen(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
NuvioTheme {
|
||||
AppScreen(tab = AppScreenTab.Addons)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NuvioScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentPadding = PaddingValues(
|
||||
start = 22.dp,
|
||||
top = 14.dp,
|
||||
end = 22.dp,
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 28.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioSurfaceCard(
|
||||
modifier: Modifier = Modifier,
|
||||
tonalElevation: Int = 0,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
tonalElevation = tonalElevation.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 22.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioScreenHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioActionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioIconActionButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurface,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.background.copy(alpha = 0.001f),
|
||||
shape = CircleShape,
|
||||
),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioPrimaryButton(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.65f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.65f),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioInputField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeholder,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioInfoBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioInlineMetadata(
|
||||
title: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val NuvioDarkColors = darkColorScheme(
|
||||
primary = Color(0xFF2E86B8),
|
||||
onPrimary = Color(0xFFD2E8F7),
|
||||
primaryContainer = Color(0xFF102531),
|
||||
onPrimaryContainer = Color(0xFFE2F1FA),
|
||||
secondary = Color(0xFF8A929C),
|
||||
onSecondary = Color(0xFFEEF1F3),
|
||||
background = Color(0xFF020404),
|
||||
onBackground = Color(0xFFF5F7F8),
|
||||
surface = Color(0xFF0A0D0D),
|
||||
onSurface = Color(0xFFF5F7F8),
|
||||
surfaceVariant = Color(0xFF121616),
|
||||
onSurfaceVariant = Color(0xFF969CA3),
|
||||
outline = Color(0xFF252A2A),
|
||||
error = Color(0xFFE36A8A),
|
||||
onError = Color(0xFFFCE5EC),
|
||||
)
|
||||
|
||||
private val NuvioTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontSize = 42.sp,
|
||||
lineHeight = 46.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = (-1.2).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 34.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-0.8).sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NuvioTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(
|
||||
density = density.density,
|
||||
fontScale = 1f,
|
||||
),
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) NuvioDarkColors else NuvioDarkColors,
|
||||
typography = NuvioTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
internal object AddonManifestParser {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun parse(
|
||||
manifestUrl: String,
|
||||
payload: String,
|
||||
): AddonManifest {
|
||||
val root = json.parseToJsonElement(payload).jsonObject
|
||||
val defaultTypes = root.stringList("types")
|
||||
val defaultPrefixes = root.stringList("idPrefixes")
|
||||
|
||||
return AddonManifest(
|
||||
id = root.requiredString("id"),
|
||||
name = root.requiredString("name"),
|
||||
description = root.requiredString("description"),
|
||||
version = root.requiredString("version"),
|
||||
resources = root.resources(defaultTypes, defaultPrefixes),
|
||||
types = defaultTypes,
|
||||
idPrefixes = defaultPrefixes,
|
||||
catalogs = root.catalogs(),
|
||||
behaviorHints = root.behaviorHints(),
|
||||
transportUrl = manifestUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.resources(
|
||||
defaultTypes: List<String>,
|
||||
defaultPrefixes: List<String>,
|
||||
): List<AddonResource> =
|
||||
array("resources").map { resource ->
|
||||
when (resource) {
|
||||
is JsonPrimitive -> AddonResource(
|
||||
name = resource.contentOrNull.orEmpty(),
|
||||
types = defaultTypes,
|
||||
idPrefixes = defaultPrefixes,
|
||||
)
|
||||
|
||||
else -> {
|
||||
val obj = resource.jsonObject
|
||||
AddonResource(
|
||||
name = obj.requiredString("name"),
|
||||
types = obj.stringList("types").ifEmpty { defaultTypes },
|
||||
idPrefixes = obj.stringList("idPrefixes").ifEmpty { defaultPrefixes },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.filter { it.name.isNotBlank() }
|
||||
|
||||
private fun JsonObject.catalogs(): List<AddonCatalog> =
|
||||
array("catalogs").map { catalogElement ->
|
||||
val catalog = catalogElement.jsonObject
|
||||
AddonCatalog(
|
||||
type = catalog.requiredString("type"),
|
||||
id = catalog.requiredString("id"),
|
||||
name = catalog.optionalString("name").orEmpty().ifBlank { catalog.requiredString("id") },
|
||||
extra = catalog.array("extra").mapNotNull { extraElement ->
|
||||
extraElement.jsonObject.optionalString("name")?.takeIf { it.isNotBlank() }?.let { name ->
|
||||
AddonExtraProperty(
|
||||
name = name,
|
||||
isRequired = extraElement.jsonObject.boolean("isRequired"),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.behaviorHints(): AddonBehaviorHints {
|
||||
val hints = this["behaviorHints"]?.jsonObject ?: return AddonBehaviorHints()
|
||||
return AddonBehaviorHints(
|
||||
configurable = hints.boolean("configurable"),
|
||||
configurationRequired = hints.boolean("configurationRequired"),
|
||||
adult = hints.boolean("adult"),
|
||||
p2p = hints.boolean("p2p"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.requiredString(name: String): String =
|
||||
optionalString(name)?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalArgumentException("Manifest missing \"$name\"")
|
||||
|
||||
private fun JsonObject.optionalString(name: String): String? =
|
||||
this[name]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
private fun JsonObject.stringList(name: String): List<String> =
|
||||
array(name).mapNotNull { element ->
|
||||
element.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray =
|
||||
(this[name] as? JsonArray) ?: JsonArray(emptyList())
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean =
|
||||
this[name]?.jsonPrimitive?.booleanOrNull == true
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
data class AddonManifest(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val version: String,
|
||||
val resources: List<AddonResource>,
|
||||
val types: List<String>,
|
||||
val idPrefixes: List<String> = emptyList(),
|
||||
val catalogs: List<AddonCatalog> = emptyList(),
|
||||
val behaviorHints: AddonBehaviorHints = AddonBehaviorHints(),
|
||||
val transportUrl: String,
|
||||
)
|
||||
|
||||
data class AddonResource(
|
||||
val name: String,
|
||||
val types: List<String>,
|
||||
val idPrefixes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class AddonCatalog(
|
||||
val type: String,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val extra: List<AddonExtraProperty> = emptyList(),
|
||||
)
|
||||
|
||||
data class AddonExtraProperty(
|
||||
val name: String,
|
||||
val isRequired: Boolean = false,
|
||||
)
|
||||
|
||||
data class AddonBehaviorHints(
|
||||
val configurable: Boolean = false,
|
||||
val configurationRequired: Boolean = false,
|
||||
val adult: Boolean = false,
|
||||
val p2p: Boolean = false,
|
||||
)
|
||||
|
||||
data class ManagedAddon(
|
||||
val manifestUrl: String,
|
||||
val manifest: AddonManifest? = null,
|
||||
val isRefreshing: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val isActive: Boolean
|
||||
get() = manifest != null
|
||||
|
||||
val displayTitle: String
|
||||
get() = manifest?.name ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" }
|
||||
}
|
||||
|
||||
data class AddonsUiState(
|
||||
val addons: List<ManagedAddon> = emptyList(),
|
||||
)
|
||||
|
||||
data class AddonOverview(
|
||||
val totalAddons: Int,
|
||||
val activeAddons: Int,
|
||||
val totalCatalogs: Int,
|
||||
)
|
||||
|
||||
internal fun List<ManagedAddon>.toOverview(): AddonOverview =
|
||||
AddonOverview(
|
||||
totalAddons = size,
|
||||
activeAddons = count { it.isActive },
|
||||
totalCatalogs = sumOf { it.manifest?.catalogs?.size ?: 0 },
|
||||
)
|
||||
|
||||
sealed interface AddAddonResult {
|
||||
data object Success : AddAddonResult
|
||||
data class Error(val message: String) : AddAddonResult
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
internal expect object AddonStorage {
|
||||
fun loadInstalledAddonUrls(): List<String>
|
||||
fun saveInstalledAddonUrls(urls: List<String>)
|
||||
}
|
||||
|
||||
internal expect suspend fun httpGetText(url: String): String
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object AddonRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _uiState = MutableStateFlow(AddonsUiState())
|
||||
val uiState: StateFlow<AddonsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var initialized = false
|
||||
|
||||
fun initialize() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
val storedUrls = AddonStorage.loadInstalledAddonUrls()
|
||||
if (storedUrls.isEmpty()) return
|
||||
|
||||
_uiState.value = AddonsUiState(
|
||||
addons = storedUrls.map { manifestUrl ->
|
||||
ManagedAddon(
|
||||
manifestUrl = manifestUrl,
|
||||
isRefreshing = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
storedUrls.forEach(::refreshAddon)
|
||||
}
|
||||
|
||||
fun addAddon(rawUrl: String): AddAddonResult {
|
||||
val manifestUrl = try {
|
||||
normalizeManifestUrl(rawUrl)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return AddAddonResult.Error(error.message ?: "Enter a valid addon URL")
|
||||
}
|
||||
|
||||
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
|
||||
return AddAddonResult.Error("That addon is already installed.")
|
||||
}
|
||||
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
addons = listOf(
|
||||
ManagedAddon(
|
||||
manifestUrl = manifestUrl,
|
||||
isRefreshing = true,
|
||||
),
|
||||
) + current.addons,
|
||||
)
|
||||
}
|
||||
persist()
|
||||
refreshAddon(manifestUrl)
|
||||
return AddAddonResult.Success
|
||||
}
|
||||
|
||||
fun removeAddon(manifestUrl: String) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
addons = current.addons.filterNot { it.manifestUrl == manifestUrl },
|
||||
)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
fun refreshAll() {
|
||||
_uiState.value.addons.forEach { addon ->
|
||||
refreshAddon(addon.manifestUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAddon(manifestUrl: String) {
|
||||
markRefreshing(manifestUrl)
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
val payload = httpGetText(manifestUrl)
|
||||
AddonManifestParser.parse(
|
||||
manifestUrl = manifestUrl,
|
||||
payload = payload,
|
||||
)
|
||||
}
|
||||
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
addons = current.addons.map { addon ->
|
||||
if (addon.manifestUrl != manifestUrl) {
|
||||
addon
|
||||
} else {
|
||||
result.fold(
|
||||
onSuccess = { manifest ->
|
||||
addon.copy(
|
||||
manifest = manifest,
|
||||
isRefreshing = false,
|
||||
errorMessage = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
addon.copy(
|
||||
isRefreshing = false,
|
||||
errorMessage = error.message ?: "Unable to load manifest",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRefreshing(manifestUrl: String) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
addons = current.addons.map { addon ->
|
||||
if (addon.manifestUrl == manifestUrl) {
|
||||
addon.copy(
|
||||
isRefreshing = true,
|
||||
errorMessage = null,
|
||||
)
|
||||
} else {
|
||||
addon
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
AddonStorage.saveInstalledAddonUrls(
|
||||
_uiState.value.addons.map { it.manifestUrl },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
require(trimmed.isNotEmpty()) { "Enter an addon URL." }
|
||||
|
||||
val normalizedScheme = when {
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
trimmed.startsWith("stremio://") -> "https://${trimmed.removePrefix("stremio://")}"
|
||||
else -> "https://$trimmed"
|
||||
}
|
||||
|
||||
val withoutFragment = normalizedScheme.substringBefore("#")
|
||||
val query = withoutFragment.substringAfter("?", "")
|
||||
val path = withoutFragment.substringBefore("?").trimEnd('/')
|
||||
val manifestPath = if (path.endsWith("/manifest.json")) {
|
||||
path
|
||||
} else {
|
||||
"$path/manifest.json"
|
||||
}
|
||||
|
||||
return if (query.isEmpty()) manifestPath else "$manifestPath?$query"
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.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.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.SwapVert
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioIconActionButton
|
||||
import com.nuvio.app.core.ui.NuvioInfoBadge
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
|
||||
@Composable
|
||||
fun AddonsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
AddonRepository.initialize()
|
||||
}
|
||||
|
||||
val uiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
var addonUrl by rememberSaveable { mutableStateOf("") }
|
||||
var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var sortAscending by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
val sortedAddons = remember(uiState.addons, sortAscending) {
|
||||
if (sortAscending) {
|
||||
uiState.addons.sortedBy { it.displayTitle.lowercase() }
|
||||
} else {
|
||||
uiState.addons.sortedByDescending { it.displayTitle.lowercase() }
|
||||
}
|
||||
}
|
||||
val overview = remember(sortedAddons) { sortedAddons.toOverview() }
|
||||
|
||||
NuvioScreen(modifier = modifier) {
|
||||
item {
|
||||
NuvioScreenHeader(
|
||||
title = "Addons",
|
||||
) {
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.SwapVert,
|
||||
contentDescription = if (sortAscending) "Sort descending" else "Sort ascending",
|
||||
onClick = { sortAscending = !sortAscending },
|
||||
)
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh all addons",
|
||||
onClick = { AddonRepository.refreshAll() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { SectionHeader("OVERVIEW") }
|
||||
item { OverviewCard(overview = overview) }
|
||||
|
||||
item { SectionHeader("ADD ADDON") }
|
||||
item {
|
||||
AddAddonCard(
|
||||
addonUrl = addonUrl,
|
||||
formMessage = formMessage,
|
||||
onAddonUrlChange = {
|
||||
addonUrl = it
|
||||
formMessage = null
|
||||
},
|
||||
onAddClick = {
|
||||
when (val result = AddonRepository.addAddon(addonUrl)) {
|
||||
AddAddonResult.Success -> {
|
||||
addonUrl = ""
|
||||
formMessage = null
|
||||
}
|
||||
|
||||
is AddAddonResult.Error -> {
|
||||
formMessage = result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item { SectionHeader("INSTALLED ADDONS") }
|
||||
|
||||
if (sortedAddons.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard()
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = sortedAddons,
|
||||
key = { it.manifestUrl },
|
||||
) { addon ->
|
||||
InstalledAddonCard(
|
||||
addon = addon,
|
||||
onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) },
|
||||
onDeleteClick = { AddonRepository.removeAddon(addon.manifestUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
NuvioSectionLabel(text = text)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewCard(overview: AddonOverview) {
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OverviewStat(
|
||||
value = overview.totalAddons.toString(),
|
||||
label = "Addons",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VerticalSeparator()
|
||||
OverviewStat(
|
||||
value = overview.activeAddons.toString(),
|
||||
label = "Active",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VerticalSeparator()
|
||||
OverviewStat(
|
||||
value = overview.totalCatalogs.toString(),
|
||||
label = "Catalogs",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewStat(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalSeparator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(1.dp)
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddonCard(
|
||||
addonUrl: String,
|
||||
formMessage: String?,
|
||||
onAddonUrlChange: (String) -> Unit,
|
||||
onAddClick: () -> Unit,
|
||||
) {
|
||||
NuvioSurfaceCard {
|
||||
NuvioInputField(
|
||||
value = addonUrl,
|
||||
onValueChange = onAddonUrlChange,
|
||||
placeholder = "Addon URL",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = "Add Addon",
|
||||
enabled = addonUrl.isNotBlank(),
|
||||
onClick = onAddClick,
|
||||
)
|
||||
formMessage?.let { message ->
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyStateCard() {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No addons installed yet.",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledAddonCard(
|
||||
addon: ManagedAddon,
|
||||
onRefreshClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
val manifest = addon.manifest
|
||||
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AddonIconBadge(
|
||||
icon = Icons.Rounded.Extension,
|
||||
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = manifest?.name ?: addon.displayTitle,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = addon.manifestUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
manifest?.version?.let { version ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Version $version",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh addon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
onClick = onRefreshClick,
|
||||
)
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete addon",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = onDeleteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(
|
||||
text = when {
|
||||
addon.isRefreshing -> "Refreshing"
|
||||
manifest != null -> "Active"
|
||||
else -> "Unavailable"
|
||||
},
|
||||
)
|
||||
manifest?.let {
|
||||
NuvioInfoBadge(text = "${it.resources.size} resources")
|
||||
NuvioInfoBadge(text = "${it.catalogs.size} catalogs")
|
||||
if (it.behaviorHints.configurable) {
|
||||
NuvioInfoBadge(text = "Configurable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
addon.isRefreshing -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Loading manifest details...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
addon.errorMessage != null && manifest == null -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = addon.errorMessage,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
manifest != null -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = manifest.description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = manifestSummary(manifest),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
addon.errorMessage?.let { staleError ->
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = staleError,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddonIconBadge(
|
||||
icon: ImageVector,
|
||||
tint: Color,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun manifestSummary(manifest: AddonManifest): String {
|
||||
val resources = manifest.resources.joinToString(separator = ", ") { it.name }
|
||||
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
|
||||
return buildString {
|
||||
append(types)
|
||||
append(" • ")
|
||||
append(resources)
|
||||
if (manifest.idPrefixes.isNotEmpty()) {
|
||||
append(" • ")
|
||||
append("${manifest.idPrefixes.size} id rules")
|
||||
}
|
||||
if (manifest.behaviorHints.p2p) {
|
||||
append(" • P2P")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NuvioScreen(modifier = modifier) {
|
||||
item {
|
||||
NuvioScreenHeader(title = "Home")
|
||||
}
|
||||
item {
|
||||
NuvioSectionLabel(text = "OVERVIEW")
|
||||
}
|
||||
item {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "Home screen content comes next.",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Shared cards, spacing and typography are now aligned with the Addons screen so the next feature can reuse the same shell.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import platform.UIKit.UIColor
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
|
||||
private fun nuvioViewController(
|
||||
tab: AppScreenTab,
|
||||
) = ComposeUIViewController {
|
||||
NuvioTheme {
|
||||
AppScreen(tab = tab)
|
||||
}
|
||||
}.apply {
|
||||
view.backgroundColor = UIColor.blackColor
|
||||
}
|
||||
|
||||
fun HomeViewController() = nuvioViewController(tab = AppScreenTab.Home)
|
||||
|
||||
fun AddonsViewController() = nuvioViewController(tab = AppScreenTab.Addons)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import platform.UIKit.UIDevice
|
||||
|
||||
class IOSPlatform: Platform {
|
||||
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = IOSPlatform()
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSUserDefaults
|
||||
import platform.Foundation.dataWithContentsOfURL
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual object AddonStorage {
|
||||
private const val addonUrlsKey = "installed_manifest_urls"
|
||||
|
||||
actual fun loadInstalledAddonUrls(): List<String> =
|
||||
NSUserDefaults.standardUserDefaults
|
||||
.stringForKey(addonUrlsKey)
|
||||
.orEmpty()
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
actual fun saveInstalledAddonUrls(urls: List<String>) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(
|
||||
urls.joinToString(separator = "\n"),
|
||||
forKey = addonUrlsKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun httpGetText(url: String): String =
|
||||
withContext(Dispatchers.Default) {
|
||||
val nsUrl = NSURL(string = url)
|
||||
|
||||
val data = NSData.dataWithContentsOfURL(nsUrl)
|
||||
?: throw IllegalStateException("Request failed")
|
||||
val payload = data.toByteArray().decodeToString()
|
||||
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
}
|
||||
|
||||
payload
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray =
|
||||
ByteArray(length.toInt()).apply {
|
||||
if (isEmpty()) return@apply
|
||||
usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), bytes, length)
|
||||
}
|
||||
}
|
||||
12
gradle.properties
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx3072M
|
||||
|
||||
#Gradle
|
||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
|
||||
#Android
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
43
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
[versions]
|
||||
agp = "8.11.2"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "24"
|
||||
android-targetSdk = "36"
|
||||
androidx-activity = "1.12.2"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.17.0"
|
||||
androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.9.6"
|
||||
androidx-testExt = "1.3.0"
|
||||
composeMultiplatform = "1.10.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.0"
|
||||
kotlinx-serialization = "1.8.1"
|
||||
material3 = "1.10.0-alpha05"
|
||||
|
||||
[libraries]
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
7
iosApp/Configuration/Config.xcconfig
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
TEAM_ID=
|
||||
|
||||
PRODUCT_NAME=Nuvio
|
||||
PRODUCT_BUNDLE_IDENTIFIER=com.nuvio.app.Nuvio$(TEAM_ID)
|
||||
|
||||
CURRENT_PROJECT_VERSION=1
|
||||
MARKETING_VERSION=1.0
|
||||
373
iosApp/iosApp.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
07921B4E8F8546BEE682C2FE /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
77B823F8A18A09B45EA3C7D4 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = E1B229BC363ABB711AF255E3 /* iosApp */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A2F97F59D21EB4D4B662C8D1 /* iosApp */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
77B823F8A18A09B45EA3C7D4 /* Exceptions for "iosApp" folder in "iosApp" target */,
|
||||
);
|
||||
path = iosApp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
69649F6DF5D3AF53A24CA9C3 /* Configuration */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B8D8368DBB6E1F38F403E5AA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4BD16EE0A246E7EC8E40D563 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
69649F6DF5D3AF53A24CA9C3 /* Configuration */,
|
||||
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
|
||||
AD93033A33973C81BB77B73A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AD93033A33973C81BB77B73A /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
07921B4E8F8546BEE682C2FE /* Nuvio.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
E1B229BC363ABB711AF255E3 /* iosApp */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E4075507B4A6F771FEF44E6F /* Build configuration list for PBXNativeTarget "iosApp" */;
|
||||
buildPhases = (
|
||||
8C96BAEBBE1F4269A0BADC2B /* Compile Kotlin Framework */,
|
||||
79E42700D221AF24510E3E09 /* Sources */,
|
||||
B8D8368DBB6E1F38F403E5AA /* Frameworks */,
|
||||
2DAFDB313E32F227766A2072 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
|
||||
);
|
||||
name = iosApp;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = iosApp;
|
||||
productReference = 07921B4E8F8546BEE682C2FE /* Nuvio.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E819B502921EC7C68AC4965A /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
E1B229BC363ABB711AF255E3 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = BC8D3A48007CFD75F9C7AB47 /* Build configuration list for PBXProject "iosApp" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 4BD16EE0A246E7EC8E40D563;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AD93033A33973C81BB77B73A /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E1B229BC363ABB711AF255E3 /* iosApp */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
2DAFDB313E32F227766A2072 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
8C96BAEBBE1F4269A0BADC2B /* Compile Kotlin Framework */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Compile Kotlin Framework";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
79E42700D221AF24510E3E09 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
7E47803C8E15431AEC9C9A71 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = 69649F6DF5D3AF53A24CA9C3 /* Configuration */;
|
||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
08C6158BC74ED5D18954BFD9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = 69649F6DF5D3AF53A24CA9C3 /* Configuration */;
|
||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
4322E267F98B668D798FAEEE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3BEA88C53B91734AEB44313E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BC8D3A48007CFD75F9C7AB47 /* Build configuration list for PBXProject "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7E47803C8E15431AEC9C9A71 /* Debug */,
|
||||
08C6158BC74ED5D18954BFD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E4075507B4A6F771FEF44E6F /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4322E267F98B668D798FAEEE /* Debug */,
|
||||
3BEA88C53B91734AEB44313E /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = E819B502921EC7C68AC4965A /* Project object */;
|
||||
}
|
||||
7
iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 66 KiB |
6
iosApp/iosApp/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
43
iosApp/iosApp/ContentView.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import UIKit
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct HomeComposeView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
let controller = MainViewControllerKt.HomeViewController()
|
||||
controller.view.backgroundColor = .black
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
|
||||
struct AddonsComposeView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
let controller = MainViewControllerKt.AddonsViewController()
|
||||
controller.view.backgroundColor = .black
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
HomeComposeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
|
||||
AddonsComposeView()
|
||||
.tabItem {
|
||||
Label("Addons", systemImage: "puzzlepiece.extension.fill")
|
||||
}
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.toolbarBackground(Color.black, for: .tabBar)
|
||||
.toolbarBackground(.visible, for: .tabBar)
|
||||
.toolbarColorScheme(.dark, for: .tabBar)
|
||||
}
|
||||
}
|
||||
8
iosApp/iosApp/Info.plist
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
22
iosApp/iosApp/iOSApp.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
init() {
|
||||
let tabAppearance = UITabBarAppearance()
|
||||
tabAppearance.configureWithOpaqueBackground()
|
||||
tabAppearance.backgroundColor = UIColor.black
|
||||
|
||||
UITabBar.appearance().standardAppearance = tabAppearance
|
||||
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
|
||||
UITabBar.appearance().unselectedItemTintColor = UIColor(white: 0.58, alpha: 1.0)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
scripts/run-mobile.sh
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GRADLEW="$ROOT_DIR/gradlew"
|
||||
|
||||
ANDROID_APP_ID="com.nuvio.app"
|
||||
ANDROID_ACTIVITY=".MainActivity"
|
||||
IOS_PROJECT="$ROOT_DIR/iosApp/iosApp.xcodeproj"
|
||||
IOS_SCHEME="iosApp"
|
||||
IOS_DERIVED_DATA="$ROOT_DIR/build/ios-derived"
|
||||
IOS_APP_PATH="$IOS_DERIVED_DATA/Build/Products/Debug-iphonesimulator/Nuvio.app"
|
||||
IOS_BUNDLE_ID="com.nuvio.app.Nuvio"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/run-mobile.sh android
|
||||
./scripts/run-mobile.sh ios
|
||||
|
||||
Builds the debug app for the selected platform, installs it on the first running
|
||||
Android emulator or booted iOS simulator, and launches the app.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
first_booted_android_emulator() {
|
||||
adb devices | awk '$2 == "device" && $1 ~ /^emulator-/ { print $1; exit }'
|
||||
}
|
||||
|
||||
first_booted_ios_simulator() {
|
||||
xcrun simctl list devices booted | awk -F '[()]' '/Booted/ { print $2; exit }'
|
||||
}
|
||||
|
||||
run_android() {
|
||||
require_command adb
|
||||
|
||||
local serial
|
||||
serial="$(first_booted_android_emulator)"
|
||||
|
||||
if [[ -z "$serial" ]]; then
|
||||
echo "No running Android emulator found." >&2
|
||||
echo "Start an emulator first, then rerun: ./scripts/run-mobile.sh android" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building Android debug APK..."
|
||||
"$GRADLEW" :composeApp:assembleDebug
|
||||
|
||||
local apk_path
|
||||
apk_path="$ROOT_DIR/composeApp/build/outputs/apk/debug/composeApp-debug.apk"
|
||||
|
||||
if [[ ! -f "$apk_path" ]]; then
|
||||
echo "Expected APK not found at: $apk_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing on emulator $serial..."
|
||||
adb -s "$serial" install -r "$apk_path"
|
||||
|
||||
echo "Launching app..."
|
||||
adb -s "$serial" shell am start -n "$ANDROID_APP_ID/$ANDROID_ACTIVITY"
|
||||
}
|
||||
|
||||
run_ios() {
|
||||
require_command xcodebuild
|
||||
require_command xcrun
|
||||
|
||||
local simulator_udid
|
||||
simulator_udid="$(first_booted_ios_simulator)"
|
||||
|
||||
if [[ -z "$simulator_udid" ]]; then
|
||||
echo "No booted iOS simulator found." >&2
|
||||
echo "Start a simulator first, then rerun: ./scripts/run-mobile.sh ios" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building iOS debug app for simulator $simulator_udid..."
|
||||
xcodebuild \
|
||||
-project "$IOS_PROJECT" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "id=$simulator_udid" \
|
||||
-derivedDataPath "$IOS_DERIVED_DATA" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
if [[ ! -d "$IOS_APP_PATH" ]]; then
|
||||
echo "Expected iOS app not found at: $IOS_APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing on simulator $simulator_udid..."
|
||||
xcrun simctl install "$simulator_udid" "$IOS_APP_PATH"
|
||||
|
||||
echo "Launching app..."
|
||||
xcrun simctl launch "$simulator_udid" "$IOS_BUNDLE_ID"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
android)
|
||||
run_android
|
||||
;;
|
||||
ios)
|
||||
run_ios
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown platform: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
31
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
rootProject.name = "Nuvio"
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
includeGroupAndSubgroups("com.android")
|
||||
includeGroupAndSubgroups("com.google")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
includeGroupAndSubgroups("com.android")
|
||||
includeGroupAndSubgroups("com.google")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
include(":composeApp")
|
||||