Merge branch 'unstable' into release-sync-166

This commit is contained in:
Charles Ewert 2023-04-26 21:05:38 -04:00
commit 1eaa3649e3
34 changed files with 9023 additions and 14109 deletions

View File

@ -18,4 +18,4 @@ jobs:
days-before-pr-stale: 21
days-before-pr-close: 7
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
with:
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
with:
node-version: "lts/*"

View File

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

17
.gitignore vendored
View File

@ -1,18 +1,17 @@
*.svg
jellyfin-roku.zip
source/globals.brs
.env
###BrightScript specific
# BrightScript
dist/apps
out/
build/
roku_modules
#NPM modules
source/globals.brs
jellyfin-roku.zip
# Rooibos
bsconfig-tdd.json
# NPM
node_modules/
#Eclipse
# Eclipse
.buildpath
.project
.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"
]
}
}

62
.vscode/launch.json vendored
View File

@ -4,7 +4,7 @@
{
"type": "brightscript",
"request": "launch",
"name": "Jellyfin Debug: Launch",
"name": "Jellyfin Debug",
"stopOnEntry": false,
// To enable RALE:
// set "brightscript.debug.raleTrackerTaskFileLocation": "/absolute/path/to/rale/TrackerTask.xml" in your vscode user settings
@ -22,6 +22,66 @@
"source/**/*",
"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": {
"*.ts": "xml"
},
"[xml]": {
"editor.defaultFormatter": "redhat.vscode-xml"
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
"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
}
}
]
}

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/*.*"
],
"plugins": [
"@rokucommunity/bslint"
"@rokucommunity/bslint",
"rooibos-roku"
],
"diagnosticFilters": [
"**/roku_modules/**/*",
"**/testFramework/*",
"**/tests/*"
"node_modules/**",
"**/roku_modules/**"
]
}

View File

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

View File

@ -13,7 +13,7 @@ sub loadItems()
m.top.content = [LoadItems_VideoPlayer(m.top.itemId)]
end sub
function LoadItems_VideoPlayer(id, mediaSourceId = invalid, audio_stream_idx = 1, subtitle_idx = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
function LoadItems_VideoPlayer(id as string, mediaSourceId = invalid as dynamic, audio_stream_idx = 1 as integer, subtitle_idx = -1 as integer, forceTranscoding = false as boolean, showIntro = true as boolean, allowResumeDialog = true as boolean) as dynamic
video = {}
video.id = id
@ -32,7 +32,7 @@ function LoadItems_VideoPlayer(id, mediaSourceId = invalid, audio_stream_idx = 1
return video
end function
sub LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx = 1, subtitle_idx = -1, playbackPosition = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx = 1 as integer, subtitle_idx = -1 as integer, playbackPosition = -1 as integer, forceTranscoding = false as boolean, showIntro = true as boolean, allowResumeDialog = true as boolean)
meta = ItemMetaData(video.id)

View File

@ -0,0 +1,4 @@
sub init()
checkmark = m.top.findNode("checkmark")
checkmark.font.size = 48
end sub

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<component name="PlayedCheckmark" extends="Rectangle">
<children>
<Label id="checkmark" width="60" height="50" font="font:MediumBoldSystemFont" horizAlign="center" vertAlign="bottom" text="✓" />
</children>
<script type="text/brightscript" uri="PlayedCheckmark.brs" />
</component>

View File

@ -28,8 +28,9 @@ sub setData()
end if
else if datum.type = "Episode"
imgParams = { "AddPlayedIndicator": datum.UserData.Played }
m.top.isWatched = datum.UserData.Played
imgParams = {}
imgParams.Append({ "maxHeight": 261 })
imgParams.Append({ "maxWidth": 464 })
@ -68,8 +69,9 @@ sub setData()
end if
else if datum.type = "Movie"
imgParams = { AddPlayedIndicator: datum.UserData.Played }
m.top.isWatched = datum.UserData.Played
imgParams = {}
imgParams.Append({ "maxHeight": 261 })
imgParams.Append({ "maxWidth": 175 })
@ -92,8 +94,9 @@ sub setData()
end if
else if datum.type = "Video"
imgParams = { AddPlayedIndicator: datum.UserData.Played }
m.top.isWatched = datum.UserData.Played
imgParams = {}
imgParams.Append({ "maxHeight": 261 })
imgParams.Append({ "maxWidth": 175 })

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8"?>
<component name="HomeData" extends="ContentNode">
<interface>
<field id="id" type="string" />
@ -13,6 +13,7 @@
<field id="imageWidth" type="integer" value="464" />
<field id="PlayedPercentage" type="float" value="0" />
<field id="usePoster" type="bool" value="false" />
<field id="isWatched" type="bool" value="false" />
</interface>
<script type="text/brightscript" uri="pkg:/source/api/baserequest.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/config.brs" />

View File

@ -9,6 +9,7 @@ sub init()
m.itemPoster.observeField("loadStatus", "onPosterLoadStatusChanged")
m.unplayedCount = m.top.findNode("unplayedCount")
m.unplayedEpisodeCount = m.top.findNode("unplayedEpisodeCount")
m.playedIndicator = m.top.findNode("playedIndicator")
m.showProgressBarAnimation = m.top.findNode("showProgressBar")
m.showProgressBarField = m.top.findNode("showProgressBarField")
@ -37,6 +38,12 @@ sub itemContentChanged()
m.itemIcon.uri = itemData.iconUrl
end if
if itemData.isWatched
m.playedIndicator.visible = true
m.unplayedCount.visible = false
else
m.playedIndicator.visible = false
if LCase(itemData.type) = "series"
if get_user_setting("ui.tvshows.disableUnwatchedEpisodeCount", "false") = "false"
if isValid(itemData.json.UserData) and isValid(itemData.json.UserData.UnplayedItemCount)
@ -47,6 +54,7 @@ sub itemContentChanged()
end if
end if
end if
end if
' Format the Data based on the type of Home Data
if itemData.type = "CollectionFolder" or itemData.type = "UserView" or itemData.type = "Channel"
@ -64,6 +72,8 @@ sub itemContentChanged()
return
end if
playedIndicatorLeftPosition = m.itemPoster.width - 60
m.playedIndicator.translation = [playedIndicatorLeftPosition, 0]
m.itemText.height = 34
m.itemText.font.size = 25
@ -166,6 +176,20 @@ sub itemContentChanged()
return
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"
m.itemText.text = itemData.name

View File

@ -7,6 +7,7 @@
<Rectangle id="unplayedCount" visible="false" width="90" height="60" color="#00a4dcFF" translation="[375, 0]">
<Label id="unplayedEpisodeCount" width="90" height="60" font="font:SmallestBoldSystemFont" horizAlign="center" vertAlign="center" />
</Rectangle>
<PlayedCheckmark id="playedIndicator" color="#00a4dcFF" width="60" height="46" visible="false" />
</Poster>
<Rectangle id="progressBackground" visible="false" color="0x00000098" width="464" height="8" translation="[8,260]">
<Rectangle id="progress" color="#00a4dcFF" width="0" height="8" />

View File

@ -132,6 +132,12 @@ sub loadItems()
' Skip Books for now as we don't support it (issue #558)
if item.Type <> "Book"
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
results.push(tmp)
end if

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.0" language="es_ES" sourcelanguage="en_US">
<defaultcodec>UTF-8</defaultcodec>
<context>
<defaultcodec>UTF-8</defaultcodec>
<context>
<name>default</name>
<message>
<source>192.168.1.100:8096 or https://example.com/jellyfin</source>
@ -240,8 +240,8 @@
<source>Server</source>
<translation>Servidor</translation>
</message>
</context>
<context>
</context>
<context>
<name></name>
<message>
<source>Sign Out</source>
@ -3470,5 +3470,319 @@
<source>Disabled</source>
<translation>Deshabilitado</translation>
</message>
</context>
<message>
<source>Change Server</source>
<translation>Cambiar servidor</translation>
</message>
<message>
<source>Save Credentials?</source>
<translation>¿Guardar credenciales?</translation>
</message>
<message>
<source>Sign Out</source>
<translation>Cerrar sesión</translation>
</message>
<message>
<source>Born</source>
<translation>Nacido/a</translation>
</message>
<message>
<source>TV Shows</source>
<translation>Programas de televisión</translation>
</message>
<message>
<source>Error During Playback</source>
<translation>Error durante la reproducción</translation>
<extracomment>Dialog title when error occurs during playback</extracomment>
</message>
<message>
<source>Unable to load Channel Data from the server</source>
<translation>No es posible cargar los datos del canal desde el servidor</translation>
</message>
<message>
<comment>Name or Title field of media item</comment>
<source>TITLE</source>
<translation>Nombre</translation>
</message>
<message>
<source>Additional Parts</source>
<translation>Partes adicionales</translation>
<extracomment>Additional parts of a video</extracomment>
</message>
<message>
<source>Friday</source>
<translation>Viernes</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Close</source>
<translation>Cerrar</translation>
</message>
<message>
<source>On Now</source>
<translation>Transmitiendo Ahora</translation>
</message>
<message>
<source>Error Retrieving Content</source>
<translation>Error al recuperar el contenido</translation>
<extracomment>Dialog title when unable to load Content from Server</extracomment>
</message>
<message>
<source>There was an error retrieving the data for this item from the server.</source>
<translation>Ha habido un error al intentar obtener este ítem del servidor.</translation>
<extracomment>Dialog detail when unable to load Content from Server</extracomment>
</message>
<message>
<source>An error was encountered while playing this item.</source>
<translation>Se ha encontrado un error durante la reproduccion de este ítem.</translation>
<extracomment>Dialog detail when error occurs during playback</extracomment>
</message>
<message>
<comment>Message displayed in Item Grid when no item to display. %1 is container type (e.g. Boxset, Collection, Folder, etc)</comment>
<source>NO_ITEMS</source>
<translation>Este %1 no contiene ítems</translation>
</message>
<message>
<source>DATE_PLAYED</source>
<translation>Fecha de reproducción</translation>
</message>
<message>
<source>OFFICIAL_RATING</source>
<translation>Valoración parental</translation>
</message>
<message>
<comment>Title of Tab for options to filter library content</comment>
<source>TAB_FILTER</source>
<translation>Filtrar</translation>
</message>
<message>
<source>Died</source>
<translation>Fallecido/a</translation>
</message>
<message>
<source>Press &apos;OK&apos; to Close</source>
<translation>Presiona &apos;Aceptar&apos; para cerrar</translation>
</message>
<message>
<source>Special Features</source>
<translation>Características especiales</translation>
</message>
<message>
<source>yesterday</source>
<translation>ayer</translation>
<extracomment>Previous day</extracomment>
</message>
<message>
<source>tomorrow</source>
<translation>mañana</translation>
<extracomment>Next day</extracomment>
</message>
<message>
<source>Sunday</source>
<translation>Domingo</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Monday</source>
<translation>Lunes</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Tuesday</source>
<translation>Martes</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Wednesday</source>
<translation>Miércoles</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Thursday</source>
<translation>Jueves</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Saturday</source>
<translation>Sábado</translation>
<extracomment>Day of Week</extracomment>
</message>
<message>
<source>Started at</source>
<translation>Empezado a las</translation>
<extracomment>(Past Tense) For defining time when a program started today (e.g. Started at 08:00) </extracomment>
</message>
<message>
<source>Starts</source>
<translation>Comenzará</translation>
<extracomment>(Future Tense) For defining a day and time when a program will start (e.g. Starts Wednesday, 08:00) </extracomment>
</message>
<message>
<source>Ended at</source>
<translation>Terminado a las</translation>
<extracomment>(Past Tense) For defining time when a program will ended (e.g. Ended at 08:00) </extracomment>
</message>
<message>
<source>Live</source>
<translation>Directo</translation>
<extracomment>If TV Show is being broadcast live (not pre-recorded)</extracomment>
</message>
<message>
<source>Channels</source>
<translation>Canales</translation>
<extracomment>Menu option for showing Live TV Channel List</extracomment>
</message>
<message>
<source>TV Guide</source>
<translation>Guía de Televisión</translation>
<extracomment>Menu option for showing Live TV Guide / Schedule</extracomment>
</message>
<message>
<source>Cancel Recording</source>
<translation>Cancelar la Grabación</translation>
</message>
<message>
<source>Connecting to Server</source>
<translation>Conectando al Servidor</translation>
<extracomment>Message to display to user while client is attempting to connect to the server</extracomment>
</message>
<message>
<source>Not found</source>
<translation>No encontrado</translation>
<extracomment>Title of message box when the requested content is not found on the server</extracomment>
</message>
<message>
<source>The requested content does not exist on the server</source>
<translation>El contenido solicitado no existe en el servidor</translation>
<extracomment>Content of message box when the requested content is not found on the server</extracomment>
</message>
<message>
<source>Pick a Jellyfin server from the local network</source>
<translation>Seleccione un servidor Jellyfin disponible en su red local</translation>
<extracomment>Instructions on initial app launch when the user is asked to pick a server from a list</extracomment>
</message>
<message>
<source>RUNTIME</source>
<translation>Duración</translation>
</message>
<message>
<comment>Title of Tab for switching &quot;views&quot; when looking at a library</comment>
<source>TAB_VIEW</source>
<translation>Vista</translation>
</message>
<message>
<source>Unknown</source>
<translation>Desconocido</translation>
<extracomment>Title for a cast member for which we have no information for</extracomment>
</message>
<message>
<source>IMDB_RATING</source>
<translation>Valoración IMDb</translation>
</message>
<message>
<source>DATE_ADDED</source>
<translation>Fecha añadido</translation>
</message>
<message>
<source>Age</source>
<translation>Edad</translation>
</message>
<message>
<source>Cast &amp; Crew</source>
<translation>Elenco y equipo</translation>
</message>
<message>
<source>More Like This</source>
<translation>Más de este estilo</translation>
</message>
<message>
<source>today</source>
<translation>hoy</translation>
<extracomment>Current day</extracomment>
</message>
<message>
<source>Repeat</source>
<translation>Repetir</translation>
<extracomment>If TV Shows has previously been broadcasted</extracomment>
</message>
<message>
<source>Movies (Presentation)</source>
<translation>Películas (en modo presentación)</translation>
<extracomment>Movie library view option</extracomment>
</message>
<message>
<source>Movies (Grid)</source>
<translation>Películas (cuadrícula)</translation>
<extracomment>Movie library view option</extracomment>
</message>
<message>
<source>Starts at</source>
<translation>Comienza a las</translation>
<extracomment>(Future Tense) For defining time when a program will start today (e.g. Starts at 08:00) </extracomment>
</message>
<message>
<source>Record Series</source>
<translation>Grabar Serie</translation>
</message>
<message>
<source>Cancel Series Recording</source>
<translation>Cancelar la Grabación de la Serie</translation>
</message>
<message>
<source>Ends at</source>
<translation>Termina a las</translation>
<extracomment>(Past Tense) For defining a day and time when a program ended (e.g. Ended Wednesday, 08:00) </extracomment>
</message>
<message>
<source>View Channel</source>
<translation>Ver Canal</translation>
</message>
<message>
<source>Record</source>
<translation>Grabar</translation>
</message>
<message>
<source>Started</source>
<translation>Empezó</translation>
<extracomment>(Past Tense) For defining a day and time when a program started (e.g. Started Wednesday, 08:00) </extracomment>
</message>
<message>
<source>CRITIC_RATING</source>
<translation>Puntuación de la crítica</translation>
</message>
<message>
<source>Enter the server name or IP address</source>
<translation>Introduce el nombre del servidor o la dirección IP</translation>
<extracomment>Title of KeyboardDialog when manually entering a server URL</extracomment>
</message>
<message>
<source>Delete Saved</source>
<translation>Eliminar Guardado</translation>
</message>
<message>
<source>Loading Channel Data</source>
<translation>Cargando información del canal</translation>
</message>
<message>
<source>Error loading Channel Data</source>
<translation>Error al cargar la información del canal</translation>
</message>
<message>
<source>PLAY_COUNT</source>
<translation>Número de reproducciones</translation>
</message>
<message>
<source>RELEASE_DATE</source>
<translation>Fecha de estreno</translation>
</message>
<message>
<comment>Title of Tab for options to sort library content</comment>
<source>TAB_SORT</source>
<translation>Ordenar</translation>
</message>
<message>
<source>Movies</source>
<translation>Películas</translation>
</message>
</context>
</TS>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.0" language="fr_CA" sourcelanguage="en_US">
<defaultcodec>UTF-8</defaultcodec>
<context>
<defaultcodec>UTF-8</defaultcodec>
<context>
<name>default</name>
<message>
<source>192.168.1.100:8096 or https://example.com/jellyfin</source>
@ -180,8 +180,8 @@
<source>Server</source>
<translation>Servidor</translation>
</message>
</context>
<context>
</context>
<context>
<name></name>
<message>
<source>Sign Out</source>
@ -2691,5 +2691,30 @@
<source>Error loading Channel Data</source>
<translation>Erro ao carregar os Dados do Canal</translation>
</message>
</context>
<message>
<source>Change Server</source>
<translation type="unfinished">Alterar Servidor</translation>
</message>
<message>
<source>Sign Out</source>
<translation type="unfinished">Sair</translation>
</message>
<message>
<source>Save Credentials?</source>
<translation type="unfinished">Salvar credenciais?</translation>
</message>
<message>
<source>Delete Saved</source>
<translation type="unfinished">Excluir salvo</translation>
</message>
<message>
<source>On Now</source>
<translation type="unfinished">Ativo agora</translation>
</message>
<message>
<source>Error Retrieving Content</source>
<translation type="unfinished">Erro ao recuperar conteúdo</translation>
<extracomment>Dialog title when unable to load Content from Server</extracomment>
</message>
</context>
</TS>

6080
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,26 +1,6 @@
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.
m.screen = CreateObject("roSGScreen")
' Set global constants
setConstants()
' Write screen tracker for screensaver
@ -77,6 +57,7 @@ sub Main (args as dynamic) as void
end if
' Only show the Whats New popup the first time a user runs a new client version.
appInfo = CreateObject("roAppInfo")
if appInfo.GetVersion() <> get_setting("LastRunVersion")
' Ensure the user hasn't disabled Whats New popups
if get_user_setting("load.allowwhatsnew") = "true"
@ -158,7 +139,7 @@ sub Main (args as dynamic) as void
m.selectedItemType = selectedItem.type
if selectedItem.type = "CollectionFolder"
if selectedItem.type = "CollectionFolder" or selectedItem.type = "BoxSet"
if selectedItem.collectionType = "movies"
group = CreateMovieLibraryView(selectedItem)
else if selectedItem.collectionType = "music"
@ -621,149 +602,3 @@ sub Main (args as dynamic) as void
end while
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
' 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()
screen = CreateObject("roSGNode", "SetServerScreen")
screen.optionsAvailable = true
@ -330,36 +476,57 @@ function CreateHomeGroup()
return group
end function
function CreateMovieDetailsGroup(movie)
function CreateMovieDetailsGroup(movie as object) as dynamic
' validate movie node
if not isValid(movie) or not isValid(movie.id) then return invalid
startLoadingSpinner()
' get movie meta data
movieMetaData = ItemMetaData(movie.id)
' validate movie meta data
if not isValid(movieMetaData)
stopLoadingSpinner()
return invalid
end if
' start building MovieDetails view
group = CreateObject("roSGNode", "MovieDetails")
group.overhangTitle = movie.title
group.optionsAvailable = false
m.global.sceneManager.callFunc("pushScene", group)
movieMetaData = ItemMetaData(movie.id)
group.itemContent = movieMetaData
group.trailerAvailable = false
' push scene asap (to prevent extra button presses when retriving series/movie info)
m.global.sceneManager.callFunc("pushScene", group)
group.itemContent = movieMetaData
' local trailers
trailerData = api_API().users.getlocaltrailers(get_setting("active_user"), movie.id)
if isValid(trailerData)
group.trailerAvailable = trailerData.Count() > 0
end if
' watch for button presses
buttons = group.findNode("buttons")
for each b in buttons.getChildren(-1, 0)
b.observeField("buttonSelected", m.port)
end for
' setup and load movie extras
extras = group.findNode("extrasGrid")
extras.observeField("selectedItem", m.port)
extras.callFunc("loadParts", movieMetaData.json)
' done building MovieDetails view
stopLoadingSpinner()
return group
end function
function CreateSeriesDetailsGroup(series)
function CreateSeriesDetailsGroup(series as object) as dynamic
' validate series node
if not isValid(series) or not isValid(series.id) then return invalid
startLoadingSpinner()
' get series meta data
seriesMetaData = ItemMetaData(series.id)
' validate series meta data
if not isValid(seriesMetaData)
stopLoadingSpinner()
return invalid
end if
' Get season data early in the function so we can check number of seasons.
seasonData = TVSeasons(series.id)
' Divert to season details if user setting goStraightToEpisodeListing is enabled and only one season exists.
@ -367,38 +534,43 @@ function CreateSeriesDetailsGroup(series)
stopLoadingSpinner()
return CreateSeasonDetailsGroupByID(series.id, seasonData.Items[0].id)
end if
' start building SeriesDetails view
group = CreateObject("roSGNode", "TVShowDetails")
group.optionsAvailable = false
' push scene asap (to prevent extra button presses when retriving series/movie info)
m.global.sceneManager.callFunc("pushScene", group)
group.itemContent = ItemMetaData(series.id)
group.seasonData = seasonData ' Re-use variable from beginning of function
group.itemContent = seriesMetaData
group.seasonData = seasonData
' watch for button presses
group.observeField("seasonSelected", m.port)
' setup and load series extras
extras = group.findNode("extrasGrid")
extras.observeField("selectedItem", m.port)
extras.callFunc("loadParts", group.itemcontent.json)
extras.callFunc("loadParts", seriesMetaData.json)
' done building SeriesDetails view
stopLoadingSpinner()
return group
end function
' Shows details on selected artist. Bio, image, and list of available albums
function CreateArtistView(musicartist)
musicData = MusicAlbumList(musicartist.id)
appearsOnData = AppearsOnList(musicartist.id)
function CreateArtistView(artist as object) as dynamic
' validate artist node
if not isValid(artist) or not isValid(artist.id) then return invalid
musicData = MusicAlbumList(artist.id)
appearsOnData = AppearsOnList(artist.id)
if (musicData = invalid or musicData.Items.Count() = 0) and (appearsOnData = invalid or appearsOnData.Items.Count() = 0)
' Just songs under artists...
group = CreateObject("roSGNode", "AlbumView")
group.pageContent = ItemMetaData(musicartist.id)
group.pageContent = ItemMetaData(artist.id)
' Lookup songs based on artist id
songList = GetSongsByArtist(musicartist.id)
songList = GetSongsByArtist(artist.id)
if not isValid(songList)
' Lookup songs based on folder parent / child relationship
songList = MusicSongList(musicartist.id)
songList = MusicSongList(artist.id)
end if
if not isValid(songList)
@ -412,10 +584,10 @@ function CreateArtistView(musicartist)
else
' User has albums under artists
group = CreateObject("roSGNode", "ArtistView")
group.pageContent = ItemMetaData(musicartist.id)
group.pageContent = ItemMetaData(artist.id)
group.musicArtistAlbumData = musicData
group.musicArtistAppearsOnData = appearsOnData
group.artistOverview = ArtistOverview(musicartist.name)
group.artistOverview = ArtistOverview(artist.name)
group.observeField("musicAlbumSelected", m.port)
group.observeField("playArtistSelected", m.port)
@ -429,7 +601,10 @@ function CreateArtistView(musicartist)
end function
' Shows details on selected album. Description text, image, and list of available songs
function CreateAlbumView(album)
function CreateAlbumView(album as object) as dynamic
' validate album node
if not isValid(album) or not isValid(album.id) then return invalid
group = CreateObject("roSGNode", "AlbumView")
m.global.sceneManager.callFunc("pushScene", group)
@ -449,12 +624,15 @@ function CreateAlbumView(album)
end function
' Shows details on selected playlist. Description text, image, and list of available items
function CreatePlaylistView(album)
function CreatePlaylistView(playlist as object) as dynamic
' validate playlist node
if not isValid(playlist) or not isValid(playlist.id) then return invalid
group = CreateObject("roSGNode", "PlaylistView")
m.global.sceneManager.callFunc("pushScene", group)
group.pageContent = ItemMetaData(album.id)
group.albumData = PlaylistItemList(album.id)
group.pageContent = ItemMetaData(playlist.id)
group.albumData = PlaylistItemList(playlist.id)
' Watch for user clicking on an item
group.observeField("playItem", m.port)
@ -465,39 +643,66 @@ function CreatePlaylistView(album)
return group
end function
function CreateSeasonDetailsGroup(series, season)
function CreateSeasonDetailsGroup(series as object, season as object) as dynamic
' validate series node
if not isValid(series) or not isValid(series.id) then return invalid
' validate season node
if not isValid(season) or not isValid(season.id) then return invalid
startLoadingSpinner()
' get season meta data
seasonMetaData = ItemMetaData(season.id)
' validate season meta data
if not isValid(seasonMetaData)
stopLoadingSpinner()
return invalid
end if
' start building SeasonDetails view
group = CreateObject("roSGNode", "TVEpisodes")
group.optionsAvailable = false
' push scene asap (to prevent extra button presses when retriving series/movie info)
m.global.sceneManager.callFunc("pushScene", group)
group.seasonData = ItemMetaData(season.id).json
group.seasonData = seasonMetaData.json
group.objects = TVEpisodes(series.id, season.id)
' watch for button presses
group.observeField("episodeSelected", m.port)
group.observeField("quickPlayNode", m.port)
' finished building SeasonDetails view
stopLoadingSpinner()
return group
end function
function CreateSeasonDetailsGroupByID(seriesID, seasonID)
function CreateSeasonDetailsGroupByID(seriesID as string, seasonID as string) as dynamic
' validate parameters
if seriesID = "" or seasonID = "" then return invalid
startLoadingSpinner()
' get season meta data
seasonMetaData = ItemMetaData(seasonID)
' validate season meta data
if not isValid(seasonMetaData)
stopLoadingSpinner()
return invalid
end if
' start building SeasonDetails view
group = CreateObject("roSGNode", "TVEpisodes")
group.optionsAvailable = false
' push scene asap (to prevent extra button presses when retriving series/movie info)
m.global.sceneManager.callFunc("pushScene", group)
group.seasonData = ItemMetaData(seasonID).json
group.seasonData = seasonMetaData.json
group.objects = TVEpisodes(seriesID, seasonID)
' watch for button presses
group.observeField("episodeSelected", m.port)
group.observeField("quickPlayNode", m.port)
' finished building SeasonDetails view
stopLoadingSpinner()
return group
end function
function CreateItemGrid(libraryItem)
function CreateItemGrid(libraryItem as object) as dynamic
' validate libraryItem
if not isValid(libraryItem) then return invalid
group = CreateObject("roSGNode", "ItemGrid")
group.parentItem = libraryItem
group.optionsAvailable = true
@ -505,7 +710,10 @@ function CreateItemGrid(libraryItem)
return group
end function
function CreateMovieLibraryView(libraryItem)
function CreateMovieLibraryView(libraryItem as object) as dynamic
' validate libraryItem
if not isValid(libraryItem) then return invalid
group = CreateObject("roSGNode", "MovieLibraryView")
group.parentItem = libraryItem
group.optionsAvailable = true
@ -513,7 +721,10 @@ function CreateMovieLibraryView(libraryItem)
return group
end function
function CreateMusicLibraryView(libraryItem)
function CreateMusicLibraryView(libraryItem as object) as dynamic
' validate libraryItem
if not isValid(libraryItem) then return invalid
group = CreateObject("roSGNode", "MusicLibraryView")
group.parentItem = libraryItem
group.optionsAvailable = true
@ -530,13 +741,10 @@ function CreateSearchPage()
return group
end function
sub CreateSidePanel(buttons, options)
group = CreateObject("roSGNode", "OptionsSlider")
group.buttons = buttons
group.options = options
end sub
function CreateVideoPlayerGroup(video_id as string, mediaSourceId = invalid as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean, showIntro = true as boolean, allowResumeDialog = true as boolean)
' validate video_id
if not isValid(video_id) or video_id = "" then return invalid
function CreateVideoPlayerGroup(video_id, mediaSourceId = invalid, audio_stream_idx = 1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
startMediaLoadingSpinner()
' Video is Playing
video = VideoPlayer(video_id, mediaSourceId, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), forceTranscoding, showIntro, allowResumeDialog)
@ -553,18 +761,29 @@ function CreateVideoPlayerGroup(video_id, mediaSourceId = invalid, audio_stream_
return video
end function
function CreatePersonView(personData as object) as object
startLoadingSpinner()
person = CreateObject("roSGNode", "PersonDetails")
m.global.SceneManager.callFunc("pushScene", person)
function CreatePersonView(personData as object) as dynamic
' validate personData node
if not isValid(personData) or not isValid(personData.id) then return invalid
info = ItemMetaData(personData.id)
person.itemContent = info
startLoadingSpinner()
' get person meta data
personMetaData = ItemMetaData(personData.id)
' validate season meta data
if not isValid(personMetaData)
stopLoadingSpinner()
return invalid
end if
' start building Person View
person = CreateObject("roSGNode", "PersonDetails")
' push scene asap (to prevent extra button presses when retriving series/movie info)
m.global.SceneManager.callFunc("pushScene", person)
person.itemContent = personMetaData
person.setFocus(true)
' watch for button presses
person.observeField("selectedItem", m.port)
person.findNode("favorite-button").observeField("buttonSelected", m.port)
' finished building Person View
stopLoadingSpinner()
return person
end function

View File

@ -1,4 +1,4 @@
function VideoPlayer(id, mediaSourceId = invalid, audio_stream_idx = 1, subtitle_idx = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
function VideoPlayer(id as string, mediaSourceId = invalid as dynamic, audio_stream_idx = 1 as integer, subtitle_idx = -1 as integer, forceTranscoding = false as boolean, showIntro = true as boolean, allowResumeDialog = true as boolean) as dynamic
' Get video controls and UI
video = CreateObject("roSGNode", "JFVideo")
video.id = id
@ -20,7 +20,7 @@ function VideoPlayer(id, mediaSourceId = invalid, audio_stream_idx = 1, subtitle
return video
end function
sub AddVideoContent(video, mediaSourceId, audio_stream_idx = 1, subtitle_idx = -1, playbackPosition = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
sub AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx = 1 as integer, subtitle_idx = -1 as integer, playbackPosition = -1 as integer, forceTranscoding = false as boolean, showIntro = true as boolean, allowResumeDialog = true as boolean)
video.content = createObject("RoSGNode", "ContentNode")
meta = ItemMetaData(video.id)
if meta = invalid

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

@ -207,13 +207,13 @@ sub setFieldTextValue(field, value)
end sub
' Returns whether or not passed value is valid
function isValid(input) as boolean
function isValid(input as dynamic) as boolean
return input <> invalid
end function
' Returns whether or not passed value is valid and not empty
' 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
' Use roAssociativeArray instead of list so we get access to the doesExist() method
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