584 lines
21 KiB
Plaintext
584 lines
21 KiB
Plaintext
import "pkg:/source/utils/misc.bs"
|
|
import "pkg:/source/api/Items.bs"
|
|
import "pkg:/source/api/UserLibrary.bs"
|
|
import "pkg:/source/api/baserequest.bs"
|
|
import "pkg:/source/utils/config.bs"
|
|
import "pkg:/source/api/Image.bs"
|
|
import "pkg:/source/api/userauth.bs"
|
|
import "pkg:/source/utils/deviceCapabilities.bs"
|
|
|
|
enum SubtitleSelection
|
|
notset = -2
|
|
none = -1
|
|
end enum
|
|
|
|
sub init()
|
|
m.user = AboutMe()
|
|
m.top.functionName = "loadItems"
|
|
end sub
|
|
|
|
sub loadItems()
|
|
' Reset intro tracker in case task gets reused
|
|
m.top.isIntro = false
|
|
|
|
' Only show preroll once per queue
|
|
if m.global.queueManager.callFunc("isPrerollActive")
|
|
' Prerolls not allowed if we're resuming video
|
|
if m.global.queueManager.callFunc("getCurrentItem").startingPoint = 0
|
|
preRoll = GetIntroVideos(m.top.itemId)
|
|
if isValid(preRoll) and preRoll.TotalRecordCount > 0 and isValid(preRoll.items[0])
|
|
' If an error is thrown in the Intros plugin, instead of passing the error they pass the entire rick roll music video.
|
|
' Bypass the music video and treat it as an error message
|
|
if lcase(preRoll.items[0].name) <> "rick roll'd"
|
|
m.global.queueManager.callFunc("push", m.global.queueManager.callFunc("getCurrentItem"))
|
|
m.top.itemId = preRoll.items[0].id
|
|
m.global.queueManager.callFunc("setPrerollStatus", false)
|
|
m.top.isIntro = true
|
|
end if
|
|
end if
|
|
end if
|
|
end if
|
|
|
|
if m.top.selectedAudioStreamIndex = 0
|
|
currentItem = m.global.queueManager.callFunc("getCurrentItem")
|
|
if isValid(currentItem) and isValid(currentItem.json)
|
|
m.top.selectedAudioStreamIndex = FindPreferredAudioStream(currentItem.json.MediaStreams)
|
|
end if
|
|
end if
|
|
|
|
id = m.top.itemId
|
|
mediaSourceId = invalid
|
|
audio_stream_idx = m.top.selectedAudioStreamIndex
|
|
forceTranscoding = false
|
|
|
|
m.top.content = [LoadItems_VideoPlayer(id, mediaSourceId, audio_stream_idx, forceTranscoding)]
|
|
end sub
|
|
|
|
function LoadItems_VideoPlayer(id as string, mediaSourceId = invalid as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean) as dynamic
|
|
|
|
video = {}
|
|
video.id = id
|
|
video.content = createObject("RoSGNode", "ContentNode")
|
|
|
|
LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx, forceTranscoding)
|
|
|
|
if video.content = invalid
|
|
return invalid
|
|
end if
|
|
|
|
return video
|
|
end function
|
|
|
|
sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean)
|
|
|
|
meta = ItemMetaData(video.id)
|
|
subtitle_idx = m.top.selectedSubtitleIndex
|
|
|
|
if not isValid(meta)
|
|
video.errorMsg = "Error loading metadata"
|
|
video.content = invalid
|
|
return
|
|
end if
|
|
|
|
videotype = LCase(meta.type)
|
|
|
|
' Check for any Live TV streams or Recordings coming from other places other than the TV Guide
|
|
if videotype = "recording" or (isValid(meta.json) and isValid(meta.json.ChannelId))
|
|
if isValid(meta.json.EpisodeTitle)
|
|
meta.title = meta.json.EpisodeTitle
|
|
else if isValid(meta.json.Name)
|
|
meta.title = meta.json.Name
|
|
end if
|
|
meta.live = true
|
|
if LCase(meta.json.type) = "program"
|
|
video.id = meta.json.ChannelId
|
|
else
|
|
video.id = meta.json.id
|
|
end if
|
|
end if
|
|
|
|
if videotype = "episode" or videotype = "series"
|
|
video.content.contenttype = "episode"
|
|
end if
|
|
|
|
video.chapters = meta.json.Chapters
|
|
video.content.title = meta.title
|
|
video.showID = meta.showID
|
|
|
|
user = AboutMe()
|
|
if user.Configuration.EnableNextEpisodeAutoPlay
|
|
if LCase(m.top.itemType) = "episode"
|
|
addNextEpisodesToQueue(video.showID)
|
|
end if
|
|
end if
|
|
|
|
playbackPosition = 0!
|
|
|
|
currentItem = m.global.queueManager.callFunc("getCurrentItem")
|
|
|
|
if isValid(currentItem) and isValid(currentItem.startingPoint)
|
|
playbackPosition = currentItem.startingPoint
|
|
end if
|
|
|
|
' PlayStart requires the time to be in seconds
|
|
video.content.PlayStart = int(playbackPosition / 10000000)
|
|
|
|
if not isValid(mediaSourceId) then mediaSourceId = video.id
|
|
if meta.live then mediaSourceId = ""
|
|
|
|
m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition)
|
|
if not isValid(m.playbackInfo)
|
|
video.errorMsg = "Error loading playback info"
|
|
video.content = invalid
|
|
return
|
|
end if
|
|
|
|
addSubtitlesToVideo(video, meta)
|
|
|
|
' Enable default subtitle track
|
|
if subtitle_idx = SubtitleSelection.notset
|
|
defaultSubtitleIndex = defaultSubtitleTrackFromVid(video.id)
|
|
|
|
if defaultSubtitleIndex <> SubtitleSelection.none
|
|
video.SelectedSubtitle = defaultSubtitleIndex
|
|
subtitle_idx = defaultSubtitleIndex
|
|
|
|
m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition)
|
|
if not isValid(m.playbackInfo)
|
|
video.errorMsg = "Error loading playback info"
|
|
video.content = invalid
|
|
return
|
|
end if
|
|
|
|
addSubtitlesToVideo(video, meta)
|
|
else
|
|
video.SelectedSubtitle = subtitle_idx
|
|
end if
|
|
else
|
|
video.SelectedSubtitle = subtitle_idx
|
|
end if
|
|
|
|
video.videoId = video.id
|
|
video.mediaSourceId = mediaSourceId
|
|
video.audioIndex = audio_stream_idx
|
|
|
|
video.PlaySessionId = m.playbackInfo.PlaySessionId
|
|
|
|
if meta.live
|
|
video.content.live = true
|
|
video.content.StreamFormat = "hls"
|
|
end if
|
|
|
|
video.container = getContainerType(meta)
|
|
|
|
if not isValid(m.playbackInfo.MediaSources[0])
|
|
m.playbackInfo = meta.json
|
|
end if
|
|
|
|
addAudioStreamsToVideo(video)
|
|
if meta.live
|
|
video.transcodeParams = {
|
|
"MediaSourceId": m.playbackInfo.MediaSources[0].Id,
|
|
"LiveStreamId": m.playbackInfo.MediaSources[0].LiveStreamId,
|
|
"PlaySessionId": video.PlaySessionId
|
|
}
|
|
end if
|
|
|
|
|
|
' 'TODO: allow user selection of subtitle track before playback initiated, for now set to no subtitles
|
|
video.directPlaySupported = m.playbackInfo.MediaSources[0].SupportsDirectPlay
|
|
fully_external = false
|
|
|
|
|
|
' For h264/hevc video, Roku spec states that it supports specfic encoding levels
|
|
' The device can decode content with a Higher Encoding level but may play it back with certain
|
|
' artifacts. If the user preference is set, and the only reason the server says we need to
|
|
' transcode is that the Encoding Level is not supported, then try to direct play but silently
|
|
' fall back to the transcode if that fails.
|
|
if m.playbackInfo.MediaSources[0].MediaStreams.Count() > 0 and meta.live = false
|
|
tryDirectPlay = m.global.session.user.settings["playback.tryDirect.h264ProfileLevel"] and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "h264"
|
|
tryDirectPlay = tryDirectPlay or (m.global.session.user.settings["playback.tryDirect.hevcProfileLevel"] and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "hevc")
|
|
if tryDirectPlay and isValid(m.playbackInfo.MediaSources[0].TranscodingUrl) and forceTranscoding = false
|
|
transcodingReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
|
|
if transcodingReasons.Count() = 1 and transcodingReasons[0] = "VideoLevelNotSupported"
|
|
video.directPlaySupported = true
|
|
video.transcodeAvailable = true
|
|
end if
|
|
end if
|
|
end if
|
|
|
|
if video.directPlaySupported
|
|
video.isTranscoded = false
|
|
addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
|
|
else
|
|
if m.playbackInfo.MediaSources[0].TranscodingUrl = invalid
|
|
' If server does not provide a transcode URL, display a message to the user
|
|
m.global.sceneManager.callFunc("userMessage", tr("Error Getting Playback Information"), tr("An error was encountered while playing this item. Server did not provide required transcoding data."))
|
|
video.errorMsg = "Error getting playback information"
|
|
video.content = invalid
|
|
return
|
|
end if
|
|
' Get transcoding reason
|
|
video.transcodeReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
|
|
video.content.url = buildURL(m.playbackInfo.MediaSources[0].TranscodingUrl)
|
|
video.isTranscoded = true
|
|
end if
|
|
|
|
setCertificateAuthority(video.content)
|
|
video.audioTrack = (audio_stream_idx + 1).ToStr() ' Roku's track indexes count from 1. Our index is zero based
|
|
|
|
if not fully_external
|
|
video.content = authRequest(video.content)
|
|
end if
|
|
end sub
|
|
|
|
' defaultSubtitleTrackFromVid: Identifies the default subtitle track given video id
|
|
'
|
|
' @param {dynamic} videoID - id of video user is playing
|
|
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} is one is not found
|
|
function defaultSubtitleTrackFromVid(videoID) as integer
|
|
if m.global.session.user.configuration.SubtitleMode = "None"
|
|
return SubtitleSelection.none ' No subtitles desired: return none
|
|
end if
|
|
|
|
meta = ItemMetaData(videoID)
|
|
|
|
if not isValid(meta) then return SubtitleSelection.none
|
|
if not isValid(meta.json) then return SubtitleSelection.none
|
|
if not isValidAndNotEmpty(meta.json.mediaSources) then return SubtitleSelection.none
|
|
if not isValidAndNotEmpty(meta.json.MediaSources[0].MediaStreams) then return SubtitleSelection.none
|
|
|
|
subtitles = sortSubtitles(meta.id, meta.json.MediaSources[0].MediaStreams)
|
|
|
|
selectedAudioLanguage = ""
|
|
audioMediaStream = meta.json.MediaSources[0].MediaStreams[m.top.selectedAudioStreamIndex]
|
|
|
|
' Ensure audio media stream is valid before using language property
|
|
if isValid(audioMediaStream)
|
|
selectedAudioLanguage = audioMediaStream.Language ?? ""
|
|
end if
|
|
|
|
defaultTextSubs = defaultSubtitleTrack(subtitles["text"], selectedAudioLanguage, true) ' Find correct subtitle track (forced text)
|
|
if defaultTextSubs <> SubtitleSelection.none
|
|
return defaultTextSubs
|
|
end if
|
|
|
|
if not m.global.session.user.settings["playback.subs.onlytext"]
|
|
return defaultSubtitleTrack(subtitles["all"], selectedAudioLanguage) ' if no appropriate text subs exist, allow non-text
|
|
end if
|
|
|
|
return SubtitleSelection.none
|
|
end function
|
|
|
|
' defaultSubtitleTrack:
|
|
'
|
|
' @param {dynamic} sortedSubtitles - array of subtitles sorted by type and language
|
|
' @param {string} selectedAudioLanguage - language for selected audio track
|
|
' @param {boolean} [requireText=false] - indicates if only text subtitles should be considered
|
|
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} is one is not found
|
|
function defaultSubtitleTrack(sortedSubtitles, selectedAudioLanguage as string, requireText = false as boolean) as integer
|
|
userConfig = m.global.session.user.configuration
|
|
|
|
subtitleMode = isValid(userConfig.SubtitleMode) ? LCase(userConfig.SubtitleMode) : ""
|
|
|
|
allowSmartMode = false
|
|
|
|
' Only evaluate selected audio language if we have a value
|
|
if selectedAudioLanguage <> ""
|
|
allowSmartMode = selectedAudioLanguage <> userConfig.SubtitleLanguagePreference
|
|
end if
|
|
|
|
for each item in sortedSubtitles
|
|
' Only auto-select subtitle if language matches SubtitleLanguagePreference
|
|
languageMatch = true
|
|
if userConfig.SubtitleLanguagePreference <> ""
|
|
languageMatch = (userConfig.SubtitleLanguagePreference = item.Track.Language)
|
|
end if
|
|
|
|
' Ensure textuality of subtitle matches preferenced passed as arg
|
|
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
|
|
|
|
if languageMatch and matchTextReq
|
|
if subtitleMode = "default" and (item.isForced or item.IsDefault)
|
|
' Return first forced or default subtitle track
|
|
return item.Index
|
|
else if subtitleMode = "always"
|
|
' Return the first found subtitle track
|
|
return item.Index
|
|
else if subtitleMode = "onlyforced" and item.IsForced
|
|
' Return first forced subtitle track
|
|
return item.Index
|
|
else if subtitleMode = "smart" and allowSmartMode
|
|
' Return the first found subtitle track
|
|
return item.Index
|
|
end if
|
|
end if
|
|
end for
|
|
|
|
' User has chosed smart subtitle mode
|
|
' We already attempted to load subtitles in preferred language, but none were found.
|
|
' Fall back to default behaviour while ignoring preferredlanguage
|
|
if subtitleMode = "smart" and allowSmartMode
|
|
for each item in sortedSubtitles
|
|
' Ensure textuality of subtitle matches preferenced passed as arg
|
|
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
|
|
if matchTextReq
|
|
if item.isForced or item.IsDefault
|
|
' Return first forced or default subtitle track
|
|
return item.Index
|
|
end if
|
|
end if
|
|
end for
|
|
end if
|
|
|
|
return SubtitleSelection.none ' Keep current default behavior of "None", if no correct subtitle is identified
|
|
end function
|
|
|
|
sub addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
|
|
protocol = LCase(m.playbackInfo.MediaSources[0].Protocol)
|
|
if protocol <> "file"
|
|
uri = parseUrl(m.playbackInfo.MediaSources[0].Path)
|
|
if isLocalhost(uri[2])
|
|
' if the domain of the URI is local to the server,
|
|
' create a new URI by appending the received path to the server URL
|
|
' later we will substitute the users provided URL for this case
|
|
video.content.url = buildURL(uri[4])
|
|
else
|
|
fully_external = true
|
|
video.content.url = m.playbackInfo.MediaSources[0].Path
|
|
end if
|
|
else
|
|
params = {
|
|
"Static": "true",
|
|
"Container": video.container,
|
|
"PlaySessionId": video.PlaySessionId,
|
|
"AudioStreamIndex": audio_stream_idx
|
|
}
|
|
|
|
if mediaSourceId <> ""
|
|
params.MediaSourceId = mediaSourceId
|
|
end if
|
|
|
|
video.content.url = buildURL(Substitute("Videos/{0}/stream", video.id), params)
|
|
end if
|
|
end sub
|
|
|
|
|
|
' addAudioStreamsToVideo: Add audio stream data to video
|
|
'
|
|
' @param {dynamic} video component to add fullAudioData to
|
|
sub addAudioStreamsToVideo(video)
|
|
audioStreams = []
|
|
mediaStreams = m.playbackInfo.MediaSources[0].MediaStreams
|
|
|
|
for i = 0 to mediaStreams.Count() - 1
|
|
if LCase(mediaStreams[i].Type) = "audio"
|
|
audioStreams.push(mediaStreams[i])
|
|
end if
|
|
end for
|
|
|
|
video.fullAudioData = audioStreams
|
|
end sub
|
|
|
|
sub addSubtitlesToVideo(video, meta)
|
|
subtitles = sortSubtitles(meta.id, m.playbackInfo.MediaSources[0].MediaStreams)
|
|
safesubs = subtitles["all"]
|
|
subtitleTracks = []
|
|
|
|
if m.global.session.user.settings["playback.subs.onlytext"] = true
|
|
safesubs = subtitles["text"]
|
|
end if
|
|
|
|
for each subtitle in safesubs
|
|
subtitleTracks.push(subtitle.track)
|
|
end for
|
|
|
|
video.content.SubtitleTracks = subtitleTracks
|
|
video.fullSubtitleData = safesubs
|
|
end sub
|
|
|
|
|
|
'
|
|
' Extract array of Transcode Reasons from the content URL
|
|
' @returns Array of Strings
|
|
function getTranscodeReasons(url as string) as object
|
|
|
|
regex = CreateObject("roRegex", "&TranscodeReasons=([^&]*)", "")
|
|
match = regex.Match(url)
|
|
|
|
if match.count() > 1
|
|
return match[1].Split(",")
|
|
end if
|
|
|
|
return []
|
|
end function
|
|
|
|
function directPlaySupported(meta as object) as boolean
|
|
devinfo = CreateObject("roDeviceInfo")
|
|
if isValid(meta.json.MediaSources[0]) and meta.json.MediaSources[0].SupportsDirectPlay = false
|
|
return false
|
|
end if
|
|
|
|
if meta.json.MediaStreams[0] = invalid
|
|
return false
|
|
end if
|
|
|
|
streamInfo = { Codec: meta.json.MediaStreams[0].codec }
|
|
if isValid(meta.json.MediaStreams[0].Profile) and meta.json.MediaStreams[0].Profile.len() > 0
|
|
streamInfo.Profile = LCase(meta.json.MediaStreams[0].Profile)
|
|
end if
|
|
if isValid(meta.json.MediaSources[0].container) and meta.json.MediaSources[0].container.len() > 0
|
|
'CanDecodeVideo() requires the .container to be format: “mp4”, “hls”, “mkv”, “ism”, “dash”, “ts” if its to direct stream
|
|
if meta.json.MediaSources[0].container = "mov"
|
|
streamInfo.Container = "mp4"
|
|
else
|
|
streamInfo.Container = meta.json.MediaSources[0].container
|
|
end if
|
|
end if
|
|
|
|
decodeResult = devinfo.CanDecodeVideo(streamInfo)
|
|
return decodeResult <> invalid and decodeResult.result
|
|
|
|
end function
|
|
|
|
function getContainerType(meta as object) as string
|
|
' Determine the file type of the video file source
|
|
if meta.json.mediaSources = invalid then return ""
|
|
|
|
container = meta.json.mediaSources[0].container
|
|
if container = invalid
|
|
container = ""
|
|
else if container = "m4v" or container = "mov"
|
|
container = "mp4"
|
|
end if
|
|
|
|
return container
|
|
end function
|
|
|
|
' Add next episodes to the playback queue
|
|
sub addNextEpisodesToQueue(showID)
|
|
' Don't queue next episodes if we already have a playback queue
|
|
maxQueueCount = 1
|
|
|
|
if m.top.isIntro
|
|
maxQueueCount = 2
|
|
end if
|
|
|
|
if m.global.queueManager.callFunc("getCount") > maxQueueCount then return
|
|
|
|
videoID = m.top.itemId
|
|
|
|
' If first item is an intro video, use the next item in the queue
|
|
if m.top.isIntro
|
|
currentVideo = m.global.queueManager.callFunc("getItemByIndex", 1)
|
|
|
|
if isValid(currentVideo) and isValid(currentVideo.id)
|
|
videoID = currentVideo.id
|
|
|
|
' Override showID value since it's for the intro video
|
|
meta = ItemMetaData(videoID)
|
|
if isValid(meta)
|
|
showID = meta.showID
|
|
end if
|
|
end if
|
|
end if
|
|
|
|
url = Substitute("Shows/{0}/Episodes", showID)
|
|
urlParams = { "UserId": m.global.session.user.id }
|
|
urlParams.Append({ "StartItemId": videoID })
|
|
urlParams.Append({ "Limit": 50 })
|
|
resp = APIRequest(url, urlParams)
|
|
data = getJson(resp)
|
|
|
|
if isValid(data) and data.Items.Count() > 1
|
|
for i = 1 to data.Items.Count() - 1
|
|
m.global.queueManager.callFunc("push", data.Items[i])
|
|
end for
|
|
end if
|
|
end sub
|
|
|
|
'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
|
|
function sortSubtitles(id as string, MediaStreams)
|
|
tracks = { "forced": [], "default": [], "normal": [], "text": [] }
|
|
'Too many args for using substitute
|
|
prefered_lang = m.global.session.user.configuration.SubtitleLanguagePreference
|
|
for each stream in MediaStreams
|
|
if stream.type = "Subtitle"
|
|
|
|
url = ""
|
|
if isValid(stream.DeliveryUrl)
|
|
url = buildURL(stream.DeliveryUrl)
|
|
end if
|
|
|
|
stream = {
|
|
"Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
|
|
"IsTextSubtitleStream": stream.IsTextSubtitleStream,
|
|
"Index": stream.index,
|
|
"IsDefault": stream.IsDefault,
|
|
"IsForced": stream.IsForced,
|
|
"IsExternal": stream.IsExternal,
|
|
"IsEncoded": stream.DeliveryMethod = "Encode"
|
|
}
|
|
|
|
if stream.isForced
|
|
trackType = "forced"
|
|
else if stream.IsDefault
|
|
trackType = "default"
|
|
else
|
|
trackType = "normal"
|
|
end if
|
|
|
|
if prefered_lang <> "" and prefered_lang = stream.Track.Language
|
|
tracks[trackType].unshift(stream)
|
|
|
|
if stream.IsTextSubtitleStream
|
|
tracks["text"].unshift(stream)
|
|
end if
|
|
else
|
|
tracks[trackType].push(stream)
|
|
|
|
if stream.IsTextSubtitleStream
|
|
tracks["text"].push(stream)
|
|
end if
|
|
end if
|
|
end if
|
|
end for
|
|
|
|
tracks["default"].append(tracks["normal"])
|
|
tracks["forced"].append(tracks["default"])
|
|
|
|
return { "all": tracks["forced"], "text": tracks["text"] }
|
|
end function
|
|
|
|
function FindPreferredAudioStream(streams as dynamic) as integer
|
|
preferredLanguage = m.user.Configuration.AudioLanguagePreference
|
|
playDefault = m.user.Configuration.PlayDefaultAudioTrack
|
|
|
|
if playDefault <> invalid and playDefault = true
|
|
return 1
|
|
end if
|
|
|
|
' Do we already have the MediaStreams or not?
|
|
if streams = invalid
|
|
url = Substitute("Users/{0}/Items/{1}", m.user.id, m.top.itemId)
|
|
resp = APIRequest(url)
|
|
jsonResponse = getJson(resp)
|
|
|
|
if jsonResponse = invalid or jsonResponse.MediaStreams = invalid then return 1
|
|
|
|
streams = jsonResponse.MediaStreams
|
|
end if
|
|
|
|
if preferredLanguage <> invalid
|
|
for i = 0 to streams.Count() - 1
|
|
if LCase(streams[i].Type) = "audio"
|
|
if streams[i].Language <> invalid and LCase(streams[i].Language) = LCase(preferredLanguage)
|
|
return i
|
|
end if
|
|
end if
|
|
end for
|
|
end if
|
|
|
|
return 1
|
|
end function
|