package gui import ( "log" "math" "slices" "strings" "sync" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "suruatoel.xyz/mcg/data" "suruatoel.xyz/mcg/internal/client" "suruatoel.xyz/mcg/internal/common" ) type libraryPanel struct { *adw.Bin toolbar *gtk.Box selectButton *gtk.ToggleButton actionbarRevealer *gtk.Revealer toolbarPopover *gtk.Popover searchBar *gtk.SearchBar searchEntry *gtk.SearchEntry sortButtons map[string]*gtk.CheckButton sortDescButton *gtk.CheckButton gridScale *gtk.Scale stack *gtk.Stack progressBox *gtk.Box progressBar *gtk.ProgressBar scroll *gtk.ScrolledWindow libraryGrid *gtk.GridView gridFactory *pictureItemFactory[string] libraryModel *gtk.StringList selectionMulti *gtk.MultiSelection selectionSingle *gtk.SingleSelection headerbarStandalone *albumHeaderbar libraryStack *gtk.Stack panelNormal *gtk.Box panelStandalone *gtk.Box standaloneStack *gtk.Stack standaloneSpinner *gtk.Spinner standaloneScroll *gtk.ScrolledWindow standaloneImage *gtk.Image standalonePlayButton *gtk.Button standaloneQueueButton *gtk.Button selectionQueueButton *gtk.Button selectionCancelButton *gtk.Button coverLoader *CoverLoader albums map[string]*common.Album pixbufs map[string]*gdkpixbuf.Pixbuf sortField string sortDesc bool itemSize int itemSizeCallback ItemSizeCallback libraryLock sync.Mutex libraryWait sync.WaitGroup standaloneAlbum *common.Album standalonePixbuf *gdkpixbuf.Pixbuf standaloneCallback func(bool) } type ItemSizeCallback func(itemSize int) func newLibraryPanel(config Configuration) *libraryPanel { panel := libraryPanel{ albums: make(map[string]*common.Album), pixbufs: make(map[string]*gdkpixbuf.Pixbuf), sortField: "year", sortDesc: false, libraryLock: sync.Mutex{}, libraryWait: sync.WaitGroup{}, } panel.loadWidgets(config) panel.loadDefaultValues(config) panel.connectSignals() return &panel } func (p *libraryPanel) loadWidgets(config Configuration) { builder := gtk.NewBuilderFromString(data.LibraryPanelXML) p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box) p.selectButton = builder.GetObject("select_button").Cast().(*gtk.ToggleButton) p.actionbarRevealer = builder.GetObject("actionbar_revealer").Cast().(*gtk.Revealer) p.toolbarPopover = builder.GetObject("toolbar_popover").Cast().(*gtk.Popover) p.searchBar = builder.GetObject("filter_bar").Cast().(*gtk.SearchBar) p.searchEntry = builder.GetObject("filter_entry").Cast().(*gtk.SearchEntry) p.sortButtons = make(map[string]*gtk.CheckButton) p.sortButtons["artist"] = builder.GetObject("sort_artist").Cast().(*gtk.CheckButton) p.sortButtons["title"] = builder.GetObject("sort_title").Cast().(*gtk.CheckButton) p.sortButtons["year"] = builder.GetObject("sort_year").Cast().(*gtk.CheckButton) p.sortButtons["modified"] = builder.GetObject("sort_modified").Cast().(*gtk.CheckButton) p.sortDescButton = builder.GetObject("toolbar_sort_order_button").Cast().(*gtk.CheckButton) p.gridScale = builder.GetObject("grid_scale").Cast().(*gtk.Scale) p.Bin = builder.GetObject("McgLibraryPanel").Cast().(*adw.Bin) p.stack = builder.GetObject("stack").Cast().(*gtk.Stack) p.progressBox = builder.GetObject("progress_box").Cast().(*gtk.Box) p.progressBar = builder.GetObject("progress_bar").Cast().(*gtk.ProgressBar) p.scroll = builder.GetObject("scroll").Cast().(*gtk.ScrolledWindow) p.libraryGrid = builder.GetObject("library_grid").Cast().(*gtk.GridView) p.libraryModel = gtk.NewStringList(nil) p.selectionMulti = gtk.NewMultiSelection(p.libraryModel) p.selectionSingle = gtk.NewSingleSelection(p.libraryModel) p.libraryGrid.SetModel(p.selectionSingle) p.gridFactory = newPictureItemFactory[string](p.itemToModelID, p.modelIDToValue) p.libraryGrid.SetFactory(p.gridFactory.ListItemFactory()) // Headerbar p.headerbarStandalone = newAlbumHeaderbar() p.libraryStack = builder.GetObject("library_stack").Cast().(*gtk.Stack) p.panelNormal = builder.GetObject("panel_normal").Cast().(*gtk.Box) p.panelStandalone = builder.GetObject("panel_standalone").Cast().(*gtk.Box) p.standaloneStack = builder.GetObject("standalone_stack").Cast().(*gtk.Stack) p.standaloneSpinner = builder.GetObject("standalone_spinner").Cast().(*gtk.Spinner) p.standaloneScroll = builder.GetObject("standalone_scroll").Cast().(*gtk.ScrolledWindow) p.standaloneImage = builder.GetObject("standalone_image").Cast().(*gtk.Image) p.standalonePlayButton = builder.GetObject("standalone_play_button").Cast().(*gtk.Button) p.standaloneQueueButton = builder.GetObject("standalone_queue_button").Cast().(*gtk.Button) // Selection p.selectionQueueButton = builder.GetObject("selection_queue_button").Cast().(*gtk.Button) p.selectionCancelButton = builder.GetObject("selection_cancel_button").Cast().(*gtk.Button) } func (p *libraryPanel) itemToModelID(item *glib.Object) string { return item.Cast().(*gtk.StringObject).String() } func (p *libraryPanel) modelIDToValue(itemID string) (tooltip string, pixbuf *gdkpixbuf.Pixbuf) { if album, ok := p.albums[itemID]; ok { tooltip = album.Tooltip() } pixbuf = p.pixbufs[itemID] return } func (p *libraryPanel) loadDefaultValues(config Configuration) { p.itemSize = config.ItemSize p.gridScale.SetValue(float64(config.ItemSize)) _, found := p.sortButtons[config.SortField] if found { p.sortField = config.SortField } p.sortDesc = config.SortDesc p.sortButtons[p.sortField].SetActive(true) p.sortDescButton.SetActive(p.sortDesc) } func (p *libraryPanel) connectSignals() { p.selectButton.ConnectToggled(p.onSelectToggled) p.searchEntry.ConnectSearchChanged(p.onFilterEntryChanged) for field, button := range p.sortButtons { button.ConnectToggled(func() { p.onSortToggled(field, button) }) } p.sortDescButton.ConnectToggled(p.onSortDescToggled) p.libraryGrid.ConnectActivate(p.onGridClicked) p.headerbarStandalone.ConnectClose(p.onHeaderbarStandaloneClose) p.standalonePlayButton.ConnectClicked(p.onStandalonePlayClicked) p.standaloneQueueButton.ConnectClicked(p.onStandaloneQueueClicked) p.selectionQueueButton.ConnectClicked(p.onSelectionQueue) p.selectionCancelButton.ConnectClicked(p.onSelectionCancel) buttonController := gtk.NewGestureClick() buttonController.ConnectUnpairedRelease(p.onGridScaleReleased) p.gridScale.AddController(buttonController) p.gridScale.ConnectValueChanged(p.onGridScaleChanged) } func (p *libraryPanel) ConnectItemSizeChanged(callback ItemSizeCallback) { p.itemSizeCallback = callback } func (p *libraryPanel) ConnectStandalone(callback func(bool)) { p.standaloneCallback = callback } func (p *libraryPanel) onSelectToggled() { if p.selectButton.Active() { p.actionbarRevealer.SetRevealChild(true) p.libraryGrid.SetModel(p.selectionMulti) p.libraryGrid.SetSingleClickActivate(false) p.libraryGrid.StyleContext().AddClass("selection") } else { p.actionbarRevealer.SetRevealChild(false) p.libraryGrid.SetModel(p.selectionSingle) p.libraryGrid.SetSingleClickActivate(true) p.libraryGrid.StyleContext().RemoveClass("selection") } } func (p *libraryPanel) onFilterEntryChanged() { p.updateGridModel() } func (p *libraryPanel) onSortToggled(field string, button *gtk.CheckButton) { if !button.Active() { return } p.sortField = field p.updateGridModel() } func (p *libraryPanel) onSortDescToggled() { p.sortDesc = p.sortDescButton.Active() p.updateGridModel() } func (p *libraryPanel) onGridScaleChanged() { size := math.Floor(p.gridScale.Value()) gridRange := p.gridScale.Adjustment() if size < gridRange.Lower() || size > gridRange.Upper() { return } go p.setGridSize(int(size)) } func (p *libraryPanel) setGridSize(size int) { if size == p.itemSize { return } for i := range p.libraryGrid.Model().NItems() { itemObj := p.libraryGrid.Model().Item(i) if itemObj != nil { itemStr := itemObj.Cast().(*gtk.StringObject) albumID := itemStr.String() picture, pictureFound := p.gridFactory.Picture(albumID) if pictureFound { pixbuf, pixbufFound := p.pixbufs[albumID] if pixbufFound { if pixbuf != nil { pixbuf = pixbuf.ScaleSimple(size, size, gdkpixbuf.InterpNearest) } else { } glib.IdleAdd(func() { picture.SetPaintable(gdk.NewTextureForPixbuf(pixbuf)) }) } } } } } func (p *libraryPanel) onGridScaleReleased(x float64, y float64, button uint, sequence *gdk.EventSequence) { size := math.Floor(p.gridScale.Value()) gridRange := p.gridScale.Adjustment() if size < gridRange.Lower() || size > gridRange.Upper() { return } p.itemSize = int(size) p.redraw() glib.IdleAdd(p.toolbarPopover.Popdown) if p.itemSizeCallback != nil { p.itemSizeCallback(p.itemSize) } } func (p *libraryPanel) redraw() { if len(p.albums) > 0 { p.SetAlbums(p.albums) } } func (p *libraryPanel) onGridClicked(position uint) { // Get selected album itemObj := p.libraryModel.Item(position) if itemObj == nil { return } strObj := itemObj.Cast().(*gtk.StringObject) id := strObj.String() album, found := p.albums[id] if !found { return } // Show standalone album if p.libraryGrid.Model().Native() == p.selectionSingle.Native() { p.standaloneAlbum = album p.headerbarStandalone.SetAlbum(album) p.standaloneStack.SetVisibleChild(p.standaloneSpinner) p.standaloneSpinner.Start() p.openStandalone() client.Instance().LoadAlbumart(album) } } func (p *libraryPanel) onHeaderbarStandaloneClose() { p.closeStandalone() } func (p *libraryPanel) openStandalone() { p.libraryStack.SetVisibleChild(p.panelStandalone) if p.standaloneCallback != nil { p.standaloneCallback(true) } } func (p *libraryPanel) closeStandalone() { p.libraryStack.SetVisibleChild(p.panelNormal) if p.standaloneCallback != nil { p.standaloneCallback(false) } } func (p *libraryPanel) setCoverLoader(loader *CoverLoader) { p.coverLoader = loader } func (p *libraryPanel) SetAlbumart(album *common.Album, albumart []byte) { if album != p.standaloneAlbum { return } p.standalonePixbuf = nil if albumart == nil || len(albumart) == 0 { return } loader, loaderErr := loadPixbuf(albumart) if loaderErr != nil { log.Println("failed to load albumart for ", album.Title(), ":", loaderErr) return } p.standalonePixbuf = loader.Pixbuf() glib.IdleAdd(p.showStandaloneImage) } func (p *libraryPanel) showStandaloneImage() { p.resizeStandaloneImage() p.standaloneStack.SetVisibleChild(p.standaloneScroll) p.standaloneSpinner.Stop() } func (p *libraryPanel) resizeStandaloneImage() { newWidth, newHeight := p.getStandaloneSize() if p.standalonePixbuf == nil { p.standaloneImage.SetPixelSize(min(newWidth, newHeight)) return } width, height := p.calculateCoverSize(newWidth, newHeight) if width <= 0 || height <= 0 { return } scaledPixbuf := p.standalonePixbuf.ScaleSimple(width, height, gdkpixbuf.InterpHyper) p.standaloneImage.SetFromPaintable(gdk.NewTextureForPixbuf(scaledPixbuf)) p.standaloneImage.SetPixelSize(min(width, height)) } func (p *libraryPanel) getStandaloneSize() (int, int) { return p.standaloneStack.Size(gtk.OrientationHorizontal), p.standaloneStack.Size(gtk.OrientationVertical) } func (p *libraryPanel) calculateCoverSize(currentWidth, currentHeight int) (width, height int) { ratioWidth := float64(currentWidth) / float64(p.standalonePixbuf.Width()) ratioHeight := float64(currentHeight) / float64(p.standalonePixbuf.Height()) ratio := min(min(ratioWidth, ratioHeight), 1) width = int(math.Floor(float64(p.standalonePixbuf.Width()) * ratio)) height = int(math.Floor(float64(p.standalonePixbuf.Height()) * ratio)) return } func (p *libraryPanel) onStandalonePlayClicked() { client.Instance().PlayAlbum(p.standaloneAlbum) p.closeStandalone() } func (p *libraryPanel) onStandaloneQueueClicked() { client.Instance().QueueAlbum(p.standaloneAlbum) p.closeStandalone() } func (p *libraryPanel) onSelectionQueue() { albums := p.getSelectedAlbums() if len(albums) > 0 { client.Instance().QueueAlbums(albums) } p.selectButton.SetActive(false) } func (p *libraryPanel) onSelectionCancel() { p.selectButton.SetActive(false) } func (p *libraryPanel) getSelectedAlbums() []*common.Album { albums := []*common.Album{} for i := range p.selectionMulti.NItems() { if p.selectionMulti.IsSelected(i) { itemObj := p.selectionMulti.Item(i) if itemObj != nil { itemStr := itemObj.Cast().(*gtk.StringObject) albumID := itemStr.String() album, found := p.albums[albumID] if found { albums = append(albums, album) } } } } return albums } func (p *libraryPanel) UpdateConfig(config *Configuration) { config.SortField = p.sortField config.SortDesc = p.sortDesc config.ItemSize = p.itemSize } func (p *libraryPanel) updateGridModel() { albums := p.filterAlbums() p.sortAlbums(albums) p.updateGrid(albums) } func (p *libraryPanel) filterAlbums() []*common.Album { var matchingAlbums []*common.Album query := strings.TrimSpace(strings.ToLower(p.searchEntry.Text())) for _, album := range p.albums { if album.IsMatch(query) { matchingAlbums = append(matchingAlbums, album) } } return matchingAlbums } func (p *libraryPanel) sortAlbums(filtered []*common.Album) { common.SortAlbums(filtered, p.sortField) if p.sortDesc { slices.Reverse(filtered) } } func (p *libraryPanel) updateGrid(albums []*common.Album) { glib.IdleAdd(func() { n := p.libraryModel.NItems() if n > 0 { p.libraryModel.Splice(0, n, nil) } ids := make([]string, 0, len(albums)) for _, item := range albums { ids = append(ids, item.ID()) } if len(ids) > 0 { p.libraryModel.Splice(0, 0, ids) } }) } func (p *libraryPanel) Toolbar() gtk.Widgetter { return p.toolbar } func (p *libraryPanel) Wait() { p.libraryWait.Wait() } type LibraryFactory gtk.ListItemFactory func (p *libraryPanel) SetAlbums(albums map[string]*common.Album) { p.libraryWait.Add(1) go p.setAlbums(albums) } func (p *libraryPanel) setAlbums(albums map[string]*common.Album) { p.libraryLock.Lock() defer p.libraryLock.Unlock() defer p.libraryWait.Done() p.albums = albums glib.IdleAdd(func() { p.stack.SetVisibleChild(p.progressBox) p.progressBar.SetFraction(0.0) }) // Create loader loader i := 0 n := len(albums) p.pixbufs = make(map[string]*gdkpixbuf.Pixbuf) for _, album := range albums { if p.coverLoader != nil { p.pixbufs[album.ID()] = p.coverLoader.LoadThumbnail(album) } i++ glib.IdleAdd(func() { p.progressBar.SetFraction(float64(i) / float64(n)) // FIXME: i18n p.progressBar.SetText("Loading images") }) } p.updateGridModel() glib.IdleAdd(func() { p.stack.SetVisibleChild(p.scroll) }) } func (p *libraryPanel) SetWidth(width int) { p.resizeStandaloneImage() p.setMarks(width) } func (p *libraryPanel) setMarks(width int) { glib.IdleAdd(func() { p.gridScale.ClearMarks() }) lower := p.gridScale.Adjustment().Lower() upper := p.gridScale.Adjustment().Upper() countMin := int(math.Max(float64(width)/upper, 1)) countMax := int(math.Max(float64(width)/lower, 1)) for i := countMin; i <= countMax; i++ { pixel := int(float64(width) / float64(i)) pixel = pixel - (2 * pixel / 100) glib.IdleAdd(func() { p.gridScale.AddMark(float64(pixel), gtk.PosBottom, "") }) } } func (p *libraryPanel) ShowSearch() { p.searchBar.SetSearchMode(true) } func (p *libraryPanel) HeaderbarStandalone() *albumHeaderbar { return p.headerbarStandalone }