Merge remote-tracking branch 'upstream/unstable' into expand-global-var

This commit is contained in:
Charles Ewert 2023-04-22 09:57:27 -04:00
commit 8939ecc9c5
54 changed files with 28657 additions and 33275 deletions

View File

@ -18,4 +18,4 @@ jobs:
days-before-pr-stale: 21 days-before-pr-stale: 21
days-before-pr-close: 7 days-before-pr-close: 7
exempt-draft-pr: true exempt-draft-pr: true
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}

View File

@ -30,4 +30,5 @@ jobs:
uses: eps1lon/actions-label-merge-conflict@releases/2.x uses: eps1lon/actions-label-merge-conflict@releases/2.x
with: with:
dirtyLabel: "merge conflict" dirtyLabel: "merge conflict"
repoToken: ${{ secrets.GITHUB_TOKEN }} commentOnDirty: "This pull request has merge conflicts. Please resolve the conflicts so the PR can be reviewed. Thanks!"
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@ -12,7 +12,7 @@ jobs:
dev: dev:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
with: with:
node-version: "lts/*" node-version: "lts/*"

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout master (the latest release) - name: Checkout master (the latest release)
uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3
with: with:
ref: master ref: master
- name: Install jq to parse json - name: Install jq to parse json
@ -33,7 +33,7 @@ jobs:
- name: Save old Makefile version - name: Save old Makefile version
run: awk 'BEGIN { FS=" = " } /^VERSION/ { print "oldMakeVersion="$2; }' Makefile >> $GITHUB_ENV run: awk 'BEGIN { FS=" = " } /^VERSION/ { print "oldMakeVersion="$2; }' Makefile >> $GITHUB_ENV
- name: Checkout PR branch - name: Checkout PR branch
uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3
- name: Save new package.json version - name: Save new package.json version
run: echo "newPackVersion=$(jq -r ".version" package.json)" >> $GITHUB_ENV run: echo "newPackVersion=$(jq -r ".version" package.json)" >> $GITHUB_ENV
- name: package.json version must be updated - name: package.json version must be updated
@ -61,7 +61,7 @@ jobs:
prod: prod:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
with: with:
node-version: "lts/*" node-version: "lts/*"

17
.gitignore vendored
View File

@ -1,18 +1,17 @@
*.svg *.svg
jellyfin-roku.zip
source/globals.brs
.env .env
# BrightScript
###BrightScript specific
dist/apps dist/apps
out/ out/
build/
roku_modules roku_modules
source/globals.brs
#NPM modules jellyfin-roku.zip
# Rooibos
bsconfig-tdd.json
# NPM
node_modules/ node_modules/
# Eclipse
#Eclipse
.buildpath .buildpath
.project .project
.settings .settings

418
.vscode/brighterscript.code-snippets vendored Normal file
View File

@ -0,0 +1,418 @@
{
"rooibos beforeEach": {
"prefix": "beforeEach",
"body": [
"@beforeEach",
"function ${2:namespace}_${3:itGroup}_beforeEach()",
"\t$0",
"end function"
]
},
"rooibos afterEach": {
"prefix": "afterEach",
"body": [
"@afterEach",
"function ${2:namespace}_${3:itGroup}_afterEach()",
"\t$0",
"end function"
]
},
"rooibos setup": {
"prefix": "setup",
"body": [
"@setup",
"function ${2:namespace}_setup()",
"\t$0",
"end function"
]
},
"rooibos tearDown": {
"prefix": "tearDown",
"body": [
"@tearDown",
"function ${2:namespace}_tearDown()",
"\t$0",
"end function"
]
},
"rooibos ignore": {
"prefix": "ignore",
"body": [
"@ignore ${1:reason}",
"$0"
]
},
"rooibos only": {
"prefix": "only",
"body": [
"@only",
"$0"
]
},
"rooibos testSuite": {
"prefix": "suite",
"body": [
"@suite(\"$1\")",
"$0"
]
},
"rooibos testcase": {
"prefix": "it",
"body": [
"@it(\"$1\")",
"function _()",
"\t$0",
"end function"
]
},
"rooibos params": {
"prefix": "params",
"body": [
"@params(${1:values})$0"
]
},
"rooibos it": {
"prefix": "describe",
"body": [
"'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++",
"@describe(\"${1:groupName}\")",
"'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++",
"",
"$0"
]
},
"rooibos stub": {
"prefix": "stub",
"body": [
"m.stub(${1:target}, \"${2:methodName}\", [${3:methodArgs}], ${4:result})",
"$0"
]
},
"rooibos mock": {
"prefix": "expect",
"body": [
"${1:mockName} = m.mock(${2:target}, \"${3:methodName}\", ${4:expectedNumberOfcalls}, [${5:methodArgs}], ${6:result})",
"$0"
]
},
"rooibos expect": {
"prefix": "expect",
"body": [
"m.expectOnce(${1:target}, \"${2:methodName}\", ${3:expectedNumberOfcalls}, [${4:methodArgs}], ${5:result})",
"$0"
]
},
"rooibos expectOnce": {
"prefix": "expectOnce",
"body": [
"m.expectOnce(${1:target}, \"${2:methodName}\", [${3:methodArgs}], ${4:result})",
"$0"
]
},
"rooibos expectCallfunc": {
"prefix": "expectCallfunc",
"body": [
"m.expectOnce(${1:target}, \"callFunc\", [\"${2:methodName}\", ${3:methodArgs}], ${4:result})",
"$0"
]
},
"rooibos expectObserveNodeField": {
"prefix": "eonf",
"body": [
"m.expectOnce(${1:target}, \"observeNodeField\", [${2:node},\"${3:fieldName}\", m.${4:callback}])",
"$0"
]
},
"rooibos expectUnObserveNodeField": {
"prefix": "eunf",
"body": [
"m.expectOnce(${1:target}, \"unobserveNodeField\", [${2:node},\"${:fieldName}\", m.${4:callback}])",
"$0"
]
},
"rooibos expectObjectOnce": {
"prefix": "expectObjectOnce",
"body": [
"${1:name} = { \"id\" : \"${1:name}\" }",
"m.expectOnce(${2:target}, \"${3:methodName}\", [${4:methodArgs}], ${1:name})",
"$0"
]
},
"rooibos expectGetInstance": {
"prefix": "expectGetInstance",
"body": [
"${1:name} = { \"id\" : \"${1:name}\" }",
"m.expectOnce(${2:target}, \"getInstance\", [\"${3:instanceName}\"], ${1:name})",
"$0"
]
},
"rooibos expectCreateSGNode": {
"prefix": "expectCreateSGNode",
"body": [
"${1:name} = { \"id\" : \"${1:name}\" }",
"m.expectOnce(${2:target}, \"createSGNode\", [\"${3:nodeType}\"$0], ${1:name})"
]
},
"rooibos expectGetClassInstance": {
"prefix": "expectGetClassInstance",
"body": [
"${1:name} = { \"id\" : \"${1:name}\" }",
"m.expectOnce(${2:target}, \"getClassInstance\", [\"${3:instanceName}\"], ${1:name})",
"$0"
]
},
"rooibos expectExpectOnce": {
"prefix": "expectExpect",
"body": [
"${1:name} = { \"id\" : \"${1:name}\" }",
"m.expectOnce(${2:target}, \"${3:methodName}\", [${4:methodArgs}], ${1:name})",
"m.expectOnce(${1:name}, \"${5:methodName}\", [${6:methodArgs}], ${7:name})",
"$0"
]
},
"rooibos expectNone": {
"prefix": "expectNone",
"body": [
"m.expectNone(${1:target}, \"${2:methodName}\")",
"$0"
]
},
"rooibos assertFalse": {
"prefix": "assertFalse",
"body": [
"m.assertFalse(${1:value})",
"$0"
]
},
"rooibos assertAsync": {
"prefix": "assertAsync",
"body": [
"m.AssertAsyncField(${1:value}, $2{:fieldName})",
"$0"
]
},
"rooibos assertTrue": {
"prefix": "assertTrue",
"body": [
"m.assertTrue(${1:value})",
"$0"
]
},
"rooibos assertEqual": {
"prefix": "assertEqual",
"body": [
"m.assertEqual(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertLike": {
"prefix": "assertLike",
"body": [
"m.assertLike(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNotEqual": {
"prefix": "assertNotEqual",
"body": [
"m.assertNotEqual(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertInvalid": {
"prefix": "assertInvalid",
"body": [
"m.assertInvalid(${1:value})",
"$0"
]
},
"rooibos assertNotInvalid": {
"prefix": "assertNotInvalid",
"body": [
"m.assertNotInvalid(${1:value})",
"$0"
]
},
"rooibos assertAAHasKey": {
"prefix": "assertAAHasKey",
"body": [
"m.assertAAHasKey(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertAANotHasKey": {
"prefix": "assertAANotHasKey",
"body": [
"m.assertAANotHasKey(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertAAHasKeys": {
"prefix": "assertAAHasKeys",
"body": [
"m.assertAAHasKeys(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertAANotHasKeys": {
"prefix": "assertAANotHasKeys",
"body": [
"m.assertAANotHasKeys(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayContains": {
"prefix": "assertArrayContains",
"body": [
"m.assertArrayContains(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayNotContains": {
"prefix": "assertArrayNotContains",
"body": [
"m.assertArrayNotContains(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayContainsSubset": {
"prefix": "assertArrayContainsSubset",
"body": [
"m.assertArrayContainsSubset(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayContainsAAs": {
"prefix": "assertArrayContainsAAs",
"body": [
"m.assertArrayContainsAAs(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayNotContainsSubset": {
"prefix": "assertArrayNotContainsSubset",
"body": [
"m.assertArrayNotContainsSubset(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayCount": {
"prefix": "assertArrayCount",
"body": [
"m.assertArrayCount(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertArrayNotCount": {
"prefix": "assertArrayNotCount",
"body": [
"m.assertArrayNotCount(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertEmpty": {
"prefix": "assertEmpty",
"body": [
"m.assertEmpty(${1:value})",
"$0"
]
},
"rooibos assertNotEmpty": {
"prefix": "assertNotEmpty",
"body": [
"m.assertNotEmpty(${1:value})",
"$0"
]
},
"rooibos assertArrayContainsOnlyValuesOfType": {
"prefix": "assertArrayContainsOnlyValuesOfType",
"body": [
"m.assertArrayContainsOnlyValuesOfType(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertType": {
"prefix": "assertType",
"body": [
"m.assertType(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertSubType": {
"prefix": "assertSubType",
"body": [
"m.assertSubType(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeCount": {
"prefix": "assertNodeCount",
"body": [
"m.assertNodeCount(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeNotCount": {
"prefix": "assertNodeNotCount",
"body": [
"m.assertNodeNotCount(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeEmpty": {
"prefix": "assertNodeEmpty",
"body": [
"m.assertNodeEmpty(${1:value})",
"$0"
]
},
"rooibos assertNodeNotEmpty": {
"prefix": "assertNodeNotEmpty",
"body": [
"m.assertNodeNotEmpty(${1:value})",
"$0"
]
},
"rooibos assertNodeContains": {
"prefix": "assertNodeContains",
"body": [
"m.assertNodeContains(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeNotContains": {
"prefix": "assertNodeNotContains",
"body": [
"m.assertNodeNotContains(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeContainsFields": {
"prefix": "assertNodeContainsFields",
"body": [
"m.assertNodeContainsFields(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertNodeNotContainsFields": {
"prefix": "assertNodeNotContainsFields",
"body": [
"m.assertNodeNotContainsFields(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertAAContainsSubset": {
"prefix": "assertAAContainsSubset",
"body": [
"m.assertAAContainsSubset(${1:value}, ${2:expected})",
"$0"
]
},
"rooibos assertMocks": {
"prefix": "assertMocks",
"body": [
"m.assertMocks(${1:value}, ${2:expected})",
"$0"
]
}
}

66
.vscode/launch.json vendored
View File

@ -4,8 +4,12 @@
{ {
"type": "brightscript", "type": "brightscript",
"request": "launch", "request": "launch",
"name": "Jellyfin Debug: Launch", "name": "Jellyfin Debug",
"stopOnEntry": false, "stopOnEntry": false,
// To enable RALE:
// set "brightscript.debug.raleTrackerTaskFileLocation": "/absolute/path/to/rale/TrackerTask.xml" in your vscode user settings
// set the below field to true
"injectRaleTrackerTask": false,
//WARNING: don't edit this value. Instead, set "brightscript.debug.host": "YOUR_HOST_HERE" in your vscode user settings //WARNING: don't edit this value. Instead, set "brightscript.debug.host": "YOUR_HOST_HERE" in your vscode user settings
//"host": "${promptForHost}", //"host": "${promptForHost}",
//WARNING: don't edit this value. Instead, set "brightscript.debug.password": "YOUR_PASSWORD_HERE" in your vscode user settings //WARNING: don't edit this value. Instead, set "brightscript.debug.password": "YOUR_PASSWORD_HERE" in your vscode user settings
@ -18,6 +22,66 @@
"source/**/*", "source/**/*",
"manifest" "manifest"
] ]
},
{
"name": "Run tests",
"type": "brightscript",
"request": "launch",
"consoleOutput": "full",
"internalConsoleOptions": "neverOpen",
"preLaunchTask": "build-tests",
"retainStagingFolder": true,
"stopOnEntry": false,
"files": [
"!**/images/*.*",
"!**/fonts/*.*",
"!*.jpg",
"!*.png",
"*",
"*.*",
"**/*.*",
"!*.zip",
"!**/*.zip"
],
"rootDir": "${workspaceFolder}/build",
"sourceDirs": [
"${workspaceFolder}/test-app"
],
"enableDebuggerAutoRecovery": true,
"stopDebuggerOnAppExit": true,
"enableVariablesPanel": false,
"injectRaleTrackerTask": false,
"enableDebugProtocol": false
},
{
"name": "Run test-tdd",
"type": "brightscript",
"request": "launch",
"consoleOutput": "full",
"internalConsoleOptions": "neverOpen",
"preLaunchTask": "build-tdd",
"retainStagingFolder": true,
"stopOnEntry": false,
"files": [
"!**/images/*.*",
"!**/fonts/*.*",
"!*.jpg",
"!*.png",
"*",
"*.*",
"**/*.*",
"!*.zip",
"!**/*.zip"
],
"rootDir": "${workspaceFolder}/build",
"sourceDirs": [
"${workspaceFolder}/test-app"
],
"enableDebuggerAutoRecovery": true,
"stopDebuggerOnAppExit": true,
"enableVariablesPanel": false,
"injectRaleTrackerTask": false,
"enableDebugProtocol": false
} }
] ]
} }

View File

@ -2,6 +2,13 @@
"files.associations": { "files.associations": {
"*.ts": "xml" "*.ts": "xml"
}, },
"[xml]": {
"editor.defaultFormatter": "redhat.vscode-xml"
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
"xml.format.maxLineWidth": 0, "xml.format.maxLineWidth": 0,
"editor.formatOnSave": true "editor.formatOnSave": true,
"brightscript.bsdk": "node_modules/brighterscript"
} }

41
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-tests",
"type": "shell",
"command": "npm run build-tests",
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "build-tdd",
"type": "shell",
"command": "npm run build-tdd",
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
},
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

View File

@ -27,7 +27,7 @@ Follow the steps below to install the app on your personal Roku device. This wil
## Developer Mode ## Developer Mode
Put your Roku device in [developer mode](https://blog.roku.com/developer/2016/02/04/developer-setup-guide). Write down your Roku device IP and the password you created, you will need these later. Put your Roku device in [developer mode](https://blog.roku.com/developer/2016/02/04/developer-setup-guide). Write down your Roku device IP and the password you created - you will need these!
## Clone the GitHub Repo ## Clone the GitHub Repo
@ -71,7 +71,7 @@ That's it! VSCode will auto-package the project, sideload it to the specified de
Out of the box, the BrightScript extension will prompt you to pick a Roku device (from devices found on your local network) and enter a password on every launch. If you'd prefer to hardcode this information rather than entering it every time, you can set these values in your VSCode user settings: Out of the box, the BrightScript extension will prompt you to pick a Roku device (from devices found on your local network) and enter a password on every launch. If you'd prefer to hardcode this information rather than entering it every time, you can set these values in your VSCode user settings:
```js ```json
{ {
"brightscript.debug.host": "YOUR_ROKU_HOST_HERE", "brightscript.debug.host": "YOUR_ROKU_HOST_HERE",
"brightscript.debug.password": "YOUR_ROKU_DEV_PASSWORD_HERE", "brightscript.debug.password": "YOUR_ROKU_DEV_PASSWORD_HERE",

View File

@ -10,7 +10,7 @@
########################################################################## ##########################################################################
APPNAME = Jellyfin_Roku APPNAME = Jellyfin_Roku
VERSION = 1.6.4 VERSION = 1.6.5
ZIP_EXCLUDE= -x xml/* -x artwork/* -x \*.pkg -x storeassets\* -x keys\* -x \*/.\* -x *.git* -x *.DS* -x *.pkg* -x dist/**\* -x out/**\* ZIP_EXCLUDE= -x xml/* -x artwork/* -x \*.pkg -x storeassets\* -x keys\* -x \*/.\* -x *.git* -x *.DS* -x *.pkg* -x dist/**\* -x out/**\*

View File

@ -1,39 +1,41 @@
<h1 style="text-align: center;">Jellyfin app for Roku</h1> <h1 align="center">Jellyfin Roku</h1>
<h3 style="text-align: center;">Part of the <a href="https://jellyfin.media/">Jellyfin</a> Project</h3> <h2 align="center">Part of the <a href="https://jellyfin.org">Jellyfin Project</a></h2>
<p align="center"> [![Logo Banner](https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true "Jellyfin")](https://jellyfin.org)
<img alt="Logo banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
<br/><br/>
<a href="https://github.com/jellyfin/jellyfin-roku">
<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin-roku.svg"/>
</a>
<a href="https://github.com/jellyfin/jellyfin-roku/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin-roku.svg"/>
</a>
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-roku/?utm_source=widget">
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-roku/svg-badge.svg" alt="Translation status" />
</a>
<br/>
<a href="https://matrix.to/#/#jellyfin-dev-roku:matrix.org">
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://www.reddit.com/r/jellyfin">
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
</a>
</p>
The Jellyfin Roku App is a Jellyfin client for Roku Devices. This is still very much a work in progress, so we would encourage you to [get involved](#get_involved) if you can. [![Build Status](https://img.shields.io/github/actions/workflow/status/jellyfin/jellyfin-roku/build-dev.yml?logo=github&branch=unstable "Build Status")](https://github.com/jellyfin/jellyfin-roku/actions/workflows/build-dev.yml?query=branch%3Aunstable)
[![Current Release](https://img.shields.io/github/release/jellyfin/jellyfin-roku.svg?logo=github "Current Release")](https://github.com/jellyfin/jellyfin-roku/releases)
[![Translation Status](https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-roku/svg-badge.svg "Translation Status")](https://translate.jellyfin.org/projects/jellyfin/jellyfin-roku/?utm_source=widget)
[![Matrix](https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix "Chat on Matrix")](https://matrix.to/#/#jellyfin-dev-roku:matrix.org)
[![Reddit](https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg?logo=reddit "Join our Subreddit")](https://www.reddit.com/r/jellyfin)
[![License](https://img.shields.io/github/license/jellyfin/jellyfin-roku.svg "GPL 2.0 License")](LICENSE)
## Getting Started Jellyfin Roku is the official Jellyfin client for Roku devices. We welcome all contributions and pull requests! If you have a larger feature in mind please [open an issue](https://github.com/jellyfin/jellyfin-roku/issues/new?assignees=&labels=feature&template=feature_request.md&title=) so we can discuss the implementation before you start.
The channel is available on the [Roku Channel Store](https://channelstore.roku.com/details/cc5e559d08d9ec87c5f30dcebdeebc12/jellyfin). ## Install
## Getting Involved<a name="get_involved"></a> Download the latest release on the [Roku Channel Store](https://channelstore.roku.com/details/cc5e559d08d9ec87c5f30dcebdeebc12/jellyfin).
No matter what your interests or skill are, you can help to make this client better for everyone by simply using the client and letting us know if you find a problem with it. Either give us a shout on [matrix](https://matrix.to/#/+jellyfin:matrix.org) or create a GitHub issue. ## Get Involved
Feature requests are always welcome too, but please have a read though the existing issues to see if someone has already raised one for something similar. No matter what your interests or skills are you can help make this client better for everyone by simply using the client and giving feedback to the developers when things break. [Create an issue](https://github.com/jellyfin/jellyfin-roku/issues/new/choose) here on GitHub or give us a shout on [Matrix](https://matrix.to/#/#jellyfin-dev-roku:matrix.org).
If you fancy some development, then read the [DEVGUIDE](DEVGUIDE.md) to find out the best ways to help. ## Beta Test
As Roku have severely limited their Beta channel program, the best way to test pre-release versions is by following the [DEVGUIDE](DEVGUIDE.md) to install and test the latest changes. Feedback is always welcome. To test the latest features before they get released:
1. Put your Roku device in [developer mode](https://blog.roku.com/developer/2016/02/04/developer-setup-guide). Write down your Roku device IP and the password you created - you will need these!
2. Download the [latest build](https://github.com/jellyfin/jellyfin-roku/actions/workflows/build-dev.yml?query=branch%3Aunstable). Select the first item listed then click the link at the bottom of the page i.e. `Jellyfin-Roku-dev-d3352495c579f6adeca085cdbc137ac36e70d558`. This will download a zip file to your computer.
3. Put your Roku's IP from step 1 into a browser i.e. `http://192.168.1.2` and press enter.
4. Log in with credentials from step 1.
5. Upload and install the zip file downloaded in step 2.
> NOTE: The beta app will always be at the bottom of your Roku's channel list and it will *not* automatically update.
## Advanced
For more advanced deployment methods, access to crash logs, or to learn how to setup a developer environment so you can write some code yourself please read the [DEVGUIDE](DEVGUIDE.md).
## Feature Requests
New feature requests are always welcome but before creating an issue please read through the [existing issues](https://github.com/jellyfin/jellyfin-roku/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) to see if someone has already raised one for what you're looking for.

44
bsconfig-tests.json Normal file
View File

@ -0,0 +1,44 @@
{
"files": [
{
"src": "test-app/**/*"
},
{
"src": "source/**/!(Main.brs)",
"dest": "source"
},
{
"src": "components/**/*",
"dest": "components"
},
{
"src": "locale/**/*",
"dest": "locale"
},
{
"src": "settings/**/*",
"dest": "settings"
}
],
"autoImportComponentScript": true,
"createPackage": false,
"stagingFolderPath": "build",
"plugins": [
"rooibos-roku"
],
"rooibos": {
"isRecordingCodeCoverage": false,
"testsFilePattern": null,
"tags": [
"!integration",
"!deprecated",
"!fixme"
],
"showOnlyFailures": true,
"catchCrashes": true,
"lineWidth": 70,
"failFast": false,
"sendHomeOnFinish": false
},
"sourceMap": true
}

View File

@ -9,11 +9,11 @@
"settings/*.*" "settings/*.*"
], ],
"plugins": [ "plugins": [
"@rokucommunity/bslint" "@rokucommunity/bslint",
"rooibos-roku"
], ],
"diagnosticFilters": [ "diagnosticFilters": [
"**/roku_modules/**/*", "node_modules/**",
"**/testFramework/*", "**/roku_modules/**"
"**/tests/*"
] ]
} }

View File

@ -1,6 +1,9 @@
{ {
"files": [ "files": [
"source/**/*.brs", "source/**/*.brs",
"components/**/*.brs" "source/**/*.bs",
"components/**/*.brs",
"components/**/*.bs",
"test-app/**/*.bs"
] ]
} }

View File

@ -44,7 +44,6 @@ sub LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx = 1, subtit
videotype = LCase(meta.type) videotype = LCase(meta.type)
if videotype = "episode" or videotype = "series" if videotype = "episode" or videotype = "series"
video.runTime = (meta.json.RunTimeTicks / 10000000.0)
video.content.contenttype = "episode" video.content.contenttype = "episode"
end if end if

View File

@ -23,8 +23,10 @@ sub init()
m.overlayMinutes = m.top.findNode("overlayMinutes") m.overlayMinutes = m.top.findNode("overlayMinutes")
m.overlayMeridian = m.top.findNode("overlayMeridian") m.overlayMeridian = m.top.findNode("overlayMeridian")
m.overlayMeridian.font.size = 20 m.overlayMeridian.font.size = 20
' start timer
m.currentTimeTimer = m.top.findNode("currentTimeTimer") m.currentTimeTimer = m.top.findNode("currentTimeTimer")
' display current time
updateTime()
' start timer to update clock every minute
m.currentTimeTimer.control = "start" m.currentTimeTimer.control = "start"
m.currentTimeTimer.ObserveField("fire", "updateTime") m.currentTimeTimer.ObserveField("fire", "updateTime")
end if end if

View File

@ -20,6 +20,11 @@ sub init()
m.nextEpisodeButton.text = tr("Next Episode") m.nextEpisodeButton.text = tr("Next Episode")
m.nextEpisodeButton.setFocus(false) m.nextEpisodeButton.setFocus(false)
m.nextupbuttonseconds = get_user_setting("playback.nextupbuttonseconds", "30") m.nextupbuttonseconds = get_user_setting("playback.nextupbuttonseconds", "30")
if isValid(m.nextupbuttonseconds)
m.nextupbuttonseconds = val(m.nextupbuttonseconds)
else
m.nextupbuttonseconds = 30
end if
m.showNextEpisodeButtonAnimation = m.top.findNode("showNextEpisodeButton") m.showNextEpisodeButtonAnimation = m.top.findNode("showNextEpisodeButton")
m.hideNextEpisodeButtonAnimation = m.top.findNode("hideNextEpisodeButton") m.hideNextEpisodeButtonAnimation = m.top.findNode("hideNextEpisodeButton")
@ -28,10 +33,12 @@ sub init()
m.getNextEpisodeTask = createObject("roSGNode", "GetNextEpisodeTask") m.getNextEpisodeTask = createObject("roSGNode", "GetNextEpisodeTask")
m.getNextEpisodeTask.observeField("nextEpisodeData", "onNextEpisodeDataLoaded") m.getNextEpisodeTask.observeField("nextEpisodeData", "onNextEpisodeDataLoaded")
m.top.observeField("state", "onState") m.top.observeField("allowCaptions", "onAllowCaptionsChange")
m.top.observeField("content", "onContentChange") end sub
sub onAllowCaptionsChange()
if not m.top.allowCaptions then return
'Captions
m.captionGroup = m.top.findNode("captionGroup") m.captionGroup = m.top.findNode("captionGroup")
m.captionGroup.createchildren(9, "LayoutGroup") m.captionGroup.createchildren(9, "LayoutGroup")
m.captionTask = createObject("roSGNode", "captionTask") m.captionTask = createObject("roSGNode", "captionTask")
@ -85,7 +92,6 @@ end sub
' '
' Runs Next Episode button animation and sets focus to button ' Runs Next Episode button animation and sets focus to button
sub showNextEpisodeButton() sub showNextEpisodeButton()
if m.top.content.contenttype <> 4 then return
if m.global.userConfig.EnableNextEpisodeAutoPlay and not m.nextEpisodeButton.visible if m.global.userConfig.EnableNextEpisodeAutoPlay and not m.nextEpisodeButton.visible
m.showNextEpisodeButtonAnimation.control = "start" m.showNextEpisodeButtonAnimation.control = "start"
m.nextEpisodeButton.setFocus(true) m.nextEpisodeButton.setFocus(true)
@ -96,7 +102,7 @@ end sub
' '
'Update count down text 'Update count down text
sub updateCount() sub updateCount()
nextEpisodeCountdown = Int(m.top.runTime - m.top.position) nextEpisodeCountdown = Int(m.top.duration - m.top.position)
if nextEpisodeCountdown < 0 if nextEpisodeCountdown < 0
nextEpisodeCountdown = 0 nextEpisodeCountdown = 0
end if end if
@ -114,8 +120,9 @@ end sub
' Checks if we need to display the Next Episode button ' Checks if we need to display the Next Episode button
sub checkTimeToDisplayNextEpisode() sub checkTimeToDisplayNextEpisode()
if m.top.content.contenttype <> 4 then return if m.top.content.contenttype <> 4 then return
if m.nextupbuttonseconds = 0 then return
if int(m.top.position) >= (m.top.runTime - 30) if int(m.top.position) >= (m.top.duration - m.nextupbuttonseconds)
showNextEpisodeButton() showNextEpisodeButton()
updateCount() updateCount()
return return
@ -129,7 +136,9 @@ end sub
' When Video Player state changes ' When Video Player state changes
sub onPositionChanged() sub onPositionChanged()
m.captionTask.currentPos = Int(m.top.position * 1000) if isValid(m.captionTask)
m.captionTask.currentPos = Int(m.top.position * 1000)
end if
' Check if dialog is open ' Check if dialog is open
m.dialog = m.top.getScene().findNode("dialogBackground") m.dialog = m.top.getScene().findNode("dialogBackground")
if not isValid(m.dialog) if not isValid(m.dialog)
@ -140,7 +149,9 @@ end sub
' '
' When Video Player state changes ' When Video Player state changes
sub onState(msg) sub onState(msg)
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode if isValid(m.captionTask)
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
end if
' When buffering, start timer to monitor buffering process ' When buffering, start timer to monitor buffering process
if m.top.state = "buffering" and m.bufferCheckTimer <> invalid if m.top.state = "buffering" and m.bufferCheckTimer <> invalid

View File

@ -16,13 +16,12 @@
<field id="transcodeAvailable" type="boolean" value="false" /> <field id="transcodeAvailable" type="boolean" value="false" />
<field id="retryWithTranscoding" type="boolean" value="false" /> <field id="retryWithTranscoding" type="boolean" value="false" />
<field id="isTranscoded" type="boolean" /> <field id="isTranscoded" type="boolean" />
<field id="allowCaptions" type="boolean" value="false" />
<field id="transcodeReasons" type="array" /> <field id="transcodeReasons" type="array" />
<field id="videoId" type="string" /> <field id="videoId" type="string" />
<field id="mediaSourceId" type="string" /> <field id="mediaSourceId" type="string" />
<field id="audioIndex" type="integer" /> <field id="audioIndex" type="integer" />
<field id="runTime" type="integer" />
</interface> </interface>
<script type="text/brightscript" uri="JFVideo.brs" /> <script type="text/brightscript" uri="JFVideo.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/misc.brs" /> <script type="text/brightscript" uri="pkg:/source/utils/misc.brs" />

View File

@ -28,13 +28,12 @@ end sub
sub setFont() sub setFont()
fs = CreateObject("roFileSystem") fs = CreateObject("roFileSystem")
fontlist = fs.Find("tmp:/", "font")
if fontlist.count() > 0 if fs.Exists("tmp:/font")
m.font.uri = "tmp:/" + fontlist[0] m.font.uri = "tmp:/font"
m.font.size = m.fontSize m.font.size = m.fontSize
else else
reg = CreateObject("roFontRegistry") m.font = "font:LargeSystemFont"
m.font = reg.GetDefaultFont(m.fontSize, false, false)
end if end if
end sub end sub
@ -56,6 +55,7 @@ function newlabel(txt)
label = CreateObject("roSGNode", "Label") label = CreateObject("roSGNode", "Label")
label.text = txt label.text = txt
label.font = m.font label.font = m.font
label.font.size = m.fontSize
label.color = m.textColor label.color = m.textColor
label.opacity = m.textOpac label.opacity = m.textOpac
return label return label
@ -89,7 +89,7 @@ function newRect(lg)
end function end function
sub updateCaption () sub updateCaption()
m.top.currentCaption = [] m.top.currentCaption = []
if LCase(m.top.playerState) = "playingon" if LCase(m.top.playerState) = "playingon"
m.top.currentPos = m.top.currentPos + 100 m.top.currentPos = m.top.currentPos + 100

View File

@ -166,6 +166,20 @@ sub itemContentChanged()
return return
end if end if
if itemData.type = "BoxSet"
m.itemText.text = itemData.name
m.itemPoster.uri = itemData.posterURL
' Set small text to number of items in the collection
if isValid(itemData.json) and isValid(itemData.json.ChildCount)
m.itemTextExtra.text = StrI(itemData.json.ChildCount).trim() + " item"
if itemData.json.ChildCount > 1
m.itemTextExtra.text += "s"
end if
end if
return
end if
if itemData.type = "Series" if itemData.type = "Series"
m.itemText.text = itemData.name m.itemText.text = itemData.name

View File

@ -83,18 +83,6 @@ sub onLibrariesLoaded()
haveLiveTV = false haveLiveTV = false
' Load the NextUp Data
m.LoadNextUpTask.observeField("content", "updateNextUpItems")
m.LoadNextUpTask.control = "RUN"
' Load the Continue Watching Data
m.LoadContinueTask.observeField("content", "updateContinueItems")
m.LoadContinueTask.control = "RUN"
' Load the Favorites Data
m.LoadFavoritesTask.observeField("content", "updateFavoritesItems")
m.LoadFavoritesTask.control = "RUN"
' validate library data ' validate library data
if isValid(m.libraryData) and m.libraryData.count() > 0 if isValid(m.libraryData) and m.libraryData.count() > 0
userConfig = m.global.userConfig userConfig = m.global.userConfig
@ -112,42 +100,42 @@ sub onLibrariesLoaded()
latestInRow = content.CreateChild("HomeRow") latestInRow = content.CreateChild("HomeRow")
latestInRow.title = tr("Latest in") + " " + lib.name + " >" latestInRow.title = tr("Latest in") + " " + lib.name + " >"
sizeArray.Push([464, 331]) sizeArray.Push([464, 331])
loadLatest = createObject("roSGNode", "LoadItemsTask")
loadLatest.itemsToLoad = "latest"
loadLatest.itemId = lib.id
metadata = { "title": lib.name }
metadata.Append({ "contentType": lib.json.CollectionType })
loadLatest.metadata = metadata
loadLatest.observeField("content", "updateLatestItems")
loadLatest.control = "RUN"
else if lib.collectionType = "livetv" else if lib.collectionType = "livetv"
' If we have Live TV, add "On Now" ' If we have Live TV, add "On Now"
onNowRow = content.CreateChild("HomeRow") onNowRow = content.CreateChild("HomeRow")
onNowRow.title = tr("On Now") onNowRow.title = tr("On Now")
sizeArray.Push([464, 331]) sizeArray.Push([464, 331])
haveLiveTV = true haveLiveTV = true
' If we have Live TV access, load "On Now" data
if haveLiveTV
m.LoadOnNowTask.observeField("content", "updateOnNowItems")
m.LoadOnNowTask.control = "RUN"
end if
end if end if
end for end for
end if end if
m.top.rowItemSize = sizeArray m.top.rowItemSize = sizeArray
m.top.content = content m.top.content = content
' Load the Continue Watching Data
m.LoadContinueTask.observeField("content", "updateContinueItems")
m.LoadContinueTask.control = "RUN"
' Load the Favorites Data
m.LoadFavoritesTask.observeField("content", "updateFavoritesItems")
m.LoadFavoritesTask.control = "RUN"
' If we have Live TV access, load "On Now" data
if haveLiveTV
m.LoadOnNowTask.observeField("content", "updateOnNowItems")
m.LoadOnNowTask.control = "RUN"
end if
end sub end sub
sub updateHomeRows() sub updateHomeRows()
if m.global.playstateTask.state = "run" if m.global.playstateTask.state = "run"
m.global.playstateTask.observeField("state", "updateHomeRows") m.global.playstateTask.observeField("state", "updateHomeRows")
else return
m.global.playstateTask.unobserveField("state")
end if end if
m.global.playstateTask.unobserveField("state")
m.LoadContinueTask.observeField("content", "updateContinueItems") m.LoadContinueTask.observeField("content", "updateContinueItems")
m.LoadContinueTask.control = "RUN" m.LoadContinueTask.control = "RUN"
end sub end sub
@ -237,6 +225,9 @@ sub updateContinueItems()
homeRows.replaceChild(row, continueRowIndex) homeRows.replaceChild(row, continueRowIndex)
end if end if
end if end if
m.LoadNextUpTask.observeField("content", "updateNextUpItems")
m.LoadNextUpTask.control = "RUN"
end sub end sub
sub updateNextUpItems() sub updateNextUpItems()
@ -289,6 +280,24 @@ sub updateNextUpItems()
m.global.app_loaded = true m.global.app_loaded = true
end if end if
' create task nodes for "Latest In" rows
userConfig = m.global.userConfig
filteredLatest = filterNodeArray(m.libraryData, "id", userConfig.LatestItemsExcludes)
for each lib in filteredLatest
if lib.collectionType <> "livetv" and lib.collectionType <> "boxsets" and lib.json.CollectionType <> "Program"
loadLatest = createObject("roSGNode", "LoadItemsTask")
loadLatest.itemsToLoad = "latest"
loadLatest.itemId = lib.id
metadata = { "title": lib.name }
metadata.Append({ "contentType": lib.json.CollectionType })
loadLatest.metadata = metadata
loadLatest.observeField("content", "updateLatestItems")
loadLatest.control = "RUN"
end if
end for
end sub end sub
sub updateLatestItems(msg) sub updateLatestItems(msg)

View File

@ -132,6 +132,12 @@ sub loadItems()
' Skip Books for now as we don't support it (issue #558) ' Skip Books for now as we don't support it (issue #558)
if item.Type <> "Book" if item.Type <> "Book"
tmp = CreateObject("roSGNode", "HomeData") tmp = CreateObject("roSGNode", "HomeData")
params = {}
params["Tags"] = item.PrimaryImageTag
params["MaxWidth"] = 234
params["MaxHeight"] = 330
tmp.posterURL = ImageUrl(item.Id, "Primary", params)
tmp.json = item tmp.json = item
results.push(tmp) results.push(tmp)
end if end if

View File

@ -1,7 +1,9 @@
sub init() sub init()
m.queue = [] m.queue = []
m.originalQueue = []
m.queueTypes = [] m.queueTypes = []
m.position = 0 m.position = 0
m.shuffleEnabled = false
end sub end sub
' Clear all content from play queue ' Clear all content from play queue
@ -27,6 +29,11 @@ function getCurrentItem()
return getItemByIndex(m.position) return getItemByIndex(m.position)
end function end function
' Return whether or not shuffle is enabled
function getIsShuffled()
return m.shuffleEnabled
end function
' Return the item in the passed index from the play queue ' Return the item in the passed index from the play queue
function getItemByIndex(index) function getItemByIndex(index)
return m.queue[index] return m.queue[index]
@ -108,6 +115,54 @@ sub setPosition(newPosition)
m.position = newPosition m.position = newPosition
end sub end sub
' Reset shuffle to off state
sub resetShuffle()
m.shuffleEnabled = false
end sub
' Toggle shuffleEnabled state
sub toggleShuffle()
m.shuffleEnabled = not m.shuffleEnabled
if m.shuffleEnabled
shuffleQueueItems()
return
end if
resetQueueItemOrder()
end sub
' Reset queue items back to original, unshuffled order
sub resetQueueItemOrder()
set(m.originalQueue)
end sub
' Return original, unshuffled queue
function getUnshuffledQueue()
return m.originalQueue
end function
' Save a copy of the original queue and randomize order of queue items
sub shuffleQueueItems()
' By calling getQueue 2 different ways, Roku avoids needing to do a deep copy
m.originalQueue = m.global.queueManager.callFunc("getQueue")
songIDArray = getQueue()
' Move the currently playing song to the front of the queue
temp = top()
songIDArray[0] = getCurrentItem()
songIDArray[getPosition()] = temp
for i = 1 to songIDArray.count() - 1
j = Rnd(songIDArray.count() - 1)
temp = songIDArray[i]
songIDArray[i] = songIDArray[j]
songIDArray[j] = temp
end for
set(songIDArray)
end sub
' Return the fitst item in the play queue ' Return the fitst item in the play queue
function top() function top()
return getItemByIndex(0) return getItemByIndex(0)
@ -115,7 +170,7 @@ end function
' Replace play queue with passed array ' Replace play queue with passed array
sub set(items) sub set(items)
setPosition(0) clear()
m.queue = items m.queue = items
for each item in items for each item in items
m.queueTypes.push(getItemType(item)) m.queueTypes.push(getItemType(item))

View File

@ -5,19 +5,23 @@
<function name="deleteAtIndex" /> <function name="deleteAtIndex" />
<function name="getCount" /> <function name="getCount" />
<function name="getCurrentItem" /> <function name="getCurrentItem" />
<function name="getIsShuffled" />
<function name="getItemByIndex" /> <function name="getItemByIndex" />
<function name="getPosition" /> <function name="getPosition" />
<function name="getQueue" /> <function name="getQueue" />
<function name="getQueueTypes" /> <function name="getQueueTypes" />
<function name="getQueueUniqueTypes" /> <function name="getQueueUniqueTypes" />
<function name="getUnshuffledQueue" />
<function name="moveBack" /> <function name="moveBack" />
<function name="moveForward" /> <function name="moveForward" />
<function name="peek" /> <function name="peek" />
<function name="playQueue" /> <function name="playQueue" />
<function name="pop" /> <function name="pop" />
<function name="push" /> <function name="push" />
<function name="resetShuffle" />
<function name="set" /> <function name="set" />
<function name="setPosition" /> <function name="setPosition" />
<function name="toggleShuffle" />
<function name="top" /> <function name="top" />
</interface> </interface>
<script type="text/brightscript" uri="QueueManager.brs" /> <script type="text/brightscript" uri="QueueManager.brs" />

View File

@ -1,5 +1,6 @@
sub init() sub init()
m.playReported = false m.playReported = false
m.top.disableScreenSaver = true
m.top.observeField("state", "audioStateChanged") m.top.observeField("state", "audioStateChanged")
end sub end sub

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<component name="AudioPlayer" extends="Audio"> <component name="AudioPlayer" extends="Video">
<interface> <interface>
<field id="loopMode" type="string" value="" /> <field id="loopMode" type="string" value="" />
</interface> </interface>

View File

@ -10,7 +10,6 @@ sub init()
m.playlistTypeCount = m.global.queueManager.callFunc("getQueueUniqueTypes").count() m.playlistTypeCount = m.global.queueManager.callFunc("getQueueUniqueTypes").count()
m.shuffleEnabled = false
m.buttonCount = m.buttons.getChildCount() m.buttonCount = m.buttons.getChildCount()
m.screenSaverTimeout = 300 m.screenSaverTimeout = 300
@ -26,6 +25,8 @@ sub init()
loadButtons() loadButtons()
pageContentChanged() pageContentChanged()
setShuffleIconState()
setLoopButtonImage()
end sub end sub
sub onScreensaverTimeoutLoaded() sub onScreensaverTimeoutLoaded()
@ -111,9 +112,10 @@ sub setupInfoNodes()
m.playPosition = m.top.findNode("playPosition") m.playPosition = m.top.findNode("playPosition")
m.bufferPosition = m.top.findNode("bufferPosition") m.bufferPosition = m.top.findNode("bufferPosition")
m.seekBar = m.top.findNode("seekBar") m.seekBar = m.top.findNode("seekBar")
m.numberofsongsField = m.top.findNode("numberofsongs")
m.shuffleIndicator = m.top.findNode("shuffleIndicator") m.shuffleIndicator = m.top.findNode("shuffleIndicator")
m.loopIndicator = m.top.findNode("loopIndicator") m.loopIndicator = m.top.findNode("loopIndicator")
m.positionTimestamp = m.top.findNode("positionTimestamp")
m.totalLengthTimestamp = m.top.findNode("totalLengthTimestamp")
end sub end sub
sub bufferPositionChanged() sub bufferPositionChanged()
@ -156,6 +158,13 @@ sub audioPositionChanged()
m.playPositionAnimationWidth.keyValue = [m.playPosition.width, playPositionBarWidth] m.playPositionAnimationWidth.keyValue = [m.playPosition.width, playPositionBarWidth]
m.playPositionAnimation.control = "start" m.playPositionAnimation.control = "start"
' Update displayed position timestamp
if isValid(m.global.audioPlayer.position)
m.positionTimestamp.text = secondsToHuman(m.global.audioPlayer.position)
else
m.positionTimestamp.text = "0:00"
end if
' Only fall into screensaver logic if the user has screensaver enabled in Roku settings ' Only fall into screensaver logic if the user has screensaver enabled in Roku settings
if m.screenSaverTimeout > 0 if m.screenSaverTimeout > 0
if m.di.TimeSinceLastKeypress() >= m.screenSaverTimeout - 2 if m.di.TimeSinceLastKeypress() >= m.screenSaverTimeout - 2
@ -254,6 +263,11 @@ function previousClicked() as boolean
m.global.audioPlayer.control = "stop" m.global.audioPlayer.control = "stop"
end if end if
' Reset loop mode due to manual user interaction
if m.global.audioPlayer.loopMode = "one"
resetLoopModeToDefault()
end if
m.global.queueManager.callFunc("moveBack") m.global.queueManager.callFunc("moveBack")
pageContentChanged() pageContentChanged()
@ -261,6 +275,11 @@ function previousClicked() as boolean
return true return true
end function end function
sub resetLoopModeToDefault()
m.global.audioPlayer.loopMode = ""
setLoopButtonImage()
end sub
function loopClicked() as boolean function loopClicked() as boolean
if m.global.audioPlayer.loopMode = "" if m.global.audioPlayer.loopMode = ""
@ -290,6 +309,11 @@ end sub
function nextClicked() as boolean function nextClicked() as boolean
if m.playlistTypeCount > 1 then return false if m.playlistTypeCount > 1 then return false
' Reset loop mode due to manual user interaction
if m.global.audioPlayer.loopMode = "one"
resetLoopModeToDefault()
end if
if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1 if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1
LoadNextSong() LoadNextSong()
end if end if
@ -298,10 +322,12 @@ function nextClicked() as boolean
end function end function
sub toggleShuffleEnabled() sub toggleShuffleEnabled()
m.shuffleEnabled = not m.shuffleEnabled m.global.queueManager.callFunc("toggleShuffle")
end sub end sub
function findCurrentSongIndex(songList) as integer function findCurrentSongIndex(songList) as integer
if not isValidAndNotEmpty(songList) then return 0
for i = 0 to songList.count() - 1 for i = 0 to songList.count() - 1
if songList[i].id = m.global.queueManager.callFunc("getCurrentItem").id if songList[i].id = m.global.queueManager.callFunc("getCurrentItem").id
return i return i
@ -313,44 +339,36 @@ end function
function shuffleClicked() as boolean function shuffleClicked() as boolean
currentSongIndex = findCurrentSongIndex(m.global.queueManager.callFunc("getUnshuffledQueue"))
toggleShuffleEnabled() toggleShuffleEnabled()
if not m.shuffleEnabled if not m.global.queueManager.callFunc("getIsShuffled")
m.shuffleIndicator.opacity = ".4" m.shuffleIndicator.opacity = ".4"
m.shuffleIndicator.uri = m.shuffleIndicator.uri.Replace("-on", "-off") m.shuffleIndicator.uri = m.shuffleIndicator.uri.Replace("-on", "-off")
currentSongIndex = findCurrentSongIndex(m.originalSongList)
m.global.queueManager.callFunc("set", m.originalSongList)
m.global.queueManager.callFunc("setPosition", currentSongIndex) m.global.queueManager.callFunc("setPosition", currentSongIndex)
setFieldTextValue("numberofsongs", "Track " + stri(m.global.queueManager.callFunc("getPosition") + 1) + "/" + stri(m.global.queueManager.callFunc("getCount"))) setTrackNumberDisplay()
return true return true
end if end if
m.shuffleIndicator.opacity = "1" m.shuffleIndicator.opacity = "1"
m.shuffleIndicator.uri = m.shuffleIndicator.uri.Replace("-off", "-on") m.shuffleIndicator.uri = m.shuffleIndicator.uri.Replace("-off", "-on")
setTrackNumberDisplay()
m.originalSongList = m.global.queueManager.callFunc("getQueue")
songIDArray = m.global.queueManager.callFunc("getQueue")
' Move the currently playing song to the front of the queue
temp = m.global.queueManager.callFunc("top")
songIDArray[0] = m.global.queueManager.callFunc("getCurrentItem")
songIDArray[m.global.queueManager.callFunc("getPosition")] = temp
for i = 1 to songIDArray.count() - 1
j = Rnd(songIDArray.count() - 1)
temp = songIDArray[i]
songIDArray[i] = songIDArray[j]
songIDArray[j] = temp
end for
m.global.queueManager.callFunc("set", songIDArray)
return true return true
end function end function
sub setShuffleIconState()
if m.global.queueManager.callFunc("getIsShuffled")
m.shuffleIndicator.opacity = "1"
m.shuffleIndicator.uri = m.shuffleIndicator.uri.Replace("-off", "-on")
end if
end sub
sub setTrackNumberDisplay()
setFieldTextValue("numberofsongs", "Track " + stri(m.global.queueManager.callFunc("getPosition") + 1) + "/" + stri(m.global.queueManager.callFunc("getCount")))
end sub
sub LoadNextSong() sub LoadNextSong()
if m.global.audioPlayer.state = "playing" if m.global.audioPlayer.state = "playing"
m.global.audioPlayer.control = "stop" m.global.audioPlayer.control = "stop"
@ -400,6 +418,9 @@ sub pageContentChanged()
setScreenTitle(currentItem) setScreenTitle(currentItem)
setOnScreenTextValues(currentItem) setOnScreenTextValues(currentItem)
m.songDuration = currentItem.RunTimeTicks / 10000000.0 m.songDuration = currentItem.RunTimeTicks / 10000000.0
' Update displayed total audio length
m.totalLengthTimestamp.text = ticksToHuman(currentItem.RunTimeTicks)
end if end if
m.LoadAudioStreamTask.itemId = currentItem.id m.LoadAudioStreamTask.itemId = currentItem.id
@ -455,6 +476,9 @@ sub onMetaDataLoaded()
if isValid(data.json.RunTimeTicks) if isValid(data.json.RunTimeTicks)
m.songDuration = data.json.RunTimeTicks / 10000000.0 m.songDuration = data.json.RunTimeTicks / 10000000.0
' Update displayed total audio length
m.totalLengthTimestamp.text = ticksToHuman(data.json.RunTimeTicks)
end if end if
end if end if
end sub end sub
@ -492,14 +516,8 @@ end sub
' Populate on screen text variables ' Populate on screen text variables
sub setOnScreenTextValues(json) sub setOnScreenTextValues(json)
if isValid(json) if isValid(json)
currentSongIndex = m.global.queueManager.callFunc("getPosition")
if m.shuffleEnabled
currentSongIndex = findCurrentSongIndex(m.originalSongList)
end if
if m.playlistTypeCount = 1 if m.playlistTypeCount = 1
setFieldTextValue("numberofsongs", "Track " + stri(currentSongIndex + 1) + "/" + stri(m.global.queueManager.callFunc("getCount"))) setTrackNumberDisplay()
end if end if
setFieldTextValue("artist", json.Artists[0]) setFieldTextValue("artist", json.Artists[0])

View File

@ -4,6 +4,8 @@
<Poster id="backdrop" opacity=".5" loadDisplayMode="scaleToZoom" width="1920" height="1200" blendColor="#3f3f3f" /> <Poster id="backdrop" opacity=".5" loadDisplayMode="scaleToZoom" width="1920" height="1200" blendColor="#3f3f3f" />
<Poster id="shuffleIndicator" width="64" height="64" uri="pkg:/images/icons/shuffleIndicator-off.png" translation="[1150,775]" opacity="0" /> <Poster id="shuffleIndicator" width="64" height="64" uri="pkg:/images/icons/shuffleIndicator-off.png" translation="[1150,775]" opacity="0" />
<Poster id="loopIndicator" width="64" height="64" uri="pkg:/images/icons/loopIndicator-off.png" translation="[700,775]" opacity="0" /> <Poster id="loopIndicator" width="64" height="64" uri="pkg:/images/icons/loopIndicator-off.png" translation="[700,775]" opacity="0" />
<Label id="positionTimestamp" width="100" height="25" horizAlign="right" font="font:SmallestSystemFont" translation="[590,825]" color="#999999" text="0:00" />
<Label id="totalLengthTimestamp" width="100" height="25" horizAlign="left" font="font:SmallestSystemFont" translation="[1230,825]" color="#999999" />
<LayoutGroup id="toplevel" layoutDirection="vert" horizAlignment="center" translation="[960,175]" itemSpacings="[40]"> <LayoutGroup id="toplevel" layoutDirection="vert" horizAlignment="center" translation="[960,175]" itemSpacings="[40]">
<LayoutGroup id="main_group" layoutDirection="vert" horizAlignment="center" itemSpacings="[15]"> <LayoutGroup id="main_group" layoutDirection="vert" horizAlignment="center" itemSpacings="[15]">
<Poster id="albumCover" width="500" height="500" /> <Poster id="albumCover" width="500" height="500" />

View File

@ -1,16 +0,0 @@
sub init()
' backgroundUri must be set to an empty string before backgroundColor can be set
m.top.backgroundUri = ""
m.top.backgroundColor = "#000000"
m.PosterOne = m.top.findNode("PosterOne")
m.PosterOne.uri = "pkg:/images/logo.png"
m.BounceAnimation = m.top.findNode("BounceAnimation")
m.BounceAnimation.control = "start" 'Start BounceAnimation
if get_user_setting("ui.screensaver.splashBackground") = "true"
m.backdrop = m.top.findNode("backdrop")
m.backdrop.uri = buildURL("/Branding/Splashscreen?format=jpg&foregroundLayer=0.15&fillWidth=1280&width=1280&fillHeight=720&height=720&tag=splash")
end if
end sub

View File

@ -1,146 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component name="Screensaver" extends="Scene">
<script type="text/brightscript" uri = "Screensaver.brs"/>
<children>
<Poster id="backdrop" loadDisplayMode="scaleToZoom" width="1920" height="1200" />
<!-- Makes 2 Posters on top of each other -->
<Poster
id = "PosterOne"
width = "389"
height = "104"
opacity = "1"
translation = "[960,540]"
/>
<!--Gigantic loop for bouncing animation -->
<SequentialAnimation
id = "BounceAnimation"
repeat = "true"
>
<Animation
id = "AnimOne"
repeat = "false"
easeFunction = "linear"
duration = "7.2"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[960,540],[1500,250]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimTwo"
repeat = "false"
easeFunction = "linear"
duration = "4.5"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[1500,250],[1350,60]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimThree"
repeat = "false"
easeFunction = "linear"
duration = "16"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[1350,60],[200,890]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimFour"
repeat = "false"
easeFunction = "linear"
duration = "3.2"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[200,890],[30,750]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimFive"
repeat = "false"
easeFunction = "linear"
duration = "13.3"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[30,750],[1200,60]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimSix"
repeat = "false"
easeFunction = "linear"
duration = "6.7"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[1200,60],[1500,300]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimSeven"
repeat = "false"
easeFunction = "linear"
duration = "15"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[1500,300],[150,935]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimEight"
repeat = "false"
easeFunction = "linear"
duration = "2"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[150,935],[10,899.3]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
<Animation
id = "AnimNine"
repeat = "false"
easeFunction = "linear"
duration = "11"
>
<Vector2DFieldInterpolator
id = "OneInterp"
key = "[0.0,1.0]"
keyValue = "[[10,899.3],[960,540]]"
fieldToInterp = "PosterOne.translation"
/>
</Animation>
</SequentialAnimation>
</children>
<script type="text/brightscript" uri="pkg:/source/api/baserequest.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/config.brs" />
</component>

View File

@ -23,7 +23,6 @@
<field id="videoId" type="string" /> <field id="videoId" type="string" />
<field id="mediaSourceId" type="string" /> <field id="mediaSourceId" type="string" />
<field id="audioIndex" type="integer" /> <field id="audioIndex" type="integer" />
<field id="runTime" type="integer" />
</interface> </interface>
<script type="text/brightscript" uri="VideoPlayerView.brs" /> <script type="text/brightscript" uri="VideoPlayerView.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/misc.brs" /> <script type="text/brightscript" uri="pkg:/source/utils/misc.brs" />

View File

@ -3,6 +3,7 @@ VSCode
BrightScript BrightScript
sideload sideload
Sideload Sideload
Reddit
DEVGUIDE DEVGUIDE
ing ing
hardcode hardcode

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -675,31 +675,13 @@
<extracomment>Settings Menu - Title for option</extracomment> <extracomment>Settings Menu - Title for option</extracomment>
</message> </message>
<message> <message>
<source>If enabled, selecting a TV series with only one season will go straight to the episode list rather than the show details and season list.</source> <source>Go directly to the episode list if a TV series has only one season.</source>
<translation>If enabled, selecting a TV series with only one season will go straight to the episode list rather than the show details and season list.</translation> <translation>Go directly to the episode list if a TV series has only one season.</translation>
<extracomment>Settings Menu - Description for option</extracomment> <extracomment>Settings Menu - Description for option</extracomment>
</message> </message>
<message> <message>
<source>If enabled, images of unwatched episodes will be blurred.</source> <source>Blur images of unwatched episodes.</source>
<translation>If enabled, images of unwatched episodes will be blurred.</translation> <translation>Blur images of unwatched episodes.</translation>
</message>
<message>
<source>Screensaver</source>
<translation>Screensaver</translation>
</message>
<message>
<source>Options for Jellyfin&apos;s screensaver.</source>
<translation>Options for Jellyfin&apos;s screensaver.</translation>
<extracomment>Description for Screensaver user settings.</extracomment>
</message>
<message>
<source>Use Splashscreen as Screensaver</source>
<translation>Use Splashscreen as Screensaver</translation>
<extracomment>Option Title in user setting screen</extracomment>
</message>
<message>
<source>Use generated splashscreen image as Jellyfin&apos;s screensaver background. Jellyfin will need to be closed and reopened for change to take effect.</source>
<translation>Use generated splashscreen image as Jellyfin&apos;s screensaver background. Jellyfin will need to be closed and reopened for change to take effect.</translation>
</message> </message>
<message> <message>
<source>Design Elements</source> <source>Design Elements</source>
@ -726,8 +708,8 @@
<extracomment>Settings Menu - Title for option</extracomment> <extracomment>Settings Menu - Title for option</extracomment>
</message> </message>
<message> <message>
<source>Cinema Mode brings the theater experience straight to your living room with the ability to play custom intros before the main feature.</source> <source>Bring the theater experience straight to your living room with the ability to play custom intros before the main feature.</source>
<translation>Cinema Mode brings the theater experience straight to your living room with the ability to play custom intros before the main feature.</translation> <translation>Bring the theater experience straight to your living room with the ability to play custom intros before the main feature.</translation>
<extracomment>Settings Menu - Description for option</extracomment> <extracomment>Settings Menu - Description for option</extracomment>
</message> </message>
<message> <message>
@ -736,8 +718,8 @@
<extracomment>Option Title in user setting screen</extracomment> <extracomment>Option Title in user setting screen</extracomment>
</message> </message>
<message> <message>
<source>Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.</source> <source>Hide all clocks in Jellyfin. Jellyfin will need to be closed and reopened for changes to take effect.</source>
<translation>Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.</translation> <translation>Hide all clocks in Jellyfin. Jellyfin will need to be closed and reopened for changes to take effect.</translation>
<extracomment>Settings Menu - Description for option</extracomment> <extracomment>Settings Menu - Description for option</extracomment>
</message> </message>
<message> <message>
@ -1025,36 +1007,36 @@
<translation>Disable Community Rating for Episodes</translation> <translation>Disable Community Rating for Episodes</translation>
</message> </message>
<message> <message>
<source>If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode.</source> <source>Hide the star and community rating for episodes of a TV show. This is to prevent spoilers of an upcoming good/bad episode.</source>
<translation>If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode.</translation> <translation>Hide the star and community rating for episodes of a TV show. This is to prevent spoilers of an upcoming good/bad episode.</translation>
</message>
<message>
<source>Configure the maximum playback bitrate.</source>
<translation>Configure the maximum playback bitrate.</translation>
</message> </message>
<message> <message>
<source>Biographical information for this person is not currently available.</source> <source>Biographical information for this person is not currently available.</source>
<translation>Biographical information for this person is not currently available.</translation> <translation>Biographical information for this person is not currently available.</translation>
</message> </message>
<message> <message>
<source>Playback Bitrate Limits</source> <source>Enable Limit</source>
<translation>Playback Bitrate Limits</translation> <translation>Enable Limit</translation>
</message> </message>
<message> <message>
<source>Set limits for how high playback bitrates are allowed to be.</source> <source>Enable or disable the 'Maximum Bitrate' setting.</source>
<translation>Set limits for how high playback bitrates are allowed to be.</translation> <translation>Enable or disable the 'Maximum Bitrate' setting.</translation>
</message> </message>
<message> <message>
<source>Limits Enabled</source> <source>Bitrate Limit</source>
<translation>Limits Enabled</translation> <translation>Bitrate Limit</translation>
</message> </message>
<message> <message>
<source>If enabled, playback bitrates will be limited based on the 'Playback Bitrate Limit' setting.</source> <source>Maximum Bitrate</source>
<translation>If enabled, playback bitrates will be limited based on the 'Playback Bitrate Limit' setting.</translation> <translation>Maximum Bitrate</translation>
</message> </message>
<message> <message>
<source>Playback Bitrate Limit</source> <source>Set the maximum bitrate in Mbps. Set to 0 to use Roku's specifications. This setting must be enabled to take effect.</source>
<translation>Playback Bitrate Limit</translation> <translation>Set the maximum bitrate in Mbps. Set to 0 to use Roku's specifications. This setting must be enabled to take effect.</translation>
</message>
<message>
<source>Max bitrate (Mbps) allowed if limits are enabled. Set to 0 to use Roku's specifications.</source>
<translation>Max bitrate (Mbps) allowed if limits are enabled. Set to 0 to use Roku's specifications.</translation>
</message> </message>
<message> <message>
<source>Libraries</source> <source>Libraries</source>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
title=Jellyfin title=Jellyfin
major_version=1 major_version=1
minor_version=6 minor_version=6
build_version=4 build_version=5
### Main Menu Icons / Channel Poster Artwork ### Main Menu Icons / Channel Poster Artwork
@ -21,7 +21,6 @@ splash_min_time=1500
ui_resolutions=fhd ui_resolutions=fhd
screensaver_private=1 confirm_partner_button=1
screensaver_title=Jellyfin
supports_input_launch=1 supports_input_launch=1

5969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,43 @@
{ {
"name": "jellyfin-roku", "name": "jellyfin-roku",
"version": "1.6.4", "version": "1.6.5",
"description": "Roku app for Jellyfin media server", "description": "Roku app for Jellyfin media server",
"main": "index.js", "dependencies": {
"api": "npm:jellyfin-api-bs-client@1.0.5",
"bgv": "npm:button-group-vert@1.0.2",
"brighterscript-formatter": "1.6.24",
"intKeyboard": "npm:integer-keyboard@1.0.12",
"sob": "npm:slide-out-button@1.0.1"
},
"devDependencies": { "devDependencies": {
"@rokucommunity/bslint": "0.8.2", "@rokucommunity/bslint": "0.8.3",
"brighterscript": "0.62.0", "brighterscript": "0.64.2",
"ropm": "0.10.12", "bslib": "npm:@rokucommunity/bslib@0.1.1",
"jshint": "^2.13.6", "jshint": "2.13.6",
"markdownlint-cli2": "0.6.0", "markdownlint-cli2": "0.7.0",
"spellchecker-cli": "6.1.1" "rimraf": "4.4.1",
"roku-deploy": "3.10.0",
"roku-log-bsc-plugin": "0.7.0",
"rooibos-roku": "5.4.2",
"ropm": "0.10.13",
"spellchecker-cli": "6.1.1",
"undent": "0.1.0"
}, },
"scripts": { "scripts": {
"postinstall": "npx ropm copy", "build-tests": "npx rimraf build/ && npx bsc --project bsconfig-tests.json",
"validate": "npx bsc --copy-to-staging=false --create-package=false", "build-tdd": "npx rimraf build/ && npx bsc --project bsconfig-tdd.json",
"test": "echo \"Error: no test specified\" && exit 1", "check-formatting": "npx bsfmt --check",
"format": "npx bsfmt --write",
"lint": "bslint", "lint": "bslint",
"lint-json": "jshint --extra-ext .json --verbose --exclude node_modules ./", "lint-json": "jshint --extra-ext .json --verbose --exclude node_modules ./",
"lint-markdown": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"", "lint-markdown": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
"lint-spelling": "spellchecker -d dictionary.txt --files \"**/*.md\" \"**/.*/**/*.md\" \"!node_modules/**/*.md\"", "lint-spelling": "spellchecker -d dictionary.txt --files \"**/*.md\" \"**/.*/**/*.md\" \"!node_modules/**/*.md\"",
"check-formatting": "npx bsfmt --check", "postinstall": "npx ropm copy",
"format": "npx bsfmt --write" "validate": "npx bsc --copy-to-staging=false --create-package=false"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/jellyfin/jellyfin-roku.git" "url": "https://github.com/jellyfin/jellyfin-roku.git"
}, },
"keywords": [ "keywords": [
"jellyfin", "jellyfin",
@ -35,12 +48,5 @@
"bugs": { "bugs": {
"url": "https://github.com/jellyfin/jellyfin-roku/issues" "url": "https://github.com/jellyfin/jellyfin-roku/issues"
}, },
"homepage": "https://github.com/jellyfin/jellyfin-roku#readme", "homepage": "https://github.com/jellyfin/jellyfin-roku#readme"
"dependencies": {
"api": "npm:jellyfin-api-bs-client@^1.0.5",
"bgv": "npm:button-group-vert@^1.0.2",
"brighterscript-formatter": "^1.6.8",
"sob": "npm:slide-out-button@^1.0.1",
"intKeyboard": "npm:integer-keyboard@^1.0.12"
}
} }

View File

@ -3,9 +3,29 @@
"title": "Playback", "title": "Playback",
"description": "Settings relating to playback and supported codec and media types.", "description": "Settings relating to playback and supported codec and media types.",
"children": [ "children": [
{
"title": "Bitrate Limit",
"description": "Configure the maximum playback bitrate.",
"children": [
{
"title": "Enable Limit",
"description": "Enable or disable the 'Maximum Bitrate' setting.",
"settingName": "playback.bitrate.maxlimited",
"type": "bool",
"default": "true"
},
{
"title": "Maximum Bitrate",
"description": "Set the maximum bitrate in Mbps. Set to 0 to use Roku's specifications. This setting must be enabled to take effect.",
"settingName": "playback.bitrate.limit",
"type": "integer",
"default": "0"
}
]
},
{ {
"title": "Codec Support", "title": "Codec Support",
"description": "Enable or disable Direct Play support for certain codecs", "description": "Enable or disable Direct Play support for certain codecs.",
"children": [ "children": [
{ {
"title": "AV1", "title": "AV1",
@ -30,26 +50,6 @@
} }
] ]
}, },
{
"title": "Playback Bitrate Limits",
"description": "Set limits for how high playback bitrates are allowed to be.",
"children": [
{
"title": "Limits Enabled",
"description": "If enabled, playback bitrates will be limited based on the 'Playback Bitrate Limit' setting.",
"settingName": "playback.bitrate.maxlimited",
"type": "bool",
"default": "true"
},
{
"title": "Playback Bitrate Limit",
"description": "Max bitrate (Mbps) allowed if limits are enabled. Set to 0 to use Roku's specifications.",
"settingName": "playback.bitrate.limit",
"type": "integer",
"default": "0"
}
]
},
{ {
"title": "Profile Level Support", "title": "Profile Level Support",
"description": "Attempt Direct Play of potentially unsupported profile levels", "description": "Attempt Direct Play of potentially unsupported profile levels",
@ -72,7 +72,7 @@
}, },
{ {
"title": "Cinema Mode", "title": "Cinema Mode",
"description": "Cinema Mode brings the theater experience straight to your living room with the ability to play custom intros before the main feature.", "description": "Bring the theater experience straight to your living room with the ability to play custom intros before the main feature.",
"settingName": "playback.cinemamode", "settingName": "playback.cinemamode",
"type": "bool", "type": "bool",
"default": "false" "default": "false"
@ -110,7 +110,7 @@
"children": [ "children": [
{ {
"title": "Hide Clock", "title": "Hide Clock",
"description": "Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.", "description": "Hide all clocks in Jellyfin. Jellyfin will need to be closed and reopened for changes to take effect.",
"settingName": "ui.design.hideclock", "settingName": "ui.design.hideclock",
"type": "bool", "type": "bool",
"default": "false" "default": "false"
@ -145,19 +145,6 @@
} }
] ]
}, },
{
"title": "Screensaver",
"description": "Options for Jellyfin's screensaver.",
"children": [
{
"title": "Use Splashscreen as Screensaver Background",
"description": "Use generated splashscreen image as Jellyfin's screensaver background. Jellyfin will need to be closed and reopened for change to take effect.",
"settingName": "ui.screensaver.splashBackground",
"type": "bool",
"default": "false"
}
]
},
{ {
"title": "Libraries", "title": "Libraries",
"description": "Settings relating to the appearance of Library pages.", "description": "Settings relating to the appearance of Library pages.",
@ -245,14 +232,14 @@
"children": [ "children": [
{ {
"title": "Blur Unwatched Episodes", "title": "Blur Unwatched Episodes",
"description": "If enabled, images of unwatched episodes will be blurred.", "description": "Blur images of unwatched episodes.",
"settingName": "ui.tvshows.blurunwatched", "settingName": "ui.tvshows.blurunwatched",
"type": "bool", "type": "bool",
"default": "false" "default": "false"
}, },
{ {
"title": "Disable Community Rating for Episodes", "title": "Disable Community Rating for Episodes",
"description": "If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode.", "description": "Hide the star and community rating for episodes of a TV show. This is to prevent spoilers of an upcoming good/bad episode.",
"settingName": "ui.tvshows.disableCommunityRating", "settingName": "ui.tvshows.disableCommunityRating",
"type": "bool", "type": "bool",
"default": "false" "default": "false"

View File

@ -1,26 +1,6 @@
sub Main (args as dynamic) as void sub Main (args as dynamic) as void
appInfo = CreateObject("roAppInfo")
if appInfo.IsDev() and args.RunTests = "true" and TF_Utils__IsFunction(TestRunner)
' POST to {ROKU ADDRESS}:8060/launch/dev?RunTests=true
Runner = TestRunner()
Runner.SetFunctions([
TestSuite__Misc
])
Runner.Logger.SetVerbosity(1)
Runner.Logger.SetEcho(false)
Runner.Logger.SetJUnit(false)
Runner.SetFailFast(true)
Runner.Run()
end if
' The main function that runs when the application is launched. ' The main function that runs when the application is launched.
m.screen = CreateObject("roSGScreen") m.screen = CreateObject("roSGScreen")
' Set global constants ' Set global constants
setConstants() setConstants()
' Write screen tracker for screensaver ' Write screen tracker for screensaver
@ -30,7 +10,7 @@ sub Main (args as dynamic) as void
m.port = CreateObject("roMessagePort") m.port = CreateObject("roMessagePort")
m.screen.setMessagePort(m.port) m.screen.setMessagePort(m.port)
m.scene = m.screen.CreateScene("JFScene") m.scene = m.screen.CreateScene("JFScene")
m.screen.show() m.screen.show() ' vscode_rale_tracker_entry
' Set any initial Global Variables ' Set any initial Global Variables
m.global = m.screen.getGlobalNode() m.global = m.screen.getGlobalNode()
@ -96,13 +76,19 @@ sub Main (args as dynamic) as void
m.scene.observeField("exit", m.port) m.scene.observeField("exit", m.port)
' Downloads and stores a fallback font to tmp:/ ' Downloads and stores a fallback font to tmp:/
if parseJSON(APIRequest("/System/Configuration/encoding").GetToString())["EnableFallbackFont"] = true configEncoding = api_API().system.getconfigurationbyname("encoding")
re = CreateObject("roRegex", "Name.:.(.*?).,.Size", "s")
filename = APIRequest("FallbackFont/Fonts").GetToString() if isValid(configEncoding) and isValid(configEncoding.EnableFallbackFont)
filename = re.match(filename) if configEncoding.EnableFallbackFont
if filename.count() > 0 re = CreateObject("roRegex", "Name.:.(.*?).,.Size", "s")
filename = filename[1] filename = APIRequest("FallbackFont/Fonts").GetToString()
APIRequest("FallbackFont/Fonts/" + filename).gettofile("tmp:/font") if isValid(filename)
filename = re.match(filename)
if isValid(filename) and filename.count() > 0
filename = filename[1]
APIRequest("FallbackFont/Fonts/" + filename).gettofile("tmp:/font")
end if
end if
end if end if
end if end if
@ -182,8 +168,6 @@ sub Main (args as dynamic) as void
group.lastFocus.setFocus(true) group.lastFocus.setFocus(true)
end if end if
end if end if
reportingNode.quickPlayNode.type = ""
end if end if
else if isNodeEvent(msg, "selectedItem") else if isNodeEvent(msg, "selectedItem")
' If you select a library from ANYWHERE, follow this flow ' If you select a library from ANYWHERE, follow this flow
@ -191,7 +175,7 @@ sub Main (args as dynamic) as void
m.selectedItemType = selectedItem.type m.selectedItemType = selectedItem.type
if selectedItem.type = "CollectionFolder" if selectedItem.type = "CollectionFolder" or selectedItem.type = "BoxSet"
if selectedItem.collectionType = "movies" if selectedItem.collectionType = "movies"
group = CreateMovieLibraryView(selectedItem) group = CreateMovieLibraryView(selectedItem)
else if selectedItem.collectionType = "music" else if selectedItem.collectionType = "music"
@ -276,6 +260,7 @@ sub Main (args as dynamic) as void
group = CreatePlaylistView(selectedItem.json) group = CreatePlaylistView(selectedItem.json)
else if selectedItem.type = "Audio" else if selectedItem.type = "Audio"
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("push", selectedItem.json) m.global.queueManager.callFunc("push", selectedItem.json)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else else
@ -317,6 +302,7 @@ sub Main (args as dynamic) as void
screenContent = msg.getRoSGNode() screenContent = msg.getRoSGNode()
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex]) m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex])
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else if isNodeEvent(msg, "playItem") else if isNodeEvent(msg, "playItem")
@ -325,6 +311,7 @@ sub Main (args as dynamic) as void
screenContent = msg.getRoSGNode() screenContent = msg.getRoSGNode()
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex]) m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex])
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else if isNodeEvent(msg, "playAllSelected") else if isNodeEvent(msg, "playAllSelected")
@ -334,6 +321,7 @@ sub Main (args as dynamic) as void
m.spinner.visible = true m.spinner.visible = true
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", screenContent.albumData.items) m.global.queueManager.callFunc("set", screenContent.albumData.items)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else if isNodeEvent(msg, "playArtistSelected") else if isNodeEvent(msg, "playArtistSelected")
@ -341,6 +329,7 @@ sub Main (args as dynamic) as void
screenContent = msg.getRoSGNode() screenContent = msg.getRoSGNode()
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", CreateArtistMix(screenContent.pageContent.id).Items) m.global.queueManager.callFunc("set", CreateArtistMix(screenContent.pageContent.id).Items)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
@ -360,6 +349,7 @@ sub Main (args as dynamic) as void
if isValid(screenContent.albumData.items) if isValid(screenContent.albumData.items)
if screenContent.albumData.items.count() > 0 if screenContent.albumData.items.count() > 0
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", CreateInstantMix(screenContent.albumData.items[0].id).Items) m.global.queueManager.callFunc("set", CreateInstantMix(screenContent.albumData.items[0].id).Items)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
@ -371,6 +361,7 @@ sub Main (args as dynamic) as void
if not viewHandled if not viewHandled
' Create instant mix based on selected artist ' Create instant mix based on selected artist
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", CreateInstantMix(screenContent.pageContent.id).Items) m.global.queueManager.callFunc("set", CreateInstantMix(screenContent.pageContent.id).Items)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
end if end if
@ -418,6 +409,7 @@ sub Main (args as dynamic) as void
group = CreateAlbumView(node.json) group = CreateAlbumView(node.json)
else if node.type = "Audio" else if node.type = "Audio"
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("push", node.json) m.global.queueManager.callFunc("push", node.json)
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else if node.type = "Person" else if node.type = "Person"
@ -432,6 +424,7 @@ sub Main (args as dynamic) as void
selectedIndex = msg.getData() selectedIndex = msg.getData()
screenContent = msg.getRoSGNode() screenContent = msg.getRoSGNode()
m.global.queueManager.callFunc("clear") m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("push", screenContent.albumData.items[node.id]) m.global.queueManager.callFunc("push", screenContent.albumData.items[node.id])
m.global.queueManager.callFunc("playQueue") m.global.queueManager.callFunc("playQueue")
else else
@ -651,174 +644,3 @@ sub Main (args as dynamic) as void
end while end while
end sub end sub
function LoginFlow(startOver = false as boolean)
'Collect Jellyfin server and user information
start_login:
if get_setting("server") = invalid then startOver = true
invalidServer = true
if not startOver
' Show Connecting to Server spinner
dialog = createObject("roSGNode", "ProgressDialog")
dialog.title = tr("Connecting to Server")
m.scene.dialog = dialog
invalidServer = ServerInfo().Error
dialog.close = true
end if
m.serverSelection = "Saved"
if startOver or invalidServer
print "Get server details"
SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting
m.serverSelection = CreateServerGroup()
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
if m.serverSelection = "backPressed"
print "backPressed"
m.global.sceneManager.callFunc("clearScenes")
return false
end if
SaveServerList()
end if
if get_setting("active_user") = invalid
SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting
publicUsers = GetPublicUsers()
if publicUsers.count()
publicUsersNodes = []
for each item in publicUsers
user = CreateObject("roSGNode", "PublicUserData")
user.id = item.Id
user.name = item.Name
if item.PrimaryImageTag <> invalid
user.ImageURL = UserImageURL(user.id, { "tag": item.PrimaryImageTag })
end if
publicUsersNodes.push(user)
end for
userSelected = CreateUserSelectGroup(publicUsersNodes)
if userSelected = "backPressed"
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
return LoginFlow(true)
else
'Try to login without password. If the token is valid, we're done
get_token(userSelected, "")
if get_setting("active_user") <> invalid
m.user = AboutMe()
LoadUserPreferences()
LoadUserAbilities(m.user)
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
return true
end if
end if
else
userSelected = ""
end if
passwordEntry = CreateSigninGroup(userSelected)
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
if passwordEntry = "backPressed"
m.global.sceneManager.callFunc("clearScenes")
return LoginFlow(true)
end if
end if
m.user = AboutMe()
if m.user = invalid or m.user.id <> get_setting("active_user")
print "Login failed, restart flow"
unset_setting("active_user")
goto start_login
end if
LoadUserPreferences()
LoadUserAbilities(m.user)
m.global.sceneManager.callFunc("clearScenes")
'Send Device Profile information to server
body = getDeviceCapabilities()
req = APIRequest("/Sessions/Capabilities/Full")
req.SetRequest("POST")
postJson(req, FormatJson(body))
return true
end function
sub SaveServerList()
'Save off this server to our list of saved servers for easier navigation between servers
server = get_setting("server")
saved = get_setting("saved_servers")
if server <> invalid
server = LCase(server)'Saved server data is always lowercase
end if
entryCount = 0
addNewEntry = true
savedServers = { serverList: [] }
if saved <> invalid
savedServers = ParseJson(saved)
entryCount = savedServers.serverList.Count()
if savedServers.serverList <> invalid and entryCount > 0
for each item in savedServers.serverList
if item.baseUrl = server
addNewEntry = false
exit for
end if
end for
end if
end if
if addNewEntry
if entryCount = 0
set_setting("saved_servers", FormatJson({ serverList: [{ name: m.serverSelection, baseUrl: server, iconUrl: "pkg:/images/logo-icon120.jpg", iconWidth: 120, iconHeight: 120 }] }))
else
savedServers.serverList.Push({ name: m.serverSelection, baseUrl: server, iconUrl: "pkg:/images/logo-icon120.jpg", iconWidth: 120, iconHeight: 120 })
set_setting("saved_servers", FormatJson(savedServers))
end if
end if
end sub
sub DeleteFromServerList(urlToDelete)
saved = get_setting("saved_servers")
if urlToDelete <> invalid
urlToDelete = LCase(urlToDelete)
end if
if saved <> invalid
savedServers = ParseJson(saved)
newServers = { serverList: [] }
for each item in savedServers.serverList
if item.baseUrl <> urlToDelete
newServers.serverList.Push(item)
end if
end for
set_setting("saved_servers", FormatJson(newServers))
end if
end sub
sub RunScreenSaver()
print "Starting screensaver..."
scene = ReadAsciiFile("tmp:/scene")
if scene = "nowplaying" then return
screen = createObject("roSGScreen")
m.port = createObject("roMessagePort")
screen.setMessagePort(m.port)
screen.createScene("Screensaver")
screen.Show()
while true
msg = wait(8000, m.port)
if msg <> invalid
msgType = type(msg)
if msgType = "roSGScreenEvent"
if msg.isScreenClosed() then return
end if
end if
end while
end sub
' Roku Performance monitoring
sub SendPerformanceBeacon(signalName as string)
if m.global.app_loaded = false
m.scene.signalBeacon(signalName)
end if
end sub

View File

@ -1,3 +1,149 @@
function LoginFlow(startOver = false as boolean)
'Collect Jellyfin server and user information
start_login:
if get_setting("server") = invalid then startOver = true
invalidServer = true
if not startOver
' Show Connecting to Server spinner
dialog = createObject("roSGNode", "ProgressDialog")
dialog.title = tr("Connecting to Server")
m.scene.dialog = dialog
invalidServer = ServerInfo().Error
dialog.close = true
end if
m.serverSelection = "Saved"
if startOver or invalidServer
print "Get server details"
SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting
m.serverSelection = CreateServerGroup()
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
if m.serverSelection = "backPressed"
print "backPressed"
m.global.sceneManager.callFunc("clearScenes")
return false
end if
SaveServerList()
end if
if get_setting("active_user") = invalid
SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting
publicUsers = GetPublicUsers()
if publicUsers.count()
publicUsersNodes = []
for each item in publicUsers
user = CreateObject("roSGNode", "PublicUserData")
user.id = item.Id
user.name = item.Name
if item.PrimaryImageTag <> invalid
user.ImageURL = UserImageURL(user.id, { "tag": item.PrimaryImageTag })
end if
publicUsersNodes.push(user)
end for
userSelected = CreateUserSelectGroup(publicUsersNodes)
if userSelected = "backPressed"
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
return LoginFlow(true)
else
'Try to login without password. If the token is valid, we're done
get_token(userSelected, "")
if get_setting("active_user") <> invalid
m.user = AboutMe()
LoadUserPreferences()
LoadUserAbilities(m.user)
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
return true
end if
end if
else
userSelected = ""
end if
passwordEntry = CreateSigninGroup(userSelected)
SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
if passwordEntry = "backPressed"
m.global.sceneManager.callFunc("clearScenes")
return LoginFlow(true)
end if
end if
m.user = AboutMe()
if m.user = invalid or m.user.id <> get_setting("active_user")
print "Login failed, restart flow"
unset_setting("active_user")
goto start_login
end if
LoadUserPreferences()
LoadUserAbilities(m.user)
m.global.sceneManager.callFunc("clearScenes")
'Send Device Profile information to server
body = getDeviceCapabilities()
req = APIRequest("/Sessions/Capabilities/Full")
req.SetRequest("POST")
postJson(req, FormatJson(body))
return true
end function
sub SaveServerList()
'Save off this server to our list of saved servers for easier navigation between servers
server = get_setting("server")
saved = get_setting("saved_servers")
if server <> invalid
server = LCase(server)'Saved server data is always lowercase
end if
entryCount = 0
addNewEntry = true
savedServers = { serverList: [] }
if saved <> invalid
savedServers = ParseJson(saved)
entryCount = savedServers.serverList.Count()
if savedServers.serverList <> invalid and entryCount > 0
for each item in savedServers.serverList
if item.baseUrl = server
addNewEntry = false
exit for
end if
end for
end if
end if
if addNewEntry
if entryCount = 0
set_setting("saved_servers", FormatJson({ serverList: [{ name: m.serverSelection, baseUrl: server, iconUrl: "pkg:/images/logo-icon120.jpg", iconWidth: 120, iconHeight: 120 }] }))
else
savedServers.serverList.Push({ name: m.serverSelection, baseUrl: server, iconUrl: "pkg:/images/logo-icon120.jpg", iconWidth: 120, iconHeight: 120 })
set_setting("saved_servers", FormatJson(savedServers))
end if
end if
end sub
sub DeleteFromServerList(urlToDelete)
saved = get_setting("saved_servers")
if urlToDelete <> invalid
urlToDelete = LCase(urlToDelete)
end if
if saved <> invalid
savedServers = ParseJson(saved)
newServers = { serverList: [] }
for each item in savedServers.serverList
if item.baseUrl <> urlToDelete
newServers.serverList.Push(item)
end if
end for
set_setting("saved_servers", FormatJson(newServers))
end if
end sub
' Roku Performance monitoring
sub SendPerformanceBeacon(signalName as string)
if m.global.app_loaded = false
m.scene.signalBeacon(signalName)
end if
end sub
function CreateServerGroup() function CreateServerGroup()
screen = CreateObject("roSGNode", "SetServerScreen") screen = CreateObject("roSGNode", "SetServerScreen")
screen.optionsAvailable = true screen.optionsAvailable = true
@ -542,6 +688,9 @@ function CreateVideoPlayerGroup(video_id, mediaSourceId = invalid, audio_stream_
video = VideoPlayer(video_id, mediaSourceId, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), forceTranscoding, showIntro, allowResumeDialog) video = VideoPlayer(video_id, mediaSourceId, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), forceTranscoding, showIntro, allowResumeDialog)
if video = invalid then return invalid if video = invalid then return invalid
video.allowCaptions = true
if video.errorMsg = "introaborted" then return video if video.errorMsg = "introaborted" then return video
video.observeField("selectSubtitlePressed", m.port) video.observeField("selectSubtitlePressed", m.port)
video.observeField("selectPlaybackInfoPressed", m.port) video.observeField("selectPlaybackInfoPressed", m.port)

View File

@ -47,9 +47,6 @@ sub AddVideoContent(video, mediaSourceId, audio_stream_idx = 1, subtitle_idx = -
end if end if
if m.videotype = "Episode" or m.videotype = "Series" if m.videotype = "Episode" or m.videotype = "Series"
if isValid(meta.json) and isValid(meta.json.RunTimeTicks)
video.runTime = (meta.json.RunTimeTicks / 10000000.0)
end if
video.content.contenttype = "episode" video.content.contenttype = "episode"
end if end if
@ -332,6 +329,9 @@ function PlayIntroVideo(video_id, audio_stream_idx) as boolean
if lcase(introVideos.items[0].name) = "rick roll'd" then return true if lcase(introVideos.items[0].name) = "rick roll'd" then return true
introVideo = VideoPlayer(introVideos.items[0].id, introVideos.items[0].id, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), false, false) introVideo = VideoPlayer(introVideos.items[0].id, introVideos.items[0].id, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), false, false)
if isValid(introVideo)
introVideo.allowCaptions = false
end if
port = CreateObject("roMessagePort") port = CreateObject("roMessagePort")
introVideo.observeField("state", port) introVideo.observeField("state", port)

View File

@ -1,146 +1,50 @@
[ [
{ {
"description": "Feature: Phase 1 CJK subtitle support - external files only", "description": "Bug Fix: Rows on home view not refreshing when data has changed",
"author": "jkim2492" "author": "1hitsong"
}, },
{ {
"description": "Bug Fix: Fix multiple client crashes identified by crashlogs", "description": "Bug Fix: Next up section does not populate correctly after watching a TV episode",
"author": "1hitsong"
},
{
"description": "Bug Fix: Next episode button does not display consistently",
"author": "1hitsong"
},
{
"description": "Bug Fix: Crash when fallback font API call fails",
"author": "1hitsong"
},
{
"description": "Core: Fix Jellyfin links in readme",
"author": "cewert" "author": "cewert"
}, },
{ {
"description": "Feature: Add TV series & season shuffle", "description": "Bug Fix: Music shuffle not working",
"author": "1hitsong" "author": "1hitsong"
}, },
{ {
"description": "Updated View: Show \"Actor\" when an actor has no role", "description": "Updated View: Add current playback time and song length to the sides of the audio progress bar",
"author": "1hitsong"
},
{
"description": "Bug Fix: Turn off loop mode when user manually changes song",
"author": "1hitsong"
},
{
"description": "Core: Enable confirm partner button and setup RALE",
"author": "cewert" "author": "cewert"
}, },
{ {
"description": "Feature: Phase 1 playlist support", "description": "Bug Fix: Clock not displaying on login view",
"author": "1hitsong"
},
{
"description": "Fix Typo: trancoding -> transcoding",
"author": "RussianCow"
},
{
"description": "Feature: Create global audio player",
"author": "1hitsong"
},
{
"description": "Core: Add user policy to check if canDelete",
"author": "candry7731"
},
{
"description": "Bug Fix: Only show next episode button if \"Play next episode automatically\" setting is enabled in web client",
"author": "cewert" "author": "cewert"
}, },
{ {
"description": "New Setting: Disable unwatched episode count", "description": "Core: Add build badge and rework readme content",
"author": "1hitsong" "author": "cewert"
}, },
{ {
"description": "New Setting: Next episode button time", "description": "Core: Adjust settings to follow latest guidelines",
"author": "candry7731"
},
{
"description": "New View: New persondetails view",
"author": "sevenrats" "author": "sevenrats"
},
{
"description": "Updated View: Make title scrolling consistent in extras slider",
"author": "sevenrats"
},
{
"description": "Feature: Make pressing the options button close settings menu",
"author": "sevenrats"
},
{
"description": "Bug Fix: Graceful episode playback failure",
"author": "sevenrats"
},
{
"description": "Bug Fix: Fix default view setting for movie genres",
"author": "1hitsong"
},
{
"description": "Core: Update CI ubuntu version and node version",
"author": "sevenrats"
},
{
"description": "New Setting: Custom max video bitrate",
"author": "jimdogx"
},
{
"description": "Updated View: Updated \"OnNow\" home row to default to channel images if program images are not availible",
"author": "candry7731"
},
{
"description": "Updated View: Improve settings menu, implement title hover and hide in missing locations",
"author": "sevenrats"
},
{
"description": "Updated View: Make home view load faster",
"author": "1hitsong"
},
{
"description": "Bug Fix: Fix option menu focus if opened while library still loading",
"author": "ApexArray"
},
{
"description": "Updated View: Add Genres, Parental Ratings, and Years as movie filters",
"author": "1hitsong"
},
{
"description": "Bug Fix: Revert change that removed image cache busting",
"author": "1hitsong"
},
{
"description": "Updated View: Fix distorted TV episode posters. Add client-side progress bar and played indicator.",
"author": "ApexArray"
},
{
"description": "Updated View: Improve quality of album art on now playing view",
"author": "1hitsong"
},
{
"description": "Updated View: Loading spinner, Progress Dialog and movie details button animate",
"author": "candry7731"
},
{
"description": "Core: Add settings guidelines to Dev Guide",
"author": "sevenrats"
},
{
"description": "Core: Add workflow to validate language translation files",
"author": "cewert"
},
{
"description": "Core: Remove optional chaining operators from code",
"author": "cewert"
},
{
"description": "Core: Make CI throw error for duplicate translation entries",
"author": "cewert"
},
{
"description": "Core: Fix en_US translation file",
"author": "cewert"
},
{
"description": "Core: Add a production build workflow",
"author": "cewert"
},
{
"description": "Core: Add json and markdown to lint workflow + add automation workflow",
"author": "cewert"
},
{
"description": "Core: Make workflows use latest LTS release + cache npm",
"author": "cewert"
},
{
"description": "Core: Install & Configure code unit test suite",
"author": "1hitsong"
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +0,0 @@
function TestSuite__Misc() as object
' Inherite test suite from BaseTestSuite
this = BaseTestSuite()
' Test suite name for log statistics
this.Name = "MiscTestSuite"
this.SetUp = MiscTestSuite__SetUp
this.TearDown = MiscTestSuite__TearDown
' Add tests to suite's tests collection
this.addTest("IsValid() true", TestCase__Misc_IsValid_True)
this.addTest("IsValid() false", TestCase__Misc_IsValid_False)
this.addTest("RoundNumber() Floor", TestCase__Misc_RoundNumber_Floor)
this.addTest("RoundNumber() Ceiling", TestCase__Misc_RoundNumber_Ceiling)
return this
end function
'----------------------------------------------------------------
' This function called immediately before running tests of current suite.
'----------------------------------------------------------------
sub MiscTestSuite__SetUp()
end sub
'----------------------------------------------------------------
' This function called immediately after running tests of current suite.
'----------------------------------------------------------------
sub MiscTestSuite__TearDown()
end sub
'----------------------------------------------------------------
' Check if isValid() properly identifies valid items
'
' @return An empty string if test is success or error message if not.
'----------------------------------------------------------------
function TestCase__Misc_IsValid_True() as string
returnResults = ""
testData = [1, 2, [3, 4], { "key": invalid }, [1, 2, 3], CreateObject("roAppInfo")]
for each testItem in testData
returnResults = returnResults + m.AssertTrue(isValid(testItem))
end for
return m.AssertEmpty(returnResults)
end function
'----------------------------------------------------------------
' Check if isValid() properly identifies invalid items
'
' @return An empty string if test is success or error message if not.
'----------------------------------------------------------------
function TestCase__Misc_IsValid_False() as string
returnResults = ""
testData = [invalid, CreateObject("nothing")]
for each testItem in testData
returnResults = m.AssertFalse(isValid(testItem))
end for
return m.AssertEmpty(returnResults)
end function
'----------------------------------------------------------------
' Check if roundNumber() properly rounds down
'
' @return An empty string if test is success or error message if not.
'----------------------------------------------------------------
function TestCase__Misc_RoundNumber_Floor() as string
return m.AssertEqual(roundNumber(9.4), 9)
end function
'----------------------------------------------------------------
' Check if roundNumber() properly rounds up
'
' @return An empty string if test is success or error message if not.
'----------------------------------------------------------------
function TestCase__Misc_RoundNumber_Ceiling() as string
return m.AssertEqual(roundNumber(9.6), 10)
end function

View File

@ -41,6 +41,18 @@ function ticksToHuman(ticks as longinteger) as string
return r return r
end function end function
function secondsToHuman(totalSeconds as integer) as string
hours = stri(int(totalSeconds / 3600)).trim()
minutes = stri(int((totalSeconds - (val(hours) * 3600)) / 60)).trim()
seconds = stri(totalSeconds - (val(hours) * 3600) - (val(minutes) * 60)).trim()
if val(hours) > 0 and val(minutes) < 10 then minutes = "0" + minutes
if val(seconds) < 10 then seconds = "0" + seconds
r = ""
if val(hours) > 0 then r = hours + ":"
r = r + minutes + ":" + seconds
return r
end function
' Format time as 12 or 24 hour format based on system clock setting ' Format time as 12 or 24 hour format based on system clock setting
function formatTime(time) as string function formatTime(time) as string
hours = time.getHours() hours = time.getHours()
@ -194,13 +206,13 @@ sub setFieldTextValue(field, value)
end sub end sub
' Returns whether or not passed value is valid ' Returns whether or not passed value is valid
function isValid(input) as boolean function isValid(input as dynamic) as boolean
return input <> invalid return input <> invalid
end function end function
' Returns whether or not passed value is valid and not empty ' Returns whether or not passed value is valid and not empty
' Accepts a string, or any countable type (arrays and lists) ' Accepts a string, or any countable type (arrays and lists)
function isValidAndNotEmpty(input) as boolean function isValidAndNotEmpty(input as dynamic) as boolean
if not isValid(input) then return false if not isValid(input) then return false
' Use roAssociativeArray instead of list so we get access to the doesExist() method ' Use roAssociativeArray instead of list so we get access to the doesExist() method
countableTypes = { "array": 1, "list": 1, "roarray": 1, "roassociativearray": 1, "rolist": 1 } countableTypes = { "array": 1, "list": 1, "roarray": 1, "roassociativearray": 1, "rolist": 1 }

4
test-app/manifest Normal file
View File

@ -0,0 +1,4 @@
title=Rooibos Unit Testing
major_version=0
minor_version=1
build_version=1

View File

@ -0,0 +1,18 @@
namespace tests
class BaseTestSuite extends rooibos.BaseTestSuite
private appController
protected override function setup()
'Do something here all your files need like setup the logger, etc
end function
protected override function beforeEach()
'do things here that all your tests need
end function
protected override function afterEach()
'tidy things up
end function
end class
end namespace

4
test-app/source/Main.bs Normal file
View File

@ -0,0 +1,4 @@
function Main(args)
? "here is my code"
? "hello"
end function

View File

@ -0,0 +1,188 @@
namespace tests
@suite("isValid functions")
class isValidTests extends tests.BaseTestSuite
protected override function setup()
super.setup()
m.myArray = CreateObject("roArray", 3, true)
m.myAssArray = { one: invalid, two: "invalid", three: 123.456 }
m.myEmptyArray = CreateObject("roArray", 0, false)
m.myEmptyList = CreateObject("roList")
m.myList = CreateObject("roList")
m.myList.AddTail("string")
end function
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@describe("isValid()")
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@it("works with booleans")
@params(true, true)
@params(false, true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with integers")
@params(-1234567890, true)
@params(0, true)
@params(1234567890, true)
@params(1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890, true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with floats")
@params(-12.3456789, true)
@params(12.3456789, true)
@params(1.23456E+30, true)
@params(12.3456789!, true)
@params(123456789012345678901234567890123456789012345678901234567890.123456789012345678901234567890123456789012345678901234567890, true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with strings")
@params("", true)
@params(" ", true)
@params("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Augue neque gravida in fermentum et. Eget lorem dolor sed viverra ipsum nunc. At quis risus sed vulputate odio ut enim. Ultricies integer quis auctor elit sed. Egestas congue quisque egestas diam in. Aliquam sem fringilla ut morbi tincidunt. Malesuada bibendum arcu vitae elementum curabitur. Aliquet sagittis id consectetur purus ut faucibus pulvinar. Eget gravida cum sociis natoque. Sollicitudin aliquam ultrices sagittis orci. Ut etiam sit amet nisl purus. Luctus venenatis lectus magna fringilla urna porttitor rhoncus dolor purus. Vitae ultricies leo integer malesuada nunc. Vitae ultricies leo integer malesuada nunc vel risus commodo. Luctus accumsan tortor posuere ac ut. Urna cursus eget nunc scelerisque viverra mauris in. Accumsan sit amet nulla facilisi morbi tempus iaculis urna id. Mauris vitae ultricies leo integer malesuada nunc vel risus commodo. Morbi tincidunt augue interdum velit euismod in pellentesque.", true)
@params("~!@#$%^&*()_-+=`\|]}';:.,/?", true)
@params("true", true)
@params("false", true)
@params("invalid", true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with arrays")
@params([0, 1, 2, 3, 4, 5], true)
@params(["invalid", "one", "two", "three", "four", "five"], true)
@params([invalid, invalid, invalid], true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with associative arrays")
@params({ myInteger: 1, myString: "one", myInvalid: invalid, myEmptyString: "" }, true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with an array of associative arrays")
@params(
[
{
Title: "The Notebook",
releaseDate: "2000"
},
{
Title: "Caddyshack",
releaseDate: "1976"
}
], true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works when accessing arrays")
function _()
m.assertEqual(isValid(m.myAssArray.one), false)
m.assertEqual(isValid(m.myAssArray.two), true)
end function
@it("works when accessing an invalid array index")
function _()
m.assertEqual(isValid(m.myAssArray.zero), false)
end function
@it("works with invalid")
@params(invalid, false)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with nodes")
@params("#RBSNode", true)
@params("#RBSNode|Group", true)
@params("#RBSNode|Label", true)
@params("#RBSNode|ScrollingLabel", true)
@params("#RBSNode|Poster", true)
@params("#RBSNode|Rectangle", true)
@params("#RBSNode|Font", true)
@params("#RBSNode|Button", true)
@params("#RBSNode|Rectangle", true)
@params("#RBSNode|Overhang", true)
@params("#RBSNode|Audio", true)
@params("#RBSNode|Video", true)
function _(value, expectedassertResult)
m.assertEqual(isValid(value), expectedassertResult)
end function
@it("works with objects")
function _()
myList = CreateObject("roList")
myLongInteger = CreateObject("roLongInteger")
myDouble = CreateObject("roDouble")
myFloat = CreateObject("roFloat")
myInvalid = CreateObject("roInvalid")
m.assertEqual(isValid(myList), true)
m.assertEqual(isValid(myLongInteger), true)
m.assertEqual(isValid(myDouble), true)
m.assertEqual(isValid(myFloat), true)
m.assertEqual(isValid(myInvalid), false)
end function
@it("works with functions")
function _()
myfunc = function(a, b)
return a + b
end function
m.assertEqual(isValid(myfunc(0, 1)), true)
end function
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@describe("isValidAndNotEmpty()")
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@it("works with invalid")
@params(invalid, false)
function _(value, expectedassertResult)
m.assertEqual(isValidAndNotEmpty(value), expectedassertResult)
end function
@it("works with strings")
@params("", false)
@params(" ", false)
@params("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Augue neque gravida in fermentum et. Eget lorem dolor sed viverra ipsum nunc. At quis risus sed vulputate odio ut enim. Ultricies integer quis auctor elit sed. Egestas congue quisque egestas diam in. Aliquam sem fringilla ut morbi tincidunt. Malesuada bibendum arcu vitae elementum curabitur. Aliquet sagittis id consectetur purus ut faucibus pulvinar. Eget gravida cum sociis natoque. Sollicitudin aliquam ultrices sagittis orci. Ut etiam sit amet nisl purus. Luctus venenatis lectus magna fringilla urna porttitor rhoncus dolor purus. Vitae ultricies leo integer malesuada nunc. Vitae ultricies leo integer malesuada nunc vel risus commodo. Luctus accumsan tortor posuere ac ut. Urna cursus eget nunc scelerisque viverra mauris in. Accumsan sit amet nulla facilisi morbi tempus iaculis urna id. Mauris vitae ultricies leo integer malesuada nunc vel risus commodo. Morbi tincidunt augue interdum velit euismod in pellentesque.", true)
@params("~!@#$%^&*()_-+=`\|]}';:.,/?", true)
@params("true", true)
@params("false", true)
@params("invalid", true)
function _(value, expectedassertResult)
m.assertEqual(isValidAndNotEmpty(value), expectedassertResult)
end function
@it("works with arrays")
function _()
m.assertEqual(isValidAndNotEmpty(m.myEmptyArray), false)
m.assertEqual(isValidAndNotEmpty(m.myArray), false)
m.myArray.Push("string")
m.assertEqual(isValidAndNotEmpty(m.myArray), true)
m.myArray.Clear()
m.assertEqual(isValidAndNotEmpty(m.myArray), false)
end function
@it("works with associative arrays")
function _()
m.assertEqual(isValidAndNotEmpty(m.myEmptyArray), false)
m.assertEqual(isValidAndNotEmpty(m.myAssArray), true)
end function
@it("works with lists")
function _()
m.assertEqual(isValidAndNotEmpty(m.myEmptyList), false)
m.assertEqual(isValidAndNotEmpty(m.myList), true)
end function
end class
end namespace