feat(plugins): VideoCore bindings

This commit is contained in:
5rahim
2025-12-13 17:27:51 +01:00
parent 19f619d486
commit ed417a59a3
41 changed files with 2211 additions and 207 deletions

View File

@@ -36421,7 +36421,7 @@
"typescriptType": "string",
"declaredValues": [
"\"native-player\"",
"\"video-core\"",
"\"videocore\"",
"\"nakama\"",
"\"plugin\"",
"\"playlist\""
@@ -40077,10 +40077,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -64951,9 +64951,7 @@
"typescriptType": "string",
"required": true,
"public": true,
"comments": [
" PeerID of the sender"
]
"comments": []
},
{
"name": "Username",
@@ -64962,9 +64960,7 @@
"typescriptType": "string",
"required": true,
"public": true,
"comments": [
" Display name of sender"
]
"comments": []
},
{
"name": "Message",
@@ -64973,9 +64969,7 @@
"typescriptType": "string",
"required": true,
"public": true,
"comments": [
" Chat message content"
]
"comments": []
},
{
"name": "Timestamp",
@@ -64985,9 +64979,7 @@
"usedStructName": "time.Time",
"required": false,
"public": true,
"comments": [
" When the message was sent"
]
"comments": []
},
{
"name": "MessageId",
@@ -64997,7 +64989,7 @@
"required": true,
"public": true,
"comments": [
" Unique message ID"
" Unique id"
]
}
],
@@ -68200,10 +68192,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -68756,10 +68748,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -68808,10 +68800,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -69190,10 +69182,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -69301,10 +69293,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -69547,10 +69539,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -69676,10 +69668,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -69752,10 +69744,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -70658,10 +70650,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": [
@@ -73507,10 +73499,10 @@
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
@@ -73601,10 +73593,10 @@
{
"name": "Scheduler",
"jsonName": "Scheduler",
"goType": "goja_util.Scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "goja_util.Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": true,
"comments": []
@@ -73666,6 +73658,161 @@
],
"comments": []
},
{
"filepath": "../internal/plugin/videocore.go",
"filename": "videocore.go",
"name": "VideoCore",
"formattedName": "VideoCore",
"package": "plugin",
"fields": [
{
"name": "ctx",
"jsonName": "ctx",
"goType": "AppContextImpl",
"typescriptType": "AppContextImpl",
"usedTypescriptType": "AppContextImpl",
"usedStructName": "plugin.AppContextImpl",
"required": false,
"public": false,
"comments": []
},
{
"name": "vm",
"jsonName": "vm",
"goType": "goja.Runtime",
"typescriptType": "Runtime",
"usedTypescriptType": "Runtime",
"usedStructName": "goja.Runtime",
"required": false,
"public": false,
"comments": []
},
{
"name": "logger",
"jsonName": "logger",
"goType": "zerolog.Logger",
"typescriptType": "Logger",
"usedTypescriptType": "Logger",
"usedStructName": "zerolog.Logger",
"required": false,
"public": false,
"comments": []
},
{
"name": "ext",
"jsonName": "ext",
"goType": "extension.Extension",
"typescriptType": "Extension_Extension",
"usedTypescriptType": "Extension_Extension",
"usedStructName": "extension.Extension",
"required": false,
"public": false,
"comments": []
},
{
"name": "scheduler",
"jsonName": "scheduler",
"goType": "gojautil.Scheduler",
"typescriptType": "Scheduler",
"usedTypescriptType": "Scheduler",
"usedStructName": "gojautil.Scheduler",
"required": false,
"public": false,
"comments": []
},
{
"name": "listeners",
"jsonName": "listeners",
"goType": "",
"typescriptType": "any",
"required": false,
"public": false,
"comments": []
},
{
"name": "videoCoreSubscriber",
"jsonName": "videoCoreSubscriber",
"goType": "videocore.Subscriber",
"typescriptType": "VideoCore_Subscriber",
"usedTypescriptType": "VideoCore_Subscriber",
"usedStructName": "videocore.Subscriber",
"required": false,
"public": false,
"comments": []
},
{
"name": "unsubscribeOnce",
"jsonName": "unsubscribeOnce",
"goType": "sync.Once",
"typescriptType": "Once",
"usedTypescriptType": "Once",
"usedStructName": "sync.Once",
"required": false,
"public": false,
"comments": []
}
],
"comments": []
},
{
"filepath": "../internal/plugin/videocore.go",
"filename": "videocore.go",
"name": "VideoCoreEventListener",
"formattedName": "VideoCoreEventListener",
"package": "plugin",
"fields": [
{
"name": "eventId",
"jsonName": "eventId",
"goType": "string",
"typescriptType": "string",
"required": true,
"public": false,
"comments": []
},
{
"name": "listenerCh",
"jsonName": "listenerCh",
"goType": "",
"typescriptType": "any",
"required": true,
"public": false,
"comments": []
},
{
"name": "closed",
"jsonName": "closed",
"goType": "atomic.Bool",
"typescriptType": "Bool",
"usedTypescriptType": "Bool",
"usedStructName": "atomic.Bool",
"required": false,
"public": false,
"comments": []
},
{
"name": "closeOnce",
"jsonName": "closeOnce",
"goType": "sync.Once",
"typescriptType": "Once",
"usedTypescriptType": "Once",
"usedStructName": "sync.Once",
"required": false,
"public": false,
"comments": []
}
],
"comments": []
},
{
"filepath": "../internal/plugin/videocore.go",
"filename": "videocore.go",
"name": "VideoCoreEvent",
"formattedName": "VideoCoreEvent",
"package": "plugin",
"fields": [],
"comments": []
},
{
"filepath": "../internal/report/report.go",
"filename": "report.go",
@@ -82873,7 +83020,7 @@
"filename": "scheduler.go",
"name": "Job",
"formattedName": "Job",
"package": "goja_util",
"package": "gojautil",
"fields": [
{
"name": "fn",
@@ -82914,7 +83061,7 @@
"filename": "scheduler.go",
"name": "Scheduler",
"formattedName": "Scheduler",
"package": "goja_util",
"package": "gojautil",
"fields": [
{
"name": "jobQueue",
@@ -82964,7 +83111,7 @@
"goType": "Job",
"typescriptType": "Job",
"usedTypescriptType": "Job",
"usedStructName": "goja_util.Job",
"usedStructName": "gojautil.Job",
"required": false,
"public": false,
"comments": []
@@ -84339,7 +84486,9 @@
"\"video-error\"",
"\"video-terminated\"",
"\"video-playback-state\"",
"\"subtitle-file-uploaded\""
"\"subtitle-file-uploaded\"",
"\"video-playlist\"",
"\"video-text-tracks\""
]
},
"comments": []
@@ -84469,6 +84618,54 @@
" VideoSubtitleTrack is an external subtitle track."
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
"name": "VideoTextTrack",
"formattedName": "VideoCore_VideoTextTrack",
"package": "videocore",
"fields": [
{
"name": "Number",
"jsonName": "number",
"goType": "int",
"typescriptType": "number",
"required": true,
"public": true,
"comments": []
},
{
"name": "Type",
"jsonName": "type",
"goType": "string",
"typescriptType": "string",
"required": true,
"public": true,
"comments": [
" \"subtitles\" | \"captions\""
]
},
{
"name": "Label",
"jsonName": "label",
"goType": "string",
"typescriptType": "string",
"required": true,
"public": true,
"comments": []
},
{
"name": "Language",
"jsonName": "language",
"goType": "string",
"typescriptType": "string",
"required": true,
"public": true,
"comments": []
}
],
"comments": []
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
@@ -84801,6 +84998,84 @@
" It is filled by the client, passed to the player and sent to the server during playback."
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
"name": "VideoPlaylistState",
"formattedName": "VideoCore_VideoPlaylistState",
"package": "videocore",
"fields": [
{
"name": "Type",
"jsonName": "type",
"goType": "PlaybackType",
"typescriptType": "VideoCore_PlaybackType",
"usedTypescriptType": "VideoCore_PlaybackType",
"usedStructName": "videocore.PlaybackType",
"required": true,
"public": true,
"comments": []
},
{
"name": "Episodes",
"jsonName": "episodes",
"goType": "[]anime.Episode",
"typescriptType": "Array\u003cAnime_Episode\u003e",
"usedTypescriptType": "Anime_Episode",
"usedStructName": "anime.Episode",
"required": false,
"public": true,
"comments": []
},
{
"name": "PreviousEpisode",
"jsonName": "previousEpisode",
"goType": "anime.Episode",
"typescriptType": "Anime_Episode",
"usedTypescriptType": "Anime_Episode",
"usedStructName": "anime.Episode",
"required": false,
"public": true,
"comments": []
},
{
"name": "NextEpisode",
"jsonName": "nextEpisode",
"goType": "anime.Episode",
"typescriptType": "Anime_Episode",
"usedTypescriptType": "Anime_Episode",
"usedStructName": "anime.Episode",
"required": false,
"public": true,
"comments": []
},
{
"name": "CurrentEpisode",
"jsonName": "currentEpisode",
"goType": "anime.Episode",
"typescriptType": "Anime_Episode",
"usedTypescriptType": "Anime_Episode",
"usedStructName": "anime.Episode",
"required": false,
"public": true,
"comments": []
},
{
"name": "AnimeEntry",
"jsonName": "animeEntry",
"goType": "anime.Entry",
"typescriptType": "Anime_Entry",
"usedTypescriptType": "Anime_Entry",
"usedStructName": "anime.Entry",
"required": false,
"public": true,
"comments": []
}
],
"comments": [
" VideoPlaylistState holds the state for the video player's playlist and playback."
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
@@ -85574,6 +85849,54 @@
"videocore.BaseVideoEvent"
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
"name": "VideoPlaylistEvent",
"formattedName": "VideoCore_VideoPlaylistEvent",
"package": "videocore",
"fields": [
{
"name": "Playlist",
"jsonName": "playlist",
"goType": "VideoPlaylistState",
"typescriptType": "VideoCore_VideoPlaylistState",
"usedTypescriptType": "VideoCore_VideoPlaylistState",
"usedStructName": "videocore.VideoPlaylistState",
"required": false,
"public": true,
"comments": []
}
],
"comments": [],
"embeddedStructNames": [
"videocore.BaseVideoEvent"
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
"name": "VideoTextTracksEvent",
"formattedName": "VideoCore_VideoTextTracksEvent",
"package": "videocore",
"fields": [
{
"name": "TextTracks",
"jsonName": "textTracks",
"goType": "[]VideoTextTrack",
"typescriptType": "Array\u003cVideoCore_VideoTextTrack\u003e",
"usedTypescriptType": "VideoCore_VideoTextTrack",
"usedStructName": "videocore.VideoTextTrack",
"required": false,
"public": true,
"comments": []
}
],
"comments": [],
"embeddedStructNames": [
"videocore.BaseVideoEvent"
]
},
{
"filepath": "../internal/videocore/types.go",
"filename": "types.go",
@@ -85600,13 +85923,17 @@
"\"terminate\"",
"\"start-onlinestream-watch-party\"",
"\"get-status\"",
"\"show-message\"",
"\"play-episode\"",
"\"get-text-tracks\"",
"\"get-fullscreen\"",
"\"get-pip\"",
"\"get-anime-4k\"",
"\"get-subtitle-track\"",
"\"get-audio-track\"",
"\"get-media-caption-track\"",
"\"get-playback-state\""
"\"get-playback-state\"",
"\"get-playlist\""
]
},
"comments": []
@@ -85792,6 +86119,28 @@
"required": false,
"public": false,
"comments": []
},
{
"name": "settingsMu",
"jsonName": "settingsMu",
"goType": "sync.RWMutex",
"typescriptType": "RWMutex",
"usedTypescriptType": "RWMutex",
"usedStructName": "sync.RWMutex",
"required": false,
"public": false,
"comments": []
},
{
"name": "settings",
"jsonName": "settings",
"goType": "models.Settings",
"typescriptType": "Models_Settings",
"usedTypescriptType": "Models_Settings",
"usedStructName": "models.Settings",
"required": false,
"public": false,
"comments": []
}
],
"comments": []
@@ -85803,6 +86152,15 @@
"formattedName": "VideoCore_Subscriber",
"package": "videocore",
"fields": [
{
"name": "id",
"jsonName": "id",
"goType": "string",
"typescriptType": "string",
"required": true,
"public": false,
"comments": []
},
{
"name": "eventCh",
"jsonName": "eventCh",

View File

@@ -2,6 +2,7 @@ package metadata_provider
import (
"seanime/internal/database/db"
"seanime/internal/extension"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
@@ -13,8 +14,9 @@ func GetMockProvider(t *testing.T, db *db.Database) Provider {
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
return NewProvider(&NewProviderImplOptions{
Logger: util.NewLogger(),
FileCacher: filecacher,
Database: db,
Logger: util.NewLogger(),
FileCacher: filecacher,
Database: db,
ExtensionBankRef: util.NewRef(extension.NewUnifiedBank()),
})
}

View File

@@ -412,6 +412,10 @@ func (a *App) InitOrRefreshModules() {
}
}
if a.VideoCore != nil {
a.VideoCore.SetSettings(settings)
}
if settings.MediaPlayer != nil {
a.MediaPlayer.VLC = &vlc.VLC{
Host: settings.MediaPlayer.Host,

View File

@@ -4,7 +4,7 @@ type WebsocketClientEventType string
const (
NativePlayerEventType WebsocketClientEventType = "native-player"
VideoCoreEventType WebsocketClientEventType = "video-core"
VideoCoreEventType WebsocketClientEventType = "videocore"
NakamaEventType WebsocketClientEventType = "nakama"
PluginEvent WebsocketClientEventType = "plugin"
PlaylistEvent WebsocketClientEventType = "playlist"

View File

@@ -8,7 +8,7 @@ import (
"seanime/internal/extension"
"seanime/internal/goja/goja_runtime"
"seanime/internal/plugin"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"time"
"github.com/dop251/goja"
@@ -24,7 +24,7 @@ type gojaProviderBase struct {
source string
runtimeManager *goja_runtime.Manager
store *plugin.Store[string, any]
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
wsEventManager events.WSEventManagerInterface
}
@@ -64,7 +64,7 @@ func initializeProviderBase(
source: source,
runtimeManager: runtimeManager,
store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading)
scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading)
scheduler: gojautil.NewScheduler(), // Create a scheduler (must be stopped when unloading)
wsEventManager: wsEventManager,
}
@@ -74,7 +74,7 @@ func initializeProviderBase(
providerBase.store.Bind(vm, providerBase.scheduler)
// Bind the shared bindings
ShareBinds(vm, logger, ext, wsEventManager)
goja_util.BindMutable(vm)
gojautil.BindMutable(vm)
BindUserConfig(vm, ext, logger)
return vm
}

View File

@@ -12,7 +12,7 @@ import (
"seanime/internal/plugin"
plugin_ui "seanime/internal/plugin/ui"
"seanime/internal/util"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"slices"
"strings"
"time"
@@ -55,7 +55,7 @@ type GojaPlugin struct {
store *plugin.Store[string, any]
storage *plugin.Storage
ui *plugin_ui.UI
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
loader *goja.Runtime
unbindHookFuncs []func()
interrupted bool
@@ -132,7 +132,7 @@ func NewGojaPlugin(
logger: logger,
runtimeManager: runtimeManager,
store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading)
scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading)
scheduler: gojautil.NewScheduler(), // Create a scheduler (must be stopped when unloading)
ui: nil, // To be initialized
loader: goja.New(), // To be initialized
unbindHookFuncs: []func(){},
@@ -235,9 +235,9 @@ func (p *GojaPlugin) BindPluginAPIs(vm *goja.Runtime, logger *zerolog.Logger) {
// Bind the store
p.store.Bind(vm, p.scheduler)
// Bind mutable bindings
goja_util.BindMutable(vm)
gojautil.BindMutable(vm)
// Bind await bindings
goja_util.BindAwait(vm)
gojautil.BindAwait(vm)
// Bind console bindings
_ = goja_bindings.BindConsoleWithWS(p.ext, vm, logger, p.wsEventManager)

View File

@@ -109,8 +109,8 @@ func InitTestPlugin(t testing.TB, opts TestPluginOptions) (*GojaPlugin, *zerolog
PlaybackManager: &playbackmanager.PlaybackManager{},
})
plugin, _, err := NewGojaPlugin(ext, opts.Language, logger, manager, wsEventManager)
return plugin, logger, manager, anilistPlatform, wsEventManager, err
p, _, err := NewGojaPlugin(ext, opts.Language, logger, manager, wsEventManager)
return p, logger, manager, anilistPlatform, wsEventManager, err
}
/////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -167,6 +167,11 @@ declare namespace $ui {
* Use a headless browser.
*/
chromeDP: ChromeDP
/**
* Video Core for controlling the built-in player
*/
videoCore: VideoCore
}
interface State<T> {
@@ -218,6 +223,7 @@ declare namespace $ui {
contentType: string
/** Response content length */
contentLength: number
/** Get response text */
text(): string
@@ -1823,3 +1829,653 @@ declare namespace $app {
*/
function invalidateClientQuery(queryKeys: string[]): void
}
declare namespace $ui {
// Video Core Types
type PlayerType = "native" | "web"
type PlaybackType = "localfile" | "torrent" | "debrid" | "nakama" | "onlinestream"
type VideoEventType =
| "video-loaded"
| "video-loaded-metadata"
| "video-can-play"
| "video-paused"
| "video-resumed"
| "video-status"
| "video-completed"
| "video-fullscreen"
| "video-pip"
| "video-subtitle-track"
| "video-media-caption-track"
| "video-anime-4k"
| "video-audio-track"
| "video-ended"
| "video-seeked"
| "video-error"
| "video-terminated"
| "video-playback-state"
| "subtitle-file-uploaded"
| "video-playlist"
interface BaseVideoEvent {
playerType: PlayerType
playbackType: PlaybackType
playbackId: string
clientId: string
}
interface VideoLoadedEvent extends BaseVideoEvent {
clientId: string
state: PlaybackState
}
interface VideoPlaybackStateEvent extends BaseVideoEvent {
clientId: string
state: PlaybackState
}
interface VideoPausedEvent extends BaseVideoEvent {
currentTime: number
duration: number
}
interface VideoResumedEvent extends BaseVideoEvent {
currentTime: number
duration: number
}
interface VideoEndedEvent extends BaseVideoEvent {
autoNext: boolean
}
interface VideoErrorEvent extends BaseVideoEvent {
error: string
}
interface VideoSeekedEvent extends BaseVideoEvent {
currentTime: number
duration: number
paused: boolean
}
interface VideoStatusEvent extends BaseVideoEvent {
currentTime: number
duration: number
paused: boolean
}
interface VideoLoadedMetadataEvent extends BaseVideoEvent {
currentTime: number
duration: number
paused: boolean
}
interface VideoCanPlayEvent extends BaseVideoEvent {
currentTime: number
duration: number
paused: boolean
}
interface SubtitleFileUploadedEvent extends BaseVideoEvent {
filename: string
content: string
}
interface VideoTerminatedEvent extends BaseVideoEvent {
}
interface VideoCompletedEvent extends BaseVideoEvent {
currentTime: number
duration: number
}
interface VideoAudioTrackEvent extends BaseVideoEvent {
trackNumber: number
isHLS: boolean
}
interface VideoSubtitleTrackEvent extends BaseVideoEvent {
trackNumber: number
kind: "file" | "event"
}
interface VideoMediaCaptionTrackEvent extends BaseVideoEvent {
trackIndex: number
}
interface VideoFullscreenEvent extends BaseVideoEvent {
fullscreen: boolean
}
interface VideoPipEvent extends BaseVideoEvent {
pip: boolean
}
interface VideoAnime4KEvent extends BaseVideoEvent {
option: string
}
interface VideoPlaylistEvent extends BaseVideoEvent {
playlist: VideoPlaylistState | null
}
interface VideoTextTracksvent extends BaseVideoEvent {
textTracks: VideoTextTrack[]
}
type VideoEvent =
| VideoLoadedEvent
| VideoPlaybackStateEvent
| VideoPausedEvent
| VideoResumedEvent
| VideoEndedEvent
| VideoErrorEvent
| VideoSeekedEvent
| VideoStatusEvent
| VideoLoadedMetadataEvent
| VideoCanPlayEvent
| SubtitleFileUploadedEvent
| VideoTerminatedEvent
| VideoCompletedEvent
| VideoAudioTrackEvent
| VideoSubtitleTrackEvent
| VideoMediaCaptionTrackEvent
| VideoFullscreenEvent
| VideoPipEvent
| VideoAnime4KEvent
| VideoPlaylistEvent
interface VideoSubtitleTrack {
index: number
src?: string
content?: string
label: string
language: string
type?: "srt" | "vtt" | "ass" | "ssa"
default?: boolean
useLibassRenderer?: boolean
}
interface VideoTextTrack {
number: number,
type: "subtitles" | "captions",
label: string,
language: string,
}
interface VideoSource {
index: number
resolution: string
url?: string
label?: string
moreInfo?: string
}
interface VideoInitialState {
currentTime?: number
paused?: boolean
}
interface OnlinestreamParams {
mediaId: number
episodeNumber: number
provider: string
server: string
quality: string
dubbed: boolean
}
export type MkvTrackType = "video" | "audio" | "subtitle" | "logo" | "buttons" | "complex" | "unknown"
export type MkvAttachmentType = "font" | "subtitle" | "other"
export interface MkvTrackInfo {
number: number
uid: number
type: MkvTrackType
codecID: string
name?: string
language?: string
languageIETF?: string
default: boolean
forced: boolean
enabled: boolean
codecPrivate?: string
video?: any
audio?: any
contentEncodings?: any
defaultDuration?: number
}
export interface MkvChapterInfo {
uid: number
start: number
end?: number
text?: string
languages?: string[]
languagesIETF?: string[]
editionUID?: number
}
export interface MkvAttachmentInfo {
uid: number
filename: string
mimetype: string
size: number
description?: string
type?: MkvAttachmentType
data?: Uint8Array
isCompressed?: boolean
}
export interface MkvMetadata {
title?: string
duration: number
timecodeScale: number
muxingApp?: string
writingApp?: string
tracks: MkvTrackInfo[]
videoTracks: MkvTrackInfo[]
audioTracks: MkvTrackInfo[]
subtitleTracks: MkvTrackInfo[]
chapters: MkvChapterInfo[]
attachments: MkvAttachmentInfo[]
mimeCodec?: string
error?: Error
}
interface VideoPlaybackInfo {
id: string
playbackType: PlaybackType
streamUrl: string
mkvMetadata?: MkvMetadata
localFile?: $app.Anime_LocalFile
onlinestreamParams?: OnlinestreamParams
subtitleTracks: VideoSubtitleTrack[]
videoSources: VideoSource[]
selectedVideoSource?: number
playlistExternalEpisodeNumbers: number[]
disableRestoreFromContinuity?: boolean
initialState?: VideoInitialState
media?: $app.AL_BaseAnime
episode?: $app.Anime_Episode
streamType: "native" | "hls" | "unknown"
isNakamaWatchParty?: boolean
}
interface VideoPlaylistState {
type: PlaybackType
episodes: $app.Anime_Episode[]
previousEpisode?: $app.Anime_Episode
nextEpisode?: $app.Anime_Episode
currentEpisode: $app.Anime_Episode
animeEntry?: $app.Anime_Entry
}
interface PlaybackStatus {
id: string
clientId: string
paused: boolean
currentTime: number
duration: number
}
interface PlaybackState {
clientId: string
playerType: PlayerType
playbackInfo: VideoPlaybackInfo
}
interface VideoCore {
/**
* Adds an event listener for video-loaded events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-loaded", callback: (event: VideoLoadedEvent) => void): void
/**
* Adds an event listener for video-playback-state events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-playback-state", callback: (event: VideoPlaybackStateEvent) => void): void
/**
* Adds an event listener for video-paused events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-paused", callback: (event: VideoPausedEvent) => void): void
/**
* Adds an event listener for video-resumed events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-resumed", callback: (event: VideoResumedEvent) => void): void
/**
* Adds an event listener for video-ended events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-ended", callback: (event: VideoEndedEvent) => void): void
/**
* Adds an event listener for video-error events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-error", callback: (event: VideoErrorEvent) => void): void
/**
* Adds an event listener for video-seeked events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-seeked", callback: (event: VideoSeekedEvent) => void): void
/**
* Adds an event listener for video-status events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-status", callback: (event: VideoStatusEvent) => void): void
/**
* Adds an event listener for video-loaded-metadata events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-loaded-metadata", callback: (event: VideoLoadedMetadataEvent) => void): void
/**
* Adds an event listener for video-can-play events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-can-play", callback: (event: VideoCanPlayEvent) => void): void
/**
* Adds an event listener for subtitle-file-uploaded events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "subtitle-file-uploaded", callback: (event: SubtitleFileUploadedEvent) => void): void
/**
* Adds an event listener for video-terminated events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-terminated", callback: (event: VideoTerminatedEvent) => void): void
/**
* Adds an event listener for video-completed events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-completed", callback: (event: VideoCompletedEvent) => void): void
/**
* Adds an event listener for video-audio-track events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-audio-track", callback: (event: VideoAudioTrackEvent) => void): void
/**
* Adds an event listener for video-subtitle-track events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-subtitle-track", callback: (event: VideoSubtitleTrackEvent) => void): void
/**
* Adds an event listener for video-media-caption-track events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-media-caption-track", callback: (event: VideoMediaCaptionTrackEvent) => void): void
/**
* Adds an event listener for video-fullscreen events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-fullscreen", callback: (event: VideoFullscreenEvent) => void): void
/**
* Adds an event listener for video-pip events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-pip", callback: (event: VideoPipEvent) => void): void
/**
* Adds an event listener for video-anime-4k events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-anime-4k", callback: (event: VideoAnime4KEvent) => void): void
/**
* Adds an event listener for video-playlist events
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: "video-playlist", callback: (event: VideoPlaylistEvent) => void): void
/**
* Adds an event listener for any video event (fallback)
* @param eventType - The event type to listen for
* @param callback - The callback function to execute when the event is triggered
*/
addEventListener(eventType: VideoEventType, callback: (event: VideoEvent) => void): void
/**
* Removes an event listener for the specified event type
* @param eventType - The event type to stop listening for
*/
removeEventListener(eventType: VideoEventType): void
// Playback control methods
/**
* Pauses the video playback
*/
pause(): void
/**
* Resumes the video playback
*/
resume(): void
/**
* Seeks forward or backward by the specified number of seconds
* @param seconds - Number of seconds to seek (positive for forward, negative for backward)
*/
seek(seconds: number): void
/**
* Seeks to an absolute position in the video
* @param seconds - The absolute position in seconds
*/
seekTo(seconds: number): void
/**
* Terminates the current video playback
*/
terminate(): void
/**
* Plays the specified episode
* @param which - "next", "previous", or an episode ID
*/
playEpisode(which: string): void
// UI control methods
/**
* Sets the fullscreen state of the video player
* @param fullscreen - Whether to enable fullscreen
*/
setFullscreen(fullscreen: boolean): void
/**
* Sets the picture-in-picture state of the video player
* @param pip - Whether to enable picture-in-picture
*/
setPip(pip: boolean): void
/**
* Shows a message in the video player
* @param message - The message to display
*/
showMessage(message: string): void
// Track control methods
/**
* Sets the active subtitle track
* @param trackNumber - The track number to activate
*/
setSubtitleTrack(trackNumber: number): void
/**
* Adds a subtitle track to the video player.
* @important Use addExternalSubtitleTrack instead.
* @param track - The subtitle track information
*/
addSubtitleTrack(track: any): void
/**
* Adds an external subtitle track to the video player
* @param track - The external subtitle track information
*/
addExternalSubtitleTrack(track: Omit<VideoSubtitleTrack, "index" | "useLibassRenderer">): void
/**
* Sets the active media caption track
* @param trackIndex - The track index to activate
*/
setMediaCaptionTrack(trackIndex: number): void
/**
* Adds a media caption track to the video player
* @important Use addExternalSubtitleTrack instead.
* @param track - The media caption track information
*/
addMediaCaptionTrack(track: any): void
/**
* Sets the active audio track
* @param trackNumber - The track number to activate
*/
setAudioTrack(trackNumber: number): void
// State request methods
/**
* Requests the current fullscreen state from the player
*/
sendGetFullscreen(): void
/**
* Requests the current picture-in-picture state from the player
*/
sendGetPip(): void
/**
* Requests the current Anime4K state from the player
*/
sendGetAnime4K(): void
/**
* Requests the current subtitle track from the player
*/
sendGetSubtitleTrack(): void
/**
* Requests the current audio track from the player
*/
sendGetAudioTrack(): void
/**
* Requests the current media caption track from the player
*/
sendGetMediaCaptionTrack(): void
/**
* Requests the current playback state from the player
*/
sendGetPlaybackState(): void
// Async getters
/**
* Gets the current text tracks
* @returns A promise that resolves to the text tracks or undefined
*/
getTextTracks(): Promise<VideoTextTrack[] | undefined>
/**
* Gets the current playlist state
* @returns A promise that resolves to the playlist state or undefined
*/
getPlaylist(): Promise<VideoPlaylistState | undefined>
/**
* Pulls the current playback status from the player
* @returns A promise that resolves to the video status event
*/
pullStatus(): Promise<VideoStatusEvent | undefined>
// Sync getters
/**
* Gets the current playback status
* @returns The playback status or undefined
*/
getPlaybackStatus(): PlaybackStatus | undefined
/**
* Gets the current playback state
* @returns The playback state or undefined
*/
getPlaybackState(): PlaybackState | undefined
/**
* Gets the current playback information
* @returns The playback information or undefined
*/
getCurrentPlaybackInfo(): VideoPlaybackInfo | undefined
/**
* Gets the current media being played
* @returns The media information or undefined
*/
getCurrentMedia(): $app.AL_BaseAnime | undefined
/**
* Gets the current client ID
* @returns The client ID or empty string
*/
getCurrentClientId(): string
/**
* Gets the current player type
* @returns The player type or empty string
*/
getCurrentPlayerType(): PlayerType | ""
/**
* Gets the current playback type
* @returns The playback type or empty string
*/
getCurrentPlaybackType(): PlaybackType | ""
}
}

View File

@@ -3,7 +3,7 @@ package goja_bindings
import (
"context"
"fmt"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"strings"
"sync"
"time"
@@ -86,7 +86,7 @@ func BindChromeDP(vm *goja.Runtime) *ChromeDP {
}
// BindChromeDPWithScheduler binds the ChromeDP utilities to the VM
func BindChromeDPWithScheduler(vm *goja.Runtime, scheduler *goja_util.Scheduler) *ChromeDP {
func BindChromeDPWithScheduler(vm *goja.Runtime, scheduler *gojautil.Scheduler) *ChromeDP {
c := NewChromeDP(vm)
// Create ChromeDP object with methods

View File

@@ -297,6 +297,8 @@ func (wpm *WatchPartyManager) LeaveWatchParty() error {
PeerId: hostConn.PeerId,
})
wpm.currentSession = mo.None[*WatchPartySession]()
// Send websocket event to update the UI (nil indicates session left)
wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, nil)

View File

@@ -7,7 +7,7 @@ import (
"seanime/internal/goja/goja_bindings"
"seanime/internal/hook"
"seanime/internal/library/anime"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"github.com/dop251/goja"
"github.com/rs/zerolog"
@@ -18,10 +18,10 @@ type Anime struct {
vm *goja.Runtime
logger *zerolog.Logger
ext *extension.Extension
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
func (a *AppContextImpl) BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
m := &Anime{
ctx: a,
vm: vm,

View File

@@ -21,7 +21,7 @@ import (
"seanime/internal/torrentstream"
"seanime/internal/util"
"seanime/internal/util/filecache"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"seanime/internal/videocore"
"github.com/dop251/goja"
@@ -73,62 +73,65 @@ type AppContext interface {
BindApp(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
// BindStorage binds $storage to the Goja runtime
BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage
BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) *Storage
// BindAnilist binds $anilist to the Goja runtime
BindAnilist(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
// BindDatabase binds $database to the Goja runtime
BindDatabase(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
// BindSystem binds $system to the Goja runtime
BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindPlaybackToContextObj binds 'playback' to the UI context object
BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindVideoCoreToContextObj binds 'videoCore' to the UI context object
BindVideoCoreToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindCronToContextObj binds 'cron' to the UI context object
BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron
BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) *Cron
// BindDownloaderToContextObj binds 'downloader' to the UI context object
BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindMangaToContextObj binds 'manga' to the UI context object
BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindAnimeToContextObj binds 'anime' to the UI context object
BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindDiscordToContextObj binds 'discord' to the UI context object
BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindContinuityToContextObj binds 'continuity' to the UI context object
BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindTorrentClientToContextObj binds 'torrentClient' to the UI context object
BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object
BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindMediastreamToContextObj binds 'mediastream' to the UI context object
BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object
BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindFillerManagerToContextObj binds 'fillerManager' to the UI context object
BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object
BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindAutoScannerToContextObj binds 'autoScanner' to the UI context object
BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindFileCacherToContextObj binds 'fileCacher' to the UI context object
BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
// BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object
BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler)
DropPluginData(extId string)
}

View File

@@ -4,13 +4,13 @@ import (
"seanime/internal/continuity"
"seanime/internal/extension"
"seanime/internal/goja/goja_bindings"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"github.com/dop251/goja"
"github.com/rs/zerolog"
)
func (a *AppContextImpl) BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
continuityObj := vm.NewObject()

View File

@@ -13,7 +13,7 @@ import (
"errors"
"fmt"
"seanime/internal/extension"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"slices"
"strconv"
"strings"
@@ -33,7 +33,7 @@ type Cron struct {
jobs []*CronJob
interval time.Duration
mux sync.RWMutex
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
// New create a new Cron struct with default tick interval of 1 minute
@@ -41,7 +41,7 @@ type Cron struct {
//
// You can change the default tick interval with Cron.SetInterval().
// You can change the default timezone with Cron.SetTimezone().
func New(scheduler *goja_util.Scheduler) *Cron {
func New(scheduler *gojautil.Scheduler) *Cron {
return &Cron{
interval: 1 * time.Minute,
timezone: time.UTC,
@@ -51,7 +51,7 @@ func New(scheduler *goja_util.Scheduler) *Cron {
}
}
func (a *AppContextImpl) BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron {
func (a *AppContextImpl) BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) *Cron {
cron := New(scheduler)
cronObj := vm.NewObject()
_ = cronObj.Set("add", cron.Add)
@@ -262,7 +262,7 @@ type CronJob struct {
fn func()
schedule *Schedule
id string
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
// Id returns the cron job id.

View File

@@ -4,13 +4,13 @@ import (
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/extension"
"seanime/internal/goja/goja_bindings"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"github.com/dop251/goja"
"github.com/rs/zerolog"
)
func (a *AppContextImpl) BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
discordObj := vm.NewObject()
_ = discordObj.Set("setMangaActivity", func(opts discordrpc_presence.MangaActivity) goja.Value {

View File

@@ -9,7 +9,7 @@ import (
"os"
"path/filepath"
"seanime/internal/extension"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"sync"
"time"
@@ -55,7 +55,7 @@ type progressSubscriber struct {
LastSent time.Time
}
func (a *AppContextImpl) BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
downloadObj := vm.NewObject()
progressMap := sync.Map{}

View File

@@ -6,7 +6,7 @@ import (
"seanime/internal/extension"
"seanime/internal/goja/goja_bindings"
"seanime/internal/manga"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"github.com/dop251/goja"
"github.com/rs/zerolog"
@@ -17,10 +17,10 @@ type Manga struct {
vm *goja.Runtime
logger *zerolog.Logger
ext *extension.Extension
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
func (a *AppContextImpl) BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
m := &Manga{
ctx: a,
vm: vm,

View File

@@ -6,7 +6,7 @@ import (
"seanime/internal/goja/goja_bindings"
"seanime/internal/library/anime"
"seanime/internal/onlinestream"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"strconv"
"github.com/dop251/goja"
@@ -14,22 +14,22 @@ import (
)
// BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object
func (a *AppContextImpl) BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
}
// BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object
func (a *AppContextImpl) BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
}
// BindMediastreamToContextObj binds 'mediastream' to the UI context object
func (a *AppContextImpl) BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
}
// BindTorrentClientToContextObj binds 'torrentClient' to the UI context object
func (a *AppContextImpl) BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
torrentClientObj := vm.NewObject()
_ = torrentClientObj.Set("getTorrents", func() goja.Value {
@@ -221,7 +221,7 @@ func (a *AppContextImpl) BindTorrentClientToContextObj(vm *goja.Runtime, obj *go
}
// BindFillerManagerToContextObj binds 'fillerManager' to the UI context object
func (a *AppContextImpl) BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
fillerManagerObj := vm.NewObject()
_ = fillerManagerObj.Set("getFillerEpisodes", func(mediaId int) goja.Value {
@@ -285,7 +285,7 @@ func (a *AppContextImpl) BindFillerManagerToContextObj(vm *goja.Runtime, obj *go
}
// BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object
func (a *AppContextImpl) BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
autoDownloaderObj := vm.NewObject()
_ = autoDownloaderObj.Set("run", func() goja.Value {
@@ -300,7 +300,7 @@ func (a *AppContextImpl) BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *g
}
// BindAutoScannerToContextObj binds 'autoScanner' to the UI context object
func (a *AppContextImpl) BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
autoScannerObj := vm.NewObject()
_ = autoScannerObj.Set("notify", func() goja.Value {
@@ -316,12 +316,12 @@ func (a *AppContextImpl) BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja
}
// BindFileCacherToContextObj binds 'fileCacher' to the UI context object
func (a *AppContextImpl) BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
}
// BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object
func (a *AppContextImpl) BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
externalPlayerLinkObj := vm.NewObject()
_ = externalPlayerLinkObj.Set("open", func(url string, mediaId int, episodeNumber int, mediaTitle string) goja.Value {

View File

@@ -8,7 +8,7 @@ import (
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/mediaplayers/mpvipc"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"github.com/dop251/goja"
"github.com/google/uuid"
@@ -20,7 +20,7 @@ type Playback struct {
vm *goja.Runtime
logger *zerolog.Logger
ext *extension.Extension
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
type PlaybackMPV struct {
@@ -28,7 +28,7 @@ type PlaybackMPV struct {
playback *Playback
}
func (a *AppContextImpl) BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
p := &Playback{
ctx: a,
vm: vm,
@@ -43,7 +43,7 @@ func (a *AppContextImpl) BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Ob
_ = playbackObj.Set("registerEventListener", p.registerEventListener)
_ = playbackObj.Set("pause", p.pause)
_ = playbackObj.Set("resume", p.resume)
_ = playbackObj.Set("seek", p.seek)
_ = playbackObj.Set("seekTo", p.seekTo)
_ = playbackObj.Set("cancel", p.cancel)
_ = playbackObj.Set("getNextEpisode", p.getNextEpisode)
_ = playbackObj.Set("playNextEpisode", p.playNextEpisode)
@@ -307,7 +307,7 @@ func (p *Playback) resume() error {
return playbackManager.Resume()
}
func (p *Playback) seek(seconds float64) error {
func (p *Playback) seekTo(seconds float64) error {
playbackManager, ok := p.ctx.PlaybackManager().Get()
if !ok {
return errors.New("playback manager not found")

View File

@@ -5,7 +5,7 @@ import (
"errors"
"seanime/internal/database/models"
"seanime/internal/extension"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"seanime/internal/util/result"
"strings"
@@ -24,7 +24,7 @@ type Storage struct {
pluginDataCache *result.Map[string, *models.PluginData] // Cache to avoid repeated database calls
keyDataCache *result.Map[string, interface{}] // Cache to avoid repeated database calls
keySubscribers *result.Map[string, []chan interface{}] // Subscribers for key changes
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
}
var (
@@ -34,7 +34,7 @@ var (
// BindStorage binds the storage API to the Goja runtime.
// Permissions need to be checked by the caller.
// Permissions needed: storage
func (a *AppContextImpl) BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage {
func (a *AppContextImpl) BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) *Storage {
storageLogger := logger.With().Str("id", ext.ID).Logger()
storage := &Storage{
ctx: a,

View File

@@ -4,7 +4,7 @@ package plugin
import (
"encoding/json"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"seanime/internal/util/result"
"sync"
@@ -55,7 +55,7 @@ func (s *Store[K, T]) Stop() {
s.keySubscribers.Clear()
}
func (s *Store[K, T]) Bind(vm *goja.Runtime, scheduler *goja_util.Scheduler) {
func (s *Store[K, T]) Bind(vm *goja.Runtime, scheduler *gojautil.Scheduler) {
// Create a new object for the store
storeObj := vm.NewObject()
_ = storeObj.Set("get", s.Get)
@@ -76,7 +76,7 @@ func (s *Store[K, T]) Bind(vm *goja.Runtime, scheduler *goja_util.Scheduler) {
}
// BindWatch binds the watch method to the store object in the runtime.
func (s *Store[K, T]) bindWatch(storeObj *goja.Object, vm *goja.Runtime, scheduler *goja_util.Scheduler) {
func (s *Store[K, T]) bindWatch(storeObj *goja.Object, vm *goja.Runtime, scheduler *gojautil.Scheduler) {
// Example:
// store.watch("key", (value) => {

View File

@@ -16,7 +16,7 @@ import (
"runtime"
"seanime/internal/extension"
util "seanime/internal/util"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"strings"
"sync"
@@ -38,7 +38,7 @@ type AsyncCmd struct {
cmd *exec.Cmd
appContext *AppContextImpl
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
vm *goja.Runtime
}
@@ -49,13 +49,13 @@ type CmdHelper struct {
stderr io.ReadCloser
appContext *AppContextImpl
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
vm *goja.Runtime
}
// BindSystem binds the system module to the Goja runtime.
// Permissions needed: system + allowlist
func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
//////////////////////////////////////
// OS

View File

@@ -1,7 +1,7 @@
package plugin_ui
import (
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"seanime/internal/util/result"
"slices"
"sync"
@@ -334,7 +334,7 @@ func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOpt
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON {
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *gojautil.Scheduler) *CommandItemJSON {
var components interface{}
if c.renderFunc != nil {

View File

@@ -7,7 +7,7 @@ import (
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/plugin"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"seanime/internal/util/result"
"sync"
"sync/atomic"
@@ -49,7 +49,7 @@ type Context struct {
vm *goja.Runtime
states *result.Map[string, *State]
stateSubscribers []chan *State
scheduler *goja_util.Scheduler // Schedule VM executions concurrently and execute them in order.
scheduler *gojautil.Scheduler // Schedule VM executions concurrently and execute them in order.
wsSubscriber *events.ClientEventSubscriber
eventBus *result.Map[ClientEventType, *result.Map[string, *EventListener]] // map[string]map[string]*EventListener (event -> listenerID -> listener)
contextObj *goja.Object
@@ -203,6 +203,7 @@ func (c *Context) createAndBindContextObject(vm *goja.Runtime) {
case extension.PluginPermissionPlayback:
// Bind playback to the context object
plugin.GlobalAppContext.BindPlaybackToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
plugin.GlobalAppContext.BindVideoCoreToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
case extension.PluginPermissionSystem:
plugin.GlobalAppContext.BindDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
case extension.PluginPermissionCron:

View File

@@ -8,7 +8,7 @@ import (
"seanime/internal/extension"
"seanime/internal/plugin"
"seanime/internal/util"
goja_util "seanime/internal/util/goja"
gojautil "seanime/internal/util/goja"
"sync"
"github.com/dop251/goja"
@@ -38,7 +38,7 @@ type UI struct {
logger *zerolog.Logger
wsEventManager events.WSEventManagerInterface
appContext plugin.AppContext
scheduler *goja_util.Scheduler
scheduler *gojautil.Scheduler
lastException string
@@ -53,7 +53,7 @@ type NewUIOptions struct {
VM *goja.Runtime
WSManager events.WSEventManagerInterface
Database *db.Database
Scheduler *goja_util.Scheduler
Scheduler *gojautil.Scheduler
Extension *extension.Extension
}

View File

@@ -0,0 +1,656 @@
package plugin
import (
"errors"
"seanime/internal/extension"
"seanime/internal/mkvparser"
gojautil "seanime/internal/util/goja"
"seanime/internal/util/result"
"seanime/internal/videocore"
"sync"
"sync/atomic"
"github.com/dop251/goja"
"github.com/rs/zerolog"
)
type VideoCore struct {
ctx *AppContextImpl
vm *goja.Runtime
logger *zerolog.Logger
ext *extension.Extension
scheduler *gojautil.Scheduler
listeners *result.Map[string, *VideoCoreEventListener]
videoCoreSubscriber *videocore.Subscriber
unsubscribeOnce sync.Once
}
type VideoCoreEventListener struct {
eventId string
listenerCh chan videocore.VideoEvent
closed atomic.Bool
closeOnce sync.Once
}
func (a *AppContextImpl) BindVideoCoreToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *gojautil.Scheduler) {
p := &VideoCore{
ctx: a,
vm: vm,
logger: logger,
ext: ext,
scheduler: scheduler,
listeners: result.NewMap[string, *VideoCoreEventListener](),
}
vcObj := vm.NewObject()
// Event listeners
_ = vcObj.Set("addEventListener", p.addEventListener)
_ = vcObj.Set("removeEventListener", p.removeEventListener)
// Playback control
_ = vcObj.Set("pause", p.pause)
_ = vcObj.Set("resume", p.resume)
_ = vcObj.Set("seek", p.seek)
_ = vcObj.Set("seekTo", p.seekTo)
_ = vcObj.Set("terminate", p.terminate)
_ = vcObj.Set("playEpisode", p.playEpisode)
// UI control
_ = vcObj.Set("setFullscreen", p.setFullscreen)
_ = vcObj.Set("setPip", p.setPip)
_ = vcObj.Set("showMessage", p.showMessage)
// Track control
_ = vcObj.Set("setSubtitleTrack", p.setSubtitleTrack)
_ = vcObj.Set("addSubtitleTrack", p.addSubtitleTrack)
_ = vcObj.Set("addExternalSubtitleTrack", p.addExternalSubtitleTrack)
_ = vcObj.Set("setMediaCaptionTrack", p.setMediaCaptionTrack)
_ = vcObj.Set("addMediaCaptionTrack", p.addMediaCaptionTrack)
_ = vcObj.Set("setAudioTrack", p.setAudioTrack)
// State requests
_ = vcObj.Set("sendGetFullscreen", p.sendGetFullscreen)
_ = vcObj.Set("sendGetPip", p.sendGetPip)
_ = vcObj.Set("sendGetAnime4K", p.sendGetAnime4K)
_ = vcObj.Set("sendGetSubtitleTrack", p.sendGetSubtitleTrack)
_ = vcObj.Set("sendGetAudioTrack", p.sendGetAudioTrack)
_ = vcObj.Set("sendGetMediaCaptionTrack", p.sendGetMediaCaptionTrack)
_ = vcObj.Set("sendGetPlaybackState", p.sendGetPlaybackState)
// Async getters
_ = vcObj.Set("getPlaylist", p.getPlaylist)
_ = vcObj.Set("pullStatus", p.pullStatus)
_ = vcObj.Set("getTextTracks", p.getTextTracks)
// Sync getters
_ = vcObj.Set("getPlaybackStatus", p.getPlaybackStatus)
_ = vcObj.Set("getPlaybackState", p.getPlaybackState)
_ = vcObj.Set("getCurrentPlaybackInfo", p.getCurrentPlaybackInfo)
_ = vcObj.Set("getCurrentMedia", p.getCurrentMedia)
_ = vcObj.Set("getCurrentClientId", p.getCurrentClientId)
_ = vcObj.Set("getCurrentPlayerType", p.getCurrentPlayerType)
_ = vcObj.Set("getCurrentPlaybackType", p.getCurrentPlaybackType)
//_ = vcObj.Set("startOnlinestreamWatchParty", p.startOnlinestreamWatchParty)
_ = obj.Set("videoCore", vcObj)
}
type VideoCoreEvent struct {
}
// getEventType maps a VideoEvent to its event type identifier
func (p *VideoCore) getEventType(event videocore.VideoEvent) string {
switch event.(type) {
case *videocore.VideoLoadedEvent:
return string(videocore.PlayerEventVideoLoaded)
case *videocore.VideoLoadedMetadataEvent:
return string(videocore.PlayerEventVideoLoadedMetadata)
case *videocore.VideoCanPlayEvent:
return string(videocore.PlayerEventVideoCanPlay)
case *videocore.VideoPausedEvent:
return string(videocore.PlayerEventVideoPaused)
case *videocore.VideoResumedEvent:
return string(videocore.PlayerEventVideoResumed)
case *videocore.VideoStatusEvent:
return string(videocore.PlayerEventVideoStatus)
case *videocore.VideoCompletedEvent:
return string(videocore.PlayerEventVideoCompleted)
case *videocore.VideoFullscreenEvent:
return string(videocore.PlayerEventVideoFullscreen)
case *videocore.VideoPipEvent:
return string(videocore.PlayerEventVideoPip)
case *videocore.VideoSubtitleTrackEvent:
return string(videocore.PlayerEventVideoSubtitleTrack)
case *videocore.VideoMediaCaptionTrackEvent:
return string(videocore.PlayerEventMediaCaptionTrack)
case *videocore.VideoAnime4KEvent:
return string(videocore.PlayerEventAnime4K)
case *videocore.VideoAudioTrackEvent:
return string(videocore.PlayerEventVideoAudioTrack)
case *videocore.VideoEndedEvent:
return string(videocore.PlayerEventVideoEnded)
case *videocore.VideoSeekedEvent:
return string(videocore.PlayerEventVideoSeeked)
case *videocore.VideoErrorEvent:
return string(videocore.PlayerEventVideoError)
case *videocore.VideoTerminatedEvent:
return string(videocore.PlayerEventVideoTerminated)
case *videocore.VideoPlaybackStateEvent:
return string(videocore.PlayerEventVideoPlaybackState)
case *videocore.SubtitleFileUploadedEvent:
return string(videocore.PlayerEventSubtitleFileUploaded)
case *videocore.VideoPlaylistEvent:
return string(videocore.PlayerEventVideoPlaylist)
case *videocore.VideoTextTracksEvent:
return string(videocore.PlayerEventVideoTextTracks)
default:
return ""
}
}
func (p *VideoCore) convertEventToJSObject(event videocore.VideoEvent) goja.Value {
return p.vm.ToValue(event)
}
func (p *VideoCore) subscribeToEvents() {
p.unsubscribeOnce = sync.Once{}
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return
}
p.videoCoreSubscriber = videoCore.Subscribe("__plugin_videocore_subscriber__" + p.ext.ID)
go func() {
for event := range p.videoCoreSubscriber.Events() {
p.listeners.Range(func(eventId string, listener *VideoCoreEventListener) bool {
if listener.closed.Load() {
return true
}
// Filter events based on the event type the listener is subscribed to
eventType := p.getEventType(event)
if eventType == "" || eventType != listener.eventId {
return true
}
select {
case listener.listenerCh <- event:
default:
// Channel is full, drop the event
}
return true
})
}
}()
}
// addEventListener registers a subscriber for playback events.
//
// Example:
// ctx.videoCore.addEventListener("video-loaded", (event) => {
// console.log(event)
// });
func (p *VideoCore) addEventListener(call goja.FunctionCall) goja.Value {
_, ok := p.ctx.VideoCore().Get()
if !ok {
panic(p.vm.NewTypeError("videocore not found"))
}
eventId := gojautil.ExpectStringArg(p.vm, call, 0)
callback := gojautil.ExpectFunctionArg(p.vm, call, 1)
listener := &VideoCoreEventListener{
eventId: eventId,
listenerCh: make(chan videocore.VideoEvent, 100),
}
// If it's the first listener, subscribe to the videocore events
listenerCount := len(p.listeners.Keys())
if listenerCount == 0 {
p.subscribeToEvents()
}
p.listeners.Set(eventId, listener)
go func() {
for e := range listener.listenerCh {
if listener.closed.Load() {
return
}
p.scheduler.ScheduleAsync(func() error {
eventObj := p.convertEventToJSObject(e)
_, err := callback(goja.Undefined(), eventObj)
if err != nil {
p.logger.Error().Err(err).Msgf("plugin: Error calling videoCore event callback for event %s", eventId)
}
return nil
})
}
}()
return goja.Undefined()
}
// removeEventListener removes a playback event listener.
//
// Example:
// ctx.videoCore.removeEventListener("video-loaded");
func (p *VideoCore) removeEventListener(call goja.FunctionCall) goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
panic(p.vm.NewTypeError("videocore not found"))
}
eventId := gojautil.ExpectStringArg(p.vm, call, 0)
if listener, ok := p.listeners.Pop(eventId); ok {
listener.closed.Store(true)
listener.closeOnce.Do(func() {
close(listener.listenerCh)
})
}
// If it's the last listener, unsubscribe from the videocore events
listenerCount := len(p.listeners.Keys())
if listenerCount == 0 {
p.unsubscribeOnce.Do(func() {
if p.videoCoreSubscriber != nil {
videoCore.Unsubscribe(p.videoCoreSubscriber.GetId())
p.videoCoreSubscriber = nil
}
})
}
return goja.Undefined()
}
func (p *VideoCore) pause() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.Pause()
return nil
}
func (p *VideoCore) resume() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.Resume()
return nil
}
func (p *VideoCore) seek(seconds float64) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.Seek(seconds)
return nil
}
func (p *VideoCore) seekTo(seconds float64) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SeekTo(seconds)
return nil
}
func (p *VideoCore) terminate() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.Terminate()
return nil
}
func (p *VideoCore) getTextTracks() goja.Value {
promise, resolve, reject := p.vm.NewPromise()
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
reject(p.vm.NewGoError(errors.New("videocore not found")))
return p.vm.ToValue(promise)
}
go func() {
ret, ok := videoCore.GetTextTracks()
p.scheduler.ScheduleAsync(func() error {
if ok {
resolve(p.vm.ToValue(ret))
} else {
resolve(goja.Undefined())
}
return nil
})
}()
return p.vm.ToValue(promise)
}
func (p *VideoCore) getPlaylist() goja.Value {
promise, resolve, reject := p.vm.NewPromise()
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
reject(p.vm.NewGoError(errors.New("videocore not found")))
return p.vm.ToValue(promise)
}
go func() {
playlist, ok := videoCore.GetPlaylist()
p.scheduler.ScheduleAsync(func() error {
if ok {
resolve(p.vm.ToValue(playlist))
} else {
resolve(goja.Undefined())
}
return nil
})
}()
return p.vm.ToValue(promise)
}
func (p *VideoCore) playEpisode(call goja.FunctionCall) goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
panic(p.vm.NewTypeError("videocore not found"))
}
which := gojautil.ExpectStringArg(p.vm, call, 0)
videoCore.PlayEpisode(which)
return goja.Undefined()
}
// UI control methods
func (p *VideoCore) setFullscreen(fullscreen bool) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SetFullscreen(fullscreen)
return nil
}
func (p *VideoCore) setPip(pip bool) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SetPip(pip)
return nil
}
func (p *VideoCore) showMessage(message string) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.ShowMessage(message)
return nil
}
// Track control methods
func (p *VideoCore) setSubtitleTrack(trackNumber int) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SetSubtitleTrack(trackNumber)
return nil
}
func (p *VideoCore) addSubtitleTrack(track mkvparser.TrackInfo) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.AddSubtitleTrack(&track)
return nil
}
func (p *VideoCore) addExternalSubtitleTrack(track videocore.VideoSubtitleTrack) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.AddExternalSubtitleTrack(&track)
return nil
}
func (p *VideoCore) setMediaCaptionTrack(trackIndex int) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SetMediaCaptionTrack(trackIndex)
return nil
}
func (p *VideoCore) addMediaCaptionTrack(track interface{}) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.AddMediaCaptionTrack(track)
return nil
}
func (p *VideoCore) setAudioTrack(trackNumber int) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SetAudioTrack(trackNumber)
return nil
}
// State request methods
func (p *VideoCore) sendGetFullscreen() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetFullscreen()
return nil
}
func (p *VideoCore) sendGetPip() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetPip()
return nil
}
func (p *VideoCore) sendGetAnime4K() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetAnime4K()
return nil
}
func (p *VideoCore) sendGetSubtitleTrack() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetSubtitleTrack()
return nil
}
func (p *VideoCore) sendGetAudioTrack() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetAudioTrack()
return nil
}
func (p *VideoCore) sendGetMediaCaptionTrack() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetMediaCaptionTrack()
return nil
}
func (p *VideoCore) sendGetPlaybackState() error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.SendGetPlaybackState()
return nil
}
// Async getter methods
func (p *VideoCore) pullStatus() goja.Value {
promise, resolve, reject := p.vm.NewPromise()
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
reject(p.vm.NewGoError(errors.New("videocore not found")))
return p.vm.ToValue(promise)
}
go func() {
status, ok := videoCore.PullStatus()
p.scheduler.ScheduleAsync(func() error {
if ok {
_ = resolve(p.vm.ToValue(status))
} else {
_ = resolve(goja.Undefined())
}
return nil
})
}()
return p.vm.ToValue(promise)
}
// Sync getter methods
func (p *VideoCore) getPlaybackStatus() goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return goja.Undefined()
}
status, ok := videoCore.GetPlaybackStatus()
if !ok {
return goja.Undefined()
}
return p.vm.ToValue(status)
}
func (p *VideoCore) getPlaybackState() goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return goja.Undefined()
}
state, ok := videoCore.GetPlaybackState()
if !ok {
return goja.Undefined()
}
return p.vm.ToValue(state)
}
func (p *VideoCore) getCurrentPlaybackInfo() goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return goja.Undefined()
}
info, ok := videoCore.GetCurrentPlaybackInfo()
if !ok {
return goja.Undefined()
}
return p.vm.ToValue(info)
}
func (p *VideoCore) getCurrentMedia() goja.Value {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return goja.Undefined()
}
media, ok := videoCore.GetCurrentMedia()
if !ok {
return goja.Undefined()
}
return p.vm.ToValue(media)
}
func (p *VideoCore) getCurrentClientId() string {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return ""
}
return videoCore.GetCurrentClientId()
}
func (p *VideoCore) getCurrentPlayerType() string {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return ""
}
playerType, ok := videoCore.GetCurrentPlayerType()
if !ok {
return ""
}
return string(playerType)
}
func (p *VideoCore) getCurrentPlaybackType() string {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return ""
}
playbackType, ok := videoCore.GetCurrentPlaybackType()
if !ok {
return ""
}
return string(playbackType)
}
// Special methods
func (p *VideoCore) startOnlinestreamWatchParty(params videocore.OnlinestreamParams) error {
videoCore, ok := p.ctx.VideoCore().Get()
if !ok {
return errors.New("videocore not found")
}
videoCore.StartOnlinestreamWatchParty(&params)
return nil
}

View File

@@ -1,4 +1,4 @@
package goja_util
package gojautil
import (
"fmt"

View File

@@ -0,0 +1,147 @@
package gojautil
import (
"fmt"
"github.com/dop251/goja"
)
// ExpectStringArg ensures the argument exists and is strictly a string.
// It panics with a TypeError if validation fails.
// Example:
//
// func do(call goja.FunctionCall) goja.Value {
// url := gojautil.ExpectStringArg(vm, call, 0)
func ExpectStringArg(vm *goja.Runtime, call goja.FunctionCall, index int) string {
arg := call.Argument(index)
if goja.IsUndefined(arg) {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d is missing", index)))
}
// Export returns the underlying Go value
if _, ok := arg.Export().(string); !ok {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d must be a string", index)))
}
return arg.String()
}
// ExpectIntArg ensures the argument exists and is strictly a number (int64 compatible).
func ExpectIntArg(vm *goja.Runtime, call goja.FunctionCall, index int) int64 {
arg := call.Argument(index)
if goja.IsUndefined(arg) {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d is missing", index)))
}
// Goja stores numbers as int64 or float64
val := arg.ToInteger()
// We check if it was actually a number type originally
if _, ok := arg.Export().(int64); !ok {
if _, ok := arg.Export().(float64); !ok {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d must be a number", index)))
}
}
return val
}
// ExpectObjectArg ensures the value is a non-null object and returns the *goja.Object wrapper.
func ExpectObjectArg(vm *goja.Runtime, val goja.Value, argName string) *goja.Object {
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
panic(vm.NewTypeError(fmt.Sprintf("%s must be an object", argName)))
}
// Export check ensures it's not just a primitive wrapped as an object
// (e.g. strict validation against "new String('a')")
if _, ok := val.Export().(map[string]interface{}); !ok {
panic(vm.NewTypeError(fmt.Sprintf("%s must be a valid object/map", argName)))
}
return val.ToObject(vm)
}
// ExpectBoolArg ensures the argument is strictly a boolean.
func ExpectBoolArg(vm *goja.Runtime, call goja.FunctionCall, index int) bool {
arg := call.Argument(index)
if goja.IsUndefined(arg) {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d is missing", index)))
}
if _, ok := arg.Export().(bool); !ok {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d must be a boolean", index)))
}
return arg.ToBoolean()
}
// ExpectArrayArg ensures the argument is an array and returns the Object wrapper.
// You can then iterate over it using .Export().([]interface{}) or key access.
func ExpectArrayArg(vm *goja.Runtime, call goja.FunctionCall, index int) *goja.Object {
arg := call.Argument(index)
if goja.IsUndefined(arg) {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d is missing", index)))
}
// Export() of an array returns []interface{}
if _, ok := arg.Export().([]interface{}); !ok {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d must be an array", index)))
}
return arg.ToObject(vm)
}
// ExpectFunctionArg ensures the argument is a callable function.
// It returns the goja.Callable which you can invoke directly in Go.
func ExpectFunctionArg(vm *goja.Runtime, call goja.FunctionCall, index int) goja.Callable {
arg := call.Argument(index)
if goja.IsUndefined(arg) {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d is missing", index)))
}
fn, ok := goja.AssertFunction(arg)
if !ok {
panic(vm.NewTypeError(fmt.Sprintf("Argument %d must be a function", index)))
}
return fn
}
// GetStringField extracts a string from an object.
// If 'required' is true, it panics on missing keys or wrong types.
func GetStringField(vm *goja.Runtime, obj *goja.Object, key string, required bool) string {
val := obj.Get(key)
if goja.IsUndefined(val) || goja.IsNull(val) {
if required {
panic(vm.NewTypeError(fmt.Sprintf("Missing required field: '%s'", key)))
}
return ""
}
strVal := val.String()
// Strict type check
if _, ok := val.Export().(string); !ok {
panic(vm.NewTypeError(fmt.Sprintf("Field '%s' must be a string", key)))
}
return strVal
}
// GetIntField extracts an int from an object.
func GetIntField(vm *goja.Runtime, obj *goja.Object, key string, required bool) int64 {
val := obj.Get(key)
if goja.IsUndefined(val) || goja.IsNull(val) {
if required {
panic(vm.NewTypeError(fmt.Sprintf("Missing required field: '%s'", key)))
}
return 0
}
return val.ToInteger()
}

View File

@@ -1,4 +1,4 @@
package goja_util
package gojautil
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package goja_util
package gojautil
import (
"context"

View File

@@ -1,6 +1,7 @@
package videocore
import (
"context"
"seanime/internal/continuity"
"seanime/internal/discordrpc/presence"
"seanime/internal/events"
@@ -15,7 +16,7 @@ func (vc *VideoCore) setupEffects() {
}
func (vc *VideoCore) setupSharedEffects() {
subscriber := vc.Subscribe("video-core:shared")
subscriber := vc.Subscribe("videocore:shared")
go func(subscriber *Subscriber) {
for e := range subscriber.Events() {
@@ -52,6 +53,39 @@ func (vc *VideoCore) setupSharedEffects() {
if vc.discordPresence != nil && !vc.isOfflineRef.Get() {
go vc.discordPresence.Close()
}
case *VideoCompletedEvent:
state, ok := vc.GetPlaybackState()
if !ok {
continue
}
shouldUpdateProgress := false
vc.settingsMu.RLock()
shouldUpdateProgress = vc.settings.Library.AutoUpdateProgress
vc.settingsMu.RUnlock()
if shouldUpdateProgress {
// get the list entry
collection, err := vc.platformRef.Get().GetAnimeCollection(context.Background(), false)
if err != nil {
vc.logger.Error().Err(err).Msg("videocore: Cannot update progress, failed to get anime collection")
continue
}
mediaId := state.PlaybackInfo.Media.GetID()
listEntry, ok := collection.GetListEntryFromAnimeId(mediaId)
if !ok {
vc.logger.Error().Msg("videocore: Cannot update progress, failed to get list entry for media")
continue
}
progress := state.PlaybackInfo.Episode.GetProgressNumber()
if listEntry.Progress != nil && progress <= *listEntry.Progress {
continue
}
totalEpisodes := state.PlaybackInfo.Media.Episodes
err = vc.platformRef.Get().UpdateEntryProgress(context.Background(), mediaId, progress, totalEpisodes)
if err != nil {
vc.logger.Error().Err(err).Msgf("videocore: Failed to update progress for media %d", mediaId)
}
vc.refreshAnimeCollectionFunc()
}
case *VideoTerminatedEvent:
if vc.discordPresence != nil && !vc.isOfflineRef.Get() {
go vc.discordPresence.Close()
@@ -80,7 +114,7 @@ func (vc *VideoCore) setupSharedEffects() {
}
func (vc *VideoCore) setupOnlinestreamEffects() {
subscriber := vc.Subscribe("video-core:onlinestream")
subscriber := vc.Subscribe("videocore:onlinestream")
go func(subscriber *Subscriber) {
for e := range subscriber.Events() {

View File

@@ -29,6 +29,8 @@ const (
PlayerEventVideoTerminated ClientEventType = "video-terminated"
PlayerEventVideoPlaybackState ClientEventType = "video-playback-state"
PlayerEventSubtitleFileUploaded ClientEventType = "subtitle-file-uploaded"
PlayerEventVideoPlaylist ClientEventType = "video-playlist"
PlayerEventVideoTextTracks ClientEventType = "video-text-tracks"
)
type PlayerType string
@@ -61,6 +63,13 @@ type VideoSubtitleTrack struct {
UseLibassRenderer *bool `json:"useLibassRenderer"`
}
type VideoTextTrack struct {
Number int `json:"number"`
Type string `json:"type"` // "subtitles" | "captions"
Label string `json:"label"`
Language string `json:"language"`
}
// VideoSource is an alternative video stream source (e.g., resolution options).
type VideoSource struct {
Index int `json:"index"`
@@ -109,6 +118,16 @@ type VideoPlaybackInfo struct {
IsNakamaWatchParty bool `json:"isNakamaWatchParty,omitempty"`
}
// VideoPlaylistState holds the state for the video player's playlist and playback.
type VideoPlaylistState struct {
Type PlaybackType `json:"type"`
Episodes []*anime.Episode `json:"episodes"`
PreviousEpisode *anime.Episode `json:"previousEpisode,omitempty"`
NextEpisode *anime.Episode `json:"nextEpisode,omitempty"`
CurrentEpisode *anime.Episode `json:"currentEpisode"`
AnimeEntry *anime.Entry `json:"animeEntry,omitempty"`
}
type (
PlaybackStatus struct {
Id string `json:"id"`
@@ -139,6 +158,9 @@ type (
clientVideoLoadedPayload struct {
State PlaybackState `json:"state"`
}
clientVideoPlaylistPayload struct {
Playlist VideoPlaylistState `json:"playlist"`
}
clientVideoErrorPayload struct {
Error string `json:"error"`
}
@@ -170,6 +192,9 @@ type (
clientVideoAnime4KPayload struct {
Option string `json:"option"`
}
clientVideoTextTracksPayload struct {
TextTracks []*VideoTextTrack `json:"textTracks"`
}
)
func (e *ClientEvent) UnmarshalAs(dest interface{}) error {
@@ -324,6 +349,14 @@ type (
BaseVideoEvent
Option string `json:"string"` // name or "off"
}
VideoPlaylistEvent struct {
BaseVideoEvent
Playlist *VideoPlaylistState `json:"playlist"`
}
VideoTextTracksEvent struct {
BaseVideoEvent
TextTracks []*VideoTextTrack `json:"textTracks"`
}
)
func (e *VideoStatusEvent) IsCritical() bool { return false }
@@ -349,7 +382,10 @@ const (
ServerEventTerminate ServerEvent = "terminate"
ServerEventStartOnlinestreamWatchParty ServerEvent = "start-onlinestream-watch-party"
ServerEventGetStatus ServerEvent = "get-status"
// Getters
ServerEventShowMessage ServerEvent = "show-message"
ServerEventPlayEpisode ServerEvent = "play-episode"
ServerEventGetTextTracks ServerEvent = "get-text-tracks"
// State requests
ServerEventGetFullscreen ServerEvent = "get-fullscreen"
ServerEventGetPip ServerEvent = "get-pip"
ServerEventGetAnime4K ServerEvent = "get-anime-4k"
@@ -357,4 +393,5 @@ const (
ServerEventGetAudioTrack ServerEvent = "get-audio-track"
ServerEventGetMediaCaptionTrack ServerEvent = "get-media-caption-track"
ServerEventGetPlaybackState ServerEvent = "get-playback-state"
ServerEventGetPlaylist ServerEvent = "get-playlist"
)

View File

@@ -5,6 +5,7 @@ import (
"seanime/internal/api/anilist"
"seanime/internal/api/metadata_provider"
"seanime/internal/continuity"
"seanime/internal/database/models"
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/events"
"seanime/internal/mkvparser"
@@ -44,11 +45,14 @@ type (
dispatcherStop chan struct{}
startOnce sync.Once
logger *zerolog.Logger
logger *zerolog.Logger
settingsMu sync.RWMutex
settings *models.Settings
}
// Subscriber listens to the player events
Subscriber struct {
id string
eventCh chan VideoEvent
isClosed atomic.Bool
closeOnce sync.Once
@@ -86,6 +90,12 @@ func New(opts NewVideoCoreOptions) *VideoCore {
return vc
}
func (vc *VideoCore) SetSettings(settings *models.Settings) {
vc.settingsMu.Lock()
vc.settings = settings
vc.settingsMu.Unlock()
}
func (vc *VideoCore) Start() {
vc.startOnce.Do(func() {
vc.listenToClientEvents()
@@ -191,6 +201,7 @@ func (vc *VideoCore) sendPlayerEvent(t string, payload interface{}) {
// Subscribe lets other modules subscribe to the native player events
func (vc *VideoCore) Subscribe(id string) *Subscriber {
subscriber := &Subscriber{
id: id,
eventCh: make(chan VideoEvent, 100),
}
vc.subscribers.Set(id, subscriber)
@@ -213,6 +224,11 @@ func (s *Subscriber) Events() <-chan VideoEvent {
return s.eventCh
}
// GetId returns the subscriber id
func (s *Subscriber) GetId() string {
return s.id
}
func (vc *VideoCore) RegisterEventCallback(callback func(event VideoEvent) bool) (cancel func()) {
id := uuid.NewString()
sub := vc.Subscribe(id)
@@ -460,6 +476,24 @@ func (vc *VideoCore) SetAudioTrack(trackNumber int) {
vc.sendPlayerEventTo(state.ClientId, string(ServerEventSetAudioTrack), trackNumber)
}
func (vc *VideoCore) ShowMessage(message string) {
state, ok := vc.GetPlaybackState()
if !ok {
return
}
vc.sendPlayerEventTo(state.ClientId, string(ServerEventShowMessage), message)
}
// PlayEpisode sends a play-episode command to the video player.
// which is "next", "previous", or the AniDB episode ID.
func (vc *VideoCore) PlayEpisode(which string) {
state, ok := vc.GetPlaybackState()
if !ok {
return
}
vc.sendPlayerEventTo(state.ClientId, string(ServerEventPlayEpisode), which)
}
// Terminate sends a terminate command to the video player.
func (vc *VideoCore) Terminate() {
state, ok := vc.GetPlaybackState()
@@ -538,6 +572,56 @@ func (vc *VideoCore) SendGetPlaybackState() {
vc.sendPlayerEventTo(state.ClientId, string(ServerEventGetPlaybackState), nil)
}
// GetPlaylist sends a get-text-tracks request to the video player and returns the text tracks.
func (vc *VideoCore) GetTextTracks() (ret []*VideoTextTrack, ok bool) {
state, ok := vc.GetPlaybackState()
if !ok {
return nil, false
}
done := make(chan struct{})
cancel := vc.RegisterEventCallback(func(e VideoEvent) bool {
switch event := e.(type) {
case *VideoTextTracksEvent:
ret = event.TextTracks
close(done)
return false // stop
}
return true // keep listening
})
go func(cancel func()) {
defer cancel()
<-time.After(5 * time.Second)
}(cancel)
vc.sendPlayerEventTo(state.ClientId, string(ServerEventGetTextTracks), nil)
<-done
return ret, ret != nil
}
// GetPlaylist sends a get-playlist request to the video player and returns the playlist state.
func (vc *VideoCore) GetPlaylist() (ret *VideoPlaylistState, ok bool) {
state, ok := vc.GetPlaybackState()
if !ok {
return nil, false
}
done := make(chan struct{})
cancel := vc.RegisterEventCallback(func(e VideoEvent) bool {
switch event := e.(type) {
case *VideoPlaylistEvent:
ret = event.Playlist
close(done)
return false // stop
}
return true // keep listening
})
go func(cancel func()) {
defer cancel()
<-time.After(5 * time.Second)
}(cancel)
vc.sendPlayerEventTo(state.ClientId, string(ServerEventGetPlaylist), nil)
<-done
return ret, ret != nil
}
// PullStatus pulls the current playback status from the video player.
func (vc *VideoCore) PullStatus() (ret VideoStatusEvent, ok bool) {
state, ok := vc.GetPlaybackState()
@@ -545,7 +629,7 @@ func (vc *VideoCore) PullStatus() (ret VideoStatusEvent, ok bool) {
return VideoStatusEvent{}, false
}
done := make(chan struct{})
vc.RegisterEventCallback(func(e VideoEvent) bool {
cancel := vc.RegisterEventCallback(func(e VideoEvent) bool {
switch event := e.(type) {
case *VideoStatusEvent:
ret = *event
@@ -554,6 +638,10 @@ func (vc *VideoCore) PullStatus() (ret VideoStatusEvent, ok bool) {
}
return true // keep listening
})
go func(cancel func()) {
defer cancel()
<-time.After(5 * time.Second)
}(cancel)
vc.sendPlayerEventTo(state.ClientId, string(ServerEventGetStatus), nil)
<-done
return ret, true
@@ -760,6 +848,20 @@ func (vc *VideoCore) listenToClientEvents() {
Content: payload.Content,
})
}
case PlayerEventVideoPlaylist:
payload := &clientVideoPlaylistPayload{}
if err := playerEvent.UnmarshalAs(payload); err == nil {
vc.PushEvent(&VideoPlaylistEvent{
Playlist: &payload.Playlist,
})
}
case PlayerEventVideoTextTracks:
payload := &clientVideoTextTracksPayload{}
if err := playerEvent.UnmarshalAs(payload); err == nil {
vc.PushEvent(&VideoTextTracksEvent{
TextTracks: payload.TextTracks,
})
}
}
}
}

View File

@@ -4758,7 +4758,9 @@ export type VideoCore_ClientEventType = "video-loaded" |
"video-error" |
"video-terminated" |
"video-playback-state" |
"subtitle-file-uploaded"
"subtitle-file-uploaded" |
"video-playlist" |
"video-text-tracks"
/**
* - Filepath: internal/videocore/types.go
@@ -4821,13 +4823,17 @@ export type VideoCore_ServerEvent = "pause" |
"terminate" |
"start-onlinestream-watch-party" |
"get-status" |
"show-message" |
"play-episode" |
"get-text-tracks" |
"get-fullscreen" |
"get-pip" |
"get-anime-4k" |
"get-subtitle-track" |
"get-audio-track" |
"get-media-caption-track" |
"get-playback-state"
"get-playback-state" |
"get-playlist"
/**
* - Filepath: internal/videocore/types.go

View File

@@ -226,7 +226,7 @@ export function NakamaManager() {
const { startOnlineStreamWatchParty } = useNakamaOnlineStreamWatchParty()
useWebsocketMessageListener({
type: WSEvents.VIDEO_CORE,
type: WSEvents.VIDEOCORE,
onMessage: ({ type, payload }: { type: VideoCore_ServerEvent, payload: unknown }) => {
switch (type) {
case "start-onlinestream-watch-party":

View File

@@ -6,6 +6,7 @@ import {
vc_mediaCaptionsManager,
vc_subtitleManager,
} from "@/app/(main)/_features/video-core/video-core"
import { vc_doFlashAction } from "@/app/(main)/_features/video-core/video-core-action-display"
import { Anime4KManagerOptionChangedEvent } from "@/app/(main)/_features/video-core/video-core-anime-4k-manager"
import { AudioManagerHlsTrackChangedEvent, AudioManagerTrackChangedEvent } from "@/app/(main)/_features/video-core/video-core-audio"
import { FullscreenManagerChangedEvent, vc_fullscreenManager } from "@/app/(main)/_features/video-core/video-core-fullscreen"
@@ -15,14 +16,15 @@ import {
MediaCaptionsTrackSelectedEvent,
} from "@/app/(main)/_features/video-core/video-core-media-captions"
import { PipManagerToggledEvent, vc_pipManager } from "@/app/(main)/_features/video-core/video-core-pip"
import { useVideoCorePlaylist, VideoCorePlaylistState } from "@/app/(main)/_features/video-core/video-core-playlist"
import { SubtitleManagerTrackDeselectedEvent, SubtitleManagerTrackSelectedEvent } from "@/app/(main)/_features/video-core/video-core-subtitles"
import { vc_autoNextAtom, VideoCoreLifecycleState, VideoCore_VideoSubtitleTrack } from "@/app/(main)/_features/video-core/video-core.atoms"
import { vc_autoNextAtom, VideoCore_VideoSubtitleTrack, VideoCoreLifecycleState } from "@/app/(main)/_features/video-core/video-core.atoms"
import { detectSubtitleType, isSubtitleFile } from "@/app/(main)/_features/video-core/video-core.utils"
import { useWebsocketMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets"
import { clientIdAtom } from "@/app/websocket-provider"
import { logger } from "@/lib/helpers/debug"
import { WSEvents } from "@/lib/server/ws-events"
import { useAtomValue } from "jotai"
import { useAtomValue, useSetAtom } from "jotai"
import React, { useCallback, useRef } from "react"
import { toast } from "sonner"
@@ -96,6 +98,8 @@ export function useVideoCoreSetupEvents(id: string,
const pipManager = useAtomValue(vc_pipManager)
const audioManager = useAtomValue(vc_audioManager)
const autoNext = useAtomValue(vc_autoNextAtom)
const flashAction = useSetAtom(vc_doFlashAction)
const { playEpisode, playlistState } = useVideoCorePlaylist()
// React.useEffect(() => {
// log.trace(activePlayer, id)
@@ -477,7 +481,7 @@ export function useVideoCoreSetupEvents(id: string,
}, [handleUpload, state.active, videoRef.current])
useWebsocketMessageListener({
type: WSEvents.VIDEO_CORE,
type: WSEvents.VIDEOCORE,
deps: [activePlayer, id],
onMessage: ({ type, payload }: { type: VideoCore_ServerEvent, payload: unknown }) => {
if (activePlayer !== id || !videoRef.current) return
@@ -559,9 +563,9 @@ export function useVideoCoreSetupEvents(id: string,
case "add-external-subtitle-track":
log.info("Add subtitle track event received", payload)
const fileTrack = payload as VideoCore_VideoSubtitleTrack
if (subtitleManager && fileTrack.type === "ass") {
if (subtitleManager) {
subtitleManager.addFileTrack(fileTrack)
} else if (mediaCaptionsManager && fileTrack.type === "vtt") {
} else if (mediaCaptionsManager) {
mediaCaptionsManager.addCaptionTrack(fileTrack)
}
break
@@ -640,6 +644,43 @@ export function useVideoCoreSetupEvents(id: string,
},
})
break
case "show-message":
log.info("Show message event received", payload)
flashAction({ message: payload as string, type: "message", duration: 2000 })
break
case "get-playlist":
log.info("Get playlist event received")
if (!playlistState) return
sendEvent<{ playlist: VideoCorePlaylistState }>("video-playlist", {
playlist: playlistState,
})
break
case "play-episode":
log.info("Play next episode event received")
playEpisode(payload as string)
break
case "start-onlinestream-watch-party":
break
case "get-text-tracks":
log.info("Get text tracks event received")
let textTracks: { type: "subtitles" | "captions", label: string, language: string, number: number }[] = []
if (subtitleManager) {
textTracks = subtitleManager.getTracks().map(n => ({
number: n.number,
type: "subtitles",
label: n.label || "",
language: n.language || n.languageIETF || "",
}))
} else if (mediaCaptionsManager) {
textTracks = mediaCaptionsManager.getTracks().map(n => ({
number: n.number,
type: "captions",
label: n.label,
language: n.language,
}))
}
sendEvent("video-text-tracks", { textTracks })
break
default:
log.warn("Unknown event received", type)
}
@@ -661,7 +702,7 @@ export function useVideoCoreEvents() {
function sendEvent<T extends Record<string, any> | void = void>(type: VideoCore_ClientEventType, payload?: T) {
sendMessage({
type: WSEvents.VIDEO_CORE,
type: WSEvents.VIDEOCORE,
payload: {
clientId: clientId,
type: type,

View File

@@ -38,16 +38,11 @@ export function VideoCoreInlineHelpers({
const [hasUpdatedProgress, setHasUpdateProgress] = useAtom(vc_inlineHelper_hasUpdatedProgress)
const [progressUpdateData, setProgressUpdateData] = useAtom(vc_inlineHelper_progressUpdateData)
const { mutate: updateProgress, isPending: isUpdatingProgress, isSuccess: updated } = useUpdateAnimeEntryProgress(
media?.id,
currentProgress,
)
// Reset state when media, episode, or update completes
React.useEffect(() => {
setProgressUpdateData(null)
setHasUpdateProgress(false)
}, [media, currentEpisodeNumber, url, updated])
}, [media, currentEpisodeNumber, url])
React.useEffect(() => {
if (!playerRef.current || !media || currentEpisodeNumber === null || !url) return
@@ -58,10 +53,10 @@ export function VideoCoreInlineHelpers({
const checkProgress = () => {
const player = playerRef.current
if (!player) return
if (!player || serverStatus?.settings?.library?.autoUpdateProgress) return
// Skip if already updated or currently updating
if (hasUpdatedProgress || isUpdatingProgress) return
if (hasUpdatedProgress) return
// Skip if progress update data already exists
if (progressUpdateData !== null) return
@@ -75,29 +70,12 @@ export function VideoCoreInlineHelpers({
const watchedRatio = currentTime / duration
if (watchedRatio < PROGRESS_THRESHOLD) return
// Handle auto-update or prompt user
if (serverStatus?.settings?.library?.autoUpdateProgress) {
setHasUpdateProgress(true)
updateProgress({
episodeNumber: currentEpisodeNumber,
mediaId: media.id,
totalEpisodes: media.episodes || 0,
malId: media.idMal || undefined,
}, {
onSuccess: () => {
setHasUpdateProgress(true)
},
onError: () => {
setHasUpdateProgress(false)
},
})
} else {
setProgressUpdateData({
media,
currentProgress,
currentEpisodeNumber,
})
}
// prompt user
setProgressUpdateData({
media,
currentProgress,
currentEpisodeNumber,
})
}
// Start interval
@@ -112,7 +90,6 @@ export function VideoCoreInlineHelpers({
media,
playerRef,
hasUpdatedProgress,
isUpdatingProgress,
serverStatus?.settings?.library?.autoUpdateProgress,
currentProgress,
progressUpdateData,

View File

@@ -2,7 +2,7 @@ import { vc_getCaptionStyle } from "@/app/(main)/_features/video-core/video-core
import { getDefaultSubtitleTrackNumber } from "@/app/(main)/_features/video-core/video-core-subtitles"
import { VideoCoreSettings } from "@/app/(main)/_features/video-core/video-core.atoms"
import { logger } from "@/lib/helpers/debug"
import { CaptionsRenderer, ParsedCaptionsResult, parseResponse, parseText, VTTCue, VTTRegion } from "media-captions"
import { CaptionsFileFormat, CaptionsRenderer, ParsedCaptionsResult, parseResponse, parseText, VTTCue, VTTRegion } from "media-captions"
import "media-captions/styles/captions.css"
import "media-captions/styles/regions.css"
import { toast } from "sonner"
@@ -429,7 +429,7 @@ export class MediaCaptionsManager extends EventTarget {
// Adds a new subtitle track and selects it AFTER initialization
// This is used for adding subtitles from the server
public addCaptionTrack(track: MediaCaptionsTrackInfo) {
toast.success(`Subtitle track added: ${track.label}`)
toast.success(`Caption track added: ${track.label}`)
this.tracks.push(track)
const index = this.tracks.length - 1
this.loadedTracks.push({
@@ -442,7 +442,7 @@ export class MediaCaptionsManager extends EventTarget {
if (track.src) {
return await parseResponse(fetch(track.src))
} else if (track.content) {
return await parseText(track.content, { type: "vtt" })
return await parseText(track.content, { type: track.type as CaptionsFileFormat || "vtt" })
}
return null
},

View File

@@ -1,4 +1,4 @@
import { Anime_Entry, Anime_Episode, HibikeTorrent_AnimeTorrent } from "@/api/generated/types"
import { Anime_Entry, Anime_Episode } from "@/api/generated/types"
import { useGetAnimeEpisodeCollection } from "@/api/hooks/anime.hooks"
import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks"
import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item"
@@ -31,13 +31,12 @@ import React from "react"
import { useUpdateEffect } from "react-use"
import { toast } from "sonner"
type VideoCorePlaylistState = {
export type VideoCorePlaylistState = {
type: VideoCore_PlaybackType
episodes: Anime_Episode[]
previousEpisode: Anime_Episode | null
nextEpisode: Anime_Episode | null
currentEpisode: Anime_Episode
currentTorrent?: HibikeTorrent_AnimeTorrent // for torrent and debrid stream type
animeEntry: Anime_Entry | null
onPlayEpisode?: VideoCorePlaylistPlayEpisodeFunction
}

View File

@@ -331,7 +331,7 @@ export function OnlinestreamPage({ animeEntry, animeEntryLoading, hideBackButton
const episodeLoading = isLoadingEpisodeSource || isFetchingEpisodeSource
const isWatchPartyPeer = React.useMemo(() => {
return !!nakamaStatus?.currentWatchPartySession && !nakamaStatus.isHost && !nakamaStatus.currentWatchPartySession?.participants?.[nakamaStatus?.hostConnectionStatus?.peerId || ""]?.isRelayOrigin
return !!nakamaStatus?.hostConnectionStatus && !!nakamaStatus?.currentWatchPartySession && !nakamaStatus.isHost && !nakamaStatus.currentWatchPartySession?.participants?.[nakamaStatus?.hostConnectionStatus?.peerId || ""]?.isRelayOrigin
}, [nakamaStatus])
/*
@@ -339,6 +339,7 @@ export function OnlinestreamPage({ animeEntry, animeEntryLoading, hideBackButton
*/
const firstRenderRef = React.useRef(true)
React.useEffect(() => {
console.warn(nakamaStatus)
// Do not auto set the episode number if the user is in a watch party and is not the host
if (isWatchPartyPeer) return
@@ -361,28 +362,6 @@ export function OnlinestreamPage({ animeEntry, animeEntryLoading, hideBackButton
}
}, [episodes, media, animeEntry?.listData, urlEpNumber, currentPlaylist, isWatchPartyPeer])
/*
* Set episode number on update
*/
React.useEffect(() => {
// Do not auto set the episode number if the user is in a watch party and is not the host
if (isWatchPartyPeer) return
// Do not auto set if we're loading from watch party
if (isLoadingFromWatchPartyRef.current) {
return
}
if (firstRenderRef.current) return
if (!!media && !!episodes) {
const episodeNumberFromURL = urlEpNumber ? Number(urlEpNumber) : undefined
if (episodeNumberFromURL) {
setSelectedEpisodeNumber(episodeNumberFromURL)
log.info("Changing episode number to", episodeNumberFromURL)
}
}
}, [urlEpNumber, isWatchPartyPeer])
function onCanPlay() {
if (urlEpNumber) {

View File

@@ -43,7 +43,7 @@ export const enum WSEvents {
CONSOLE_LOG = "console-log",
CONSOLE_WARN = "console-warn",
NATIVE_PLAYER = "native-player",
VIDEO_CORE = "video-core",
VIDEOCORE = "videocore",
NAKAMA_HOST_STARTED = "nakama-host-started",
NAKAMA_HOST_STOPPED = "nakama-host-stopped",
NAKAMA_PEER_CONNECTED = "nakama-peer-connected",