package plugin_ui import ( "fmt" "seanime/internal/util/result" "github.com/dop251/goja" "github.com/goccy/go-json" "github.com/google/uuid" ) const ( MaxActionsPerType = 3 // A plugin can only at most X actions of a certain type ) // ActionManager // // Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI. // They are defined in the plugin code and are used to trigger events. // // The ActionManager is responsible for registering, rendering, and handling events for actions. type ActionManager struct { ctx *Context animePageButtons *result.Map[string, *AnimePageButton] animePageDropdownItems *result.Map[string, *AnimePageDropdownMenuItem] animeLibraryDropdownItems *result.Map[string, *AnimeLibraryDropdownMenuItem] mangaPageButtons *result.Map[string, *MangaPageButton] mediaCardContextMenuItems *result.Map[string, *MediaCardContextMenuItem] episodeCardContextMenuItems *result.Map[string, *EpisodeCardContextMenuItem] episodeGridItemMenuItems *result.Map[string, *EpisodeGridItemMenuItem] } type BaseActionProps struct { ID string `json:"id"` Label string `json:"label"` Style map[string]string `json:"style,omitempty"` } // Base action struct that all action types embed type BaseAction struct { BaseActionProps } // GetProps returns the base action properties func (a *BaseAction) GetProps() BaseActionProps { return a.BaseActionProps } // SetProps sets the base action properties func (a *BaseAction) SetProps(props BaseActionProps) { a.BaseActionProps = props } // UnmountAll unmounts all actions // It should be called when the plugin is unloaded func (a *ActionManager) UnmountAll() { if a.animePageButtons.ClearN() > 0 { a.renderAnimePageButtons() } if a.animePageDropdownItems.ClearN() > 0 { a.renderAnimePageDropdownItems() } if a.animeLibraryDropdownItems.ClearN() > 0 { a.renderAnimeLibraryDropdownItems() } if a.mangaPageButtons.ClearN() > 0 { a.renderMangaPageButtons() } if a.mediaCardContextMenuItems.ClearN() > 0 { a.renderMediaCardContextMenuItems() } if a.episodeCardContextMenuItems.ClearN() > 0 { a.renderEpisodeCardContextMenuItems() } if a.episodeGridItemMenuItems.ClearN() > 0 { a.renderEpisodeGridItemMenuItems() } } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type AnimePageButton struct { BaseAction Intent string `json:"intent,omitempty"` } func (a *AnimePageButton) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) _ = obj.Set("setIntent", func(intent string) { a.Intent = intent }) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type EpisodeCardContextMenuItem struct { BaseAction } func (a *EpisodeCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type EpisodeGridItemMenuItem struct { BaseAction Type string `json:"type"` } func (a *EpisodeGridItemMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type MangaPageButton struct { BaseAction Intent string `json:"intent,omitempty"` } func (a *MangaPageButton) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) _ = obj.Set("setIntent", func(intent string) { a.Intent = intent }) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type AnimePageDropdownMenuItem struct { BaseAction } func (a *AnimePageDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type AnimeLibraryDropdownMenuItem struct { BaseAction } func (a *AnimeLibraryDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// type MediaCardContextMenuItemFor string const ( MediaCardContextMenuItemForAnime MediaCardContextMenuItemFor = "anime" MediaCardContextMenuItemForManga MediaCardContextMenuItemFor = "manga" MediaCardContextMenuItemForBoth MediaCardContextMenuItemFor = "both" ) type MediaCardContextMenuItem struct { BaseAction For MediaCardContextMenuItemFor `json:"for"` // anime, manga, both } func (a *MediaCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { obj := actionManager.ctx.vm.NewObject() actionManager.bindSharedToObject(obj, a) _ = obj.Set("setFor", func(_for MediaCardContextMenuItemFor) { a.For = _for }) return obj } // /////////////////////////////////////////////////////////////////////////////////////////////////////////// func NewActionManager(ctx *Context) *ActionManager { return &ActionManager{ ctx: ctx, animePageButtons: result.NewResultMap[string, *AnimePageButton](), animeLibraryDropdownItems: result.NewResultMap[string, *AnimeLibraryDropdownMenuItem](), animePageDropdownItems: result.NewResultMap[string, *AnimePageDropdownMenuItem](), mangaPageButtons: result.NewResultMap[string, *MangaPageButton](), mediaCardContextMenuItems: result.NewResultMap[string, *MediaCardContextMenuItem](), episodeCardContextMenuItems: result.NewResultMap[string, *EpisodeCardContextMenuItem](), episodeGridItemMenuItems: result.NewResultMap[string, *EpisodeGridItemMenuItem](), } } // renderAnimePageButtons is called when the client requests the buttons to display on the anime page. func (a *ActionManager) renderAnimePageButtons() { buttons := make([]*AnimePageButton, 0) a.animePageButtons.Range(func(key string, value *AnimePageButton) bool { buttons = append(buttons, value) return true }) a.ctx.SendEventToClient(ServerActionRenderAnimePageButtonsEvent, ServerActionRenderAnimePageButtonsEventPayload{ Buttons: buttons, }) } func (a *ActionManager) renderAnimePageDropdownItems() { items := make([]*AnimePageDropdownMenuItem, 0) a.animePageDropdownItems.Range(func(key string, value *AnimePageDropdownMenuItem) bool { items = append(items, value) return true }) a.ctx.SendEventToClient(ServerActionRenderAnimePageDropdownItemsEvent, ServerActionRenderAnimePageDropdownItemsEventPayload{ Items: items, }) } func (a *ActionManager) renderAnimeLibraryDropdownItems() { items := make([]*AnimeLibraryDropdownMenuItem, 0) a.animeLibraryDropdownItems.Range(func(key string, value *AnimeLibraryDropdownMenuItem) bool { items = append(items, value) return true }) a.ctx.SendEventToClient(ServerActionRenderAnimeLibraryDropdownItemsEvent, ServerActionRenderAnimeLibraryDropdownItemsEventPayload{ Items: items, }) } func (a *ActionManager) renderMangaPageButtons() { buttons := make([]*MangaPageButton, 0) a.mangaPageButtons.Range(func(key string, value *MangaPageButton) bool { buttons = append(buttons, value) return true }) a.ctx.SendEventToClient(ServerActionRenderMangaPageButtonsEvent, ServerActionRenderMangaPageButtonsEventPayload{ Buttons: buttons, }) } func (a *ActionManager) renderMediaCardContextMenuItems() { items := make([]*MediaCardContextMenuItem, 0) a.mediaCardContextMenuItems.Range(func(key string, value *MediaCardContextMenuItem) bool { items = append(items, value) return true }) a.ctx.SendEventToClient(ServerActionRenderMediaCardContextMenuItemsEvent, ServerActionRenderMediaCardContextMenuItemsEventPayload{ Items: items, }) } func (a *ActionManager) renderEpisodeCardContextMenuItems() { items := make([]*EpisodeCardContextMenuItem, 0) a.episodeCardContextMenuItems.Range(func(key string, value *EpisodeCardContextMenuItem) bool { items = append(items, value) return true }) a.ctx.SendEventToClient(ServerActionRenderEpisodeCardContextMenuItemsEvent, ServerActionRenderEpisodeCardContextMenuItemsEventPayload{ Items: items, }) } func (a *ActionManager) renderEpisodeGridItemMenuItems() { items := make([]*EpisodeGridItemMenuItem, 0) a.episodeGridItemMenuItems.Range(func(key string, value *EpisodeGridItemMenuItem) bool { items = append(items, value) return true }) a.ctx.SendEventToClient(ServerActionRenderEpisodeGridItemMenuItemsEvent, ServerActionRenderEpisodeGridItemMenuItemsEventPayload{ Items: items, }) } // bind binds 'action' to the ctx object // // Example: // ctx.action.newAnimePageButton(...) func (a *ActionManager) bind(ctxObj *goja.Object) { actionObj := a.ctx.vm.NewObject() _ = actionObj.Set("newAnimePageButton", a.jsNewAnimePageButton) _ = actionObj.Set("newAnimePageDropdownItem", a.jsNewAnimePageDropdownItem) _ = actionObj.Set("newAnimeLibraryDropdownItem", a.jsNewAnimeLibraryDropdownItem) _ = actionObj.Set("newMediaCardContextMenuItem", a.jsNewMediaCardContextMenuItem) _ = actionObj.Set("newMangaPageButton", a.jsNewMangaPageButton) _ = actionObj.Set("newEpisodeCardContextMenuItem", a.jsNewEpisodeCardContextMenuItem) _ = actionObj.Set("newEpisodeGridItemMenuItem", a.jsNewEpisodeGridItemMenuItem) _ = ctxObj.Set("action", actionObj) } //////////////////////////////////////////////////////////////////////////////////////////////// // Actions //////////////////////////////////////////////////////////////////////////////////////////////// // jsNewEpisodeCardContextMenuItem // // Example: // const downloadButton = ctx.newEpisodeCardContextMenuItem({ // label: "Download", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewEpisodeCardContextMenuItem(call goja.FunctionCall) goja.Value { // Create a new action action := &EpisodeCardContextMenuItem{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewEpisodeGridItemMenuItem // // Example: // const downloadButton = ctx.newEpisodeGridItemContextMenuItem({ // label: "Download", // onClick: "download-button-clicked", // type: "library", // }) func (a *ActionManager) jsNewEpisodeGridItemMenuItem(call goja.FunctionCall) goja.Value { // Create a new action action := &EpisodeGridItemMenuItem{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewAnimePageButton // // Example: // const downloadButton = ctx.newAnimePageButton({ // label: "Download", // intent: "primary", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewAnimePageButton(call goja.FunctionCall) goja.Value { // Create a new action action := &AnimePageButton{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewAnimePageDropdownItem // // Example: // const downloadButton = ctx.newAnimePageDropdownItem({ // label: "Download", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewAnimePageDropdownItem(call goja.FunctionCall) goja.Value { // Create a new action action := &AnimePageDropdownMenuItem{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewAnimeLibraryDropdownItem // // Example: // const downloadButton = ctx.newAnimeLibraryDropdownItem({ // label: "Download", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewAnimeLibraryDropdownItem(call goja.FunctionCall) goja.Value { // Create a new action action := &AnimeLibraryDropdownMenuItem{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewMediaCardContextMenuItem // // Example: // const downloadButton = ctx.newMediaCardContextMenuItem({ // label: "Download", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewMediaCardContextMenuItem(call goja.FunctionCall) goja.Value { // Create a new action action := &MediaCardContextMenuItem{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // jsNewMangaPageButton // // Example: // const downloadButton = ctx.newMangaPageButton({ // label: "Download", // onClick: "download-button-clicked", // }) func (a *ActionManager) jsNewMangaPageButton(call goja.FunctionCall) goja.Value { // Create a new action action := &MangaPageButton{} // Get the props a.unmarshalProps(call, action) action.ID = uuid.New().String() // Create the object obj := action.CreateObject(a) return obj } // /////////////////////////////////////////////////////////////////////////////////// // Shared // /////////////////////////////////////////////////////////////////////////////////// // bindSharedToObject binds shared methods to action objects // // Example: // const downloadButton = ctx.newAnimePageButton(...) // downloadButton.mount() // downloadButton.unmount() // downloadButton.setLabel("Downloading...") func (a *ActionManager) bindSharedToObject(obj *goja.Object, action interface{}) { var id string var props BaseActionProps var mapToUse interface{} switch act := action.(type) { case *AnimePageButton: id = act.ID props = act.GetProps() mapToUse = a.animePageButtons case *MangaPageButton: id = act.ID props = act.GetProps() mapToUse = a.mangaPageButtons case *AnimePageDropdownMenuItem: id = act.ID props = act.GetProps() mapToUse = a.animePageDropdownItems case *AnimeLibraryDropdownMenuItem: id = act.ID props = act.GetProps() mapToUse = a.animeLibraryDropdownItems case *MediaCardContextMenuItem: id = act.ID props = act.GetProps() mapToUse = a.mediaCardContextMenuItems case *EpisodeCardContextMenuItem: id = act.ID props = act.GetProps() mapToUse = a.episodeCardContextMenuItems case *EpisodeGridItemMenuItem: id = act.ID props = act.GetProps() mapToUse = a.episodeGridItemMenuItems } _ = obj.Set("mount", func() { switch m := mapToUse.(type) { case *result.Map[string, *AnimePageButton]: if btn, ok := action.(*AnimePageButton); ok { m.Set(id, btn) a.renderAnimePageButtons() } case *result.Map[string, *MangaPageButton]: if btn, ok := action.(*MangaPageButton); ok { m.Set(id, btn) a.renderMangaPageButtons() } case *result.Map[string, *AnimePageDropdownMenuItem]: if item, ok := action.(*AnimePageDropdownMenuItem); ok { m.Set(id, item) a.renderAnimePageDropdownItems() } case *result.Map[string, *AnimeLibraryDropdownMenuItem]: if item, ok := action.(*AnimeLibraryDropdownMenuItem); ok { m.Set(id, item) a.renderAnimeLibraryDropdownItems() } case *result.Map[string, *MediaCardContextMenuItem]: if item, ok := action.(*MediaCardContextMenuItem); ok { if item.For == "" { item.For = MediaCardContextMenuItemForBoth } m.Set(id, item) a.renderMediaCardContextMenuItems() } case *result.Map[string, *EpisodeCardContextMenuItem]: if item, ok := action.(*EpisodeCardContextMenuItem); ok { m.Set(id, item) a.renderEpisodeCardContextMenuItems() } case *result.Map[string, *EpisodeGridItemMenuItem]: if item, ok := action.(*EpisodeGridItemMenuItem); ok { m.Set(id, item) a.renderEpisodeGridItemMenuItems() } } }) _ = obj.Set("unmount", func() { switch m := mapToUse.(type) { case *result.Map[string, *AnimePageButton]: m.Delete(id) a.renderAnimePageButtons() case *result.Map[string, *MangaPageButton]: m.Delete(id) a.renderMangaPageButtons() case *result.Map[string, *AnimePageDropdownMenuItem]: m.Delete(id) a.renderAnimePageDropdownItems() case *result.Map[string, *AnimeLibraryDropdownMenuItem]: m.Delete(id) a.renderAnimeLibraryDropdownItems() case *result.Map[string, *MediaCardContextMenuItem]: m.Delete(id) a.renderMediaCardContextMenuItems() case *result.Map[string, *EpisodeCardContextMenuItem]: m.Delete(id) a.renderEpisodeCardContextMenuItems() case *result.Map[string, *EpisodeGridItemMenuItem]: m.Delete(id) a.renderEpisodeGridItemMenuItems() } }) _ = obj.Set("setLabel", func(label string) { newProps := props newProps.Label = label switch act := action.(type) { case *AnimePageButton: act.SetProps(newProps) case *MangaPageButton: act.SetProps(newProps) case *AnimePageDropdownMenuItem: act.SetProps(newProps) case *AnimeLibraryDropdownMenuItem: act.SetProps(newProps) case *MediaCardContextMenuItem: act.SetProps(newProps) case *EpisodeCardContextMenuItem: act.SetProps(newProps) case *EpisodeGridItemMenuItem: act.SetProps(newProps) } }) _ = obj.Set("setStyle", func(style map[string]string) { newProps := props newProps.Style = style switch act := action.(type) { case *AnimePageButton: act.SetProps(newProps) a.renderAnimePageButtons() case *MangaPageButton: act.SetProps(newProps) a.renderMangaPageButtons() case *AnimePageDropdownMenuItem: act.SetProps(newProps) a.renderAnimePageDropdownItems() case *AnimeLibraryDropdownMenuItem: act.SetProps(newProps) a.renderAnimeLibraryDropdownItems() case *MediaCardContextMenuItem: act.SetProps(newProps) a.renderMediaCardContextMenuItems() case *EpisodeCardContextMenuItem: act.SetProps(newProps) a.renderEpisodeCardContextMenuItems() case *EpisodeGridItemMenuItem: act.SetProps(newProps) } }) _ = obj.Set("onClick", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { a.ctx.handleTypeError("onClick requires a callback function") } callback, ok := goja.AssertFunction(call.Argument(0)) if !ok { a.ctx.handleTypeError("onClick requires a callback function") } eventListener := a.ctx.RegisterEventListener(ClientActionClickedEvent) eventListener.SetCallback(func(event *ClientPluginEvent) { payload := ClientActionClickedEventPayload{} if event.ParsePayloadAs(ClientActionClickedEvent, &payload) && payload.ActionID == id { a.ctx.scheduler.ScheduleAsync(func() error { _, err := callback(goja.Undefined(), a.ctx.vm.ToValue(payload.Event)) return err }) } }) return goja.Undefined() }) } ///////////////////////////////////////////////////////////////////////////////////// // Utils ///////////////////////////////////////////////////////////////////////////////////// func (a *ActionManager) unmarshalProps(call goja.FunctionCall, ret interface{}) { if len(call.Arguments) < 1 { a.ctx.handleException(fmt.Errorf("expected 1 argument")) } props := call.Arguments[0].Export() if props == nil { a.ctx.handleException(fmt.Errorf("expected props object")) } marshaled, err := json.Marshal(props) if err != nil { a.ctx.handleException(err) } err = json.Unmarshal(marshaled, ret) if err != nil { a.ctx.handleException(err) } }