import { ListView } from "std-widgets.slint"; import { Theme } from "theme.slint"; import { GlassPanel, Cover } from "widgets.slint"; import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint"; // Heights are fixed rather than derived from content, and that is the whole reason the // grid can be virtualized: a `ListView` that has to measure a row before it can decide // whether to skip it has not skipped anything. The web grid does the same thing with // `contain-intrinsic-size` for the same reason. // // A book is taller than an album, so the cover *slot* is sized for the taller of the // two and each cover is centred in it. That keeps every row identical without // flattening the shape difference that tells the two media apart. export global Metrics { out property cover-slot: 200px; out property card-text: 78px; out property card-height: 278px; // cover-slot + card-text out property row-height: 302px; // card-height + grid-gap out property shelf-height: 236px; out property category-row-height: 260px; } component SelectionRing inherits Rectangle { in property selected; in property current; // Selection outranks "currently playing": when the keyboard is on the album that is // also playing, the ring it needs to see is the one that moves. border-width: root.selected ? 5px : (root.current ? 4px : 0px); border-color: root.selected ? Theme.paper : Theme.accent; border-radius: Theme.radius-card + 6px; } component AlbumCard inherits Rectangle { in property tile; in property selected; callback open(); callback play(); height: Metrics.card-height; ring := SelectionRing { selected: root.selected; current: root.tile.current; x: -7px; y: -7px; width: parent.width + 14px; height: parent.height + 14px; } card := Rectangle { background: root.tile.is-book ? Theme.card-book : Theme.card-music; border-radius: Theme.radius-card; drop-shadow-color: #00101759; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px; // The lift on selection: 120ms, the same as the web card's transition. y: root.selected ? -3px : 0px; animate y { duration: 120ms; easing: ease-out; } VerticalLayout { padding: 10px; spacing: 6px; // The cover, centred in a slot tall enough for the taller media type. Rectangle { height: Metrics.cover-slot - 20px; HorizontalLayout { alignment: center; Cover { source: root.tile.cover; is-book: root.tile.is-book; height: parent.height; width: root.tile.is-book ? parent.height * 0.82 : parent.height; } } TouchArea { // Cover opens the track list; the title below starts it playing. // Two targets, because "show me what is in this" and "play this" // are different intentions and a child has both. clicked => { root.open(); } } } Rectangle { height: Metrics.card-text - 26px; VerticalLayout { spacing: 1px; Text { text: root.tile.title; font-size: Theme.text-body - 1px; font-weight: 800; color: Theme.ink; wrap: word-wrap; overflow: elide; // Two lines always, whether or not the title needs them, so // every card in the row ends at the same place. height: (Theme.text-body - 1px) * 2.4; } Text { text: root.tile.artist; font-size: Theme.text-small - 1px; font-weight: 700; color: Theme.ink.with-alpha(0.65); overflow: elide; } } TouchArea { clicked => { root.play(); } } } Text { text: root.tile.meta; font-size: Theme.text-tiny; font-weight: 800; color: Theme.count-pink; overflow: elide; } } } } component CategoryCard inherits Rectangle { in property tile; in property selected; in property tile-width: Theme.shelf-tile; callback open(); width: root.tile-width; height: Metrics.shelf-height; SelectionRing { selected: root.selected; current: false; x: -7px; y: -7px; width: parent.width + 14px; height: parent.height + 14px; } Rectangle { background: root.tile.is-book ? Theme.card-book : Theme.card-music; border-radius: Theme.radius-card; drop-shadow-color: #00101759; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px; y: root.selected ? -3px : 0px; animate y { duration: 120ms; easing: ease-out; } VerticalLayout { padding: 8px; spacing: 5px; // One album fills the tile; several show as a 2x2 contact sheet, which is // how a category reads as "a stack of these" without a label saying so. Rectangle { height: root.tile-width - 16px; clip: true; if root.tile.cover-count <= 1: Cover { source: root.tile.cover1; is-book: root.tile.is-book; width: 100%; height: 100%; } if root.tile.cover-count > 1: VerticalLayout { spacing: 4px; HorizontalLayout { spacing: 4px; Cover { source: root.tile.cover1; radius: 6px; } Cover { source: root.tile.cover2; radius: 6px; } } HorizontalLayout { spacing: 4px; Cover { source: root.tile.cover3; radius: 6px; } Cover { source: root.tile.cover4; radius: 6px; } } } } Text { text: root.tile.key; font-size: Theme.text-body; font-weight: 800; color: Theme.ink; overflow: elide; } Text { text: root.tile.count-label; font-size: Theme.text-tiny; font-weight: 800; color: Theme.count-pink; overflow: elide; } } TouchArea { clicked => { root.open(); } } } } component TrackListRow inherits Rectangle { in property track; in property selected; callback play(); height: 58px; background: root.track.current ? Theme.row-active : Theme.row-idle; border-radius: Theme.radius-row; border-width: root.selected ? 3px : 1px; border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border); HorizontalLayout { padding-left: 12px; padding-right: 16px; spacing: 12px; VerticalLayout { alignment: center; Cover { source: root.track.cover; is-book: root.track.is-book; radius: 8px; height: 40px; width: root.track.is-book ? 33px : 40px; } } VerticalLayout { alignment: center; Text { text: root.track.title; font-size: Theme.text-body; font-weight: 800; color: Theme.paper; overflow: elide; } Text { text: root.track.subtitle; font-size: Theme.text-tiny; font-weight: 700; color: Theme.text-sub.with-alpha(0.75); overflow: elide; } } VerticalLayout { alignment: center; Text { text: root.track.duration; font-family: Theme.mono; font-size: Theme.text-small; font-weight: 700; color: Theme.text-dim.with-alpha(0.8); } } } TouchArea { clicked => { root.play(); } } } // The root screen: three shelves, each scrolling sideways. export component ShelfView inherits Flickable { in property <[Shelf]> shelves; in property shelf-row; in property sel-index; callback enter-group(int); // shelf index callback enter-category(int, string); // shelf index, category key content-height: layout.preferred-height; content-width: self.width; layout := VerticalLayout { padding-left: 32px; padding-right: Theme.rail-clearance; padding-top: 14px; // Clear of the player bar, which floats over this rather than displacing it. padding-bottom: 210px; spacing: 25px; for shelf[s] in root.shelves: GlassPanel { height: Metrics.shelf-height + 74px; // The panel itself is a target: it opens the whole group. TouchArea { clicked => { root.enter-group(s); } } VerticalLayout { padding: 14px; spacing: 10px; HorizontalLayout { spacing: 10px; alignment: start; // A round frosted badge behind the emoji, which otherwise renders // thin and low-contrast straight on the stage gradient. Rectangle { width: 34px; height: 34px; border-radius: 17px; background: Theme.paper.with-alpha(0.2); border-width: 1px; border-color: Theme.paper.with-alpha(0.4); Text { text: shelf.icon; font-size: 17px; vertical-alignment: center; horizontal-alignment: center; } } VerticalLayout { alignment: center; Text { text: shelf.label; font-size: Theme.text-heading; font-weight: 900; color: Theme.paper; } } VerticalLayout { alignment: center; Text { text: "›"; font-size: 17px; font-weight: 900; color: Theme.paper.with-alpha(0.6); } } } if shelf.tiles.length == 0: Text { text: shelf.empty-label; font-size: Theme.text-small; font-weight: 700; color: Theme.text-dim.with-alpha(0.6); horizontal-alignment: center; vertical-alignment: center; } if shelf.tiles.length > 0: Flickable { content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px; content-height: self.height; // Keep the focused tile in view as the arrow keys walk past the fold. content-x: root.shelf-row != s ? 0px : -max(0px, min( self.content-width - self.width, root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2)); animate content-x { duration: 180ms; easing: ease-out; } HorizontalLayout { spacing: 12px; padding: 10px; alignment: start; for tile[i] in shelf.tiles: CategoryCard { tile: tile; selected: root.shelf-row == s && root.sel-index == i; open => { root.enter-category(s, tile.key); } } } } } } } } // Inside a group, or showing search results. // // Exactly one of the three lists is ever populated, and that is a property of the query // model rather than a coincidence worth defending against: track hits only exist in // track-search mode, which forces the album list empty; the category list only exists // when there is neither a query nor a chosen category, which is exactly when the album // list is empty. So this shows one list filling the page, instead of stacking three // inside a scroller and nesting a second scroller in it. export component ResultsView inherits VerticalLayout { in property group-label; in property category-heading; in property album-heading; in property show-search; in property search-text; in property mode-label; in property count-label; in property <[TrackRow]> songs; in property <[CategoryRow]> category-grid; in property <[GridRow]> grid; in property sel-index; /// Which row of the visible grid holds the selection. Every row is the same height /// by construction, so following the keyboard is arithmetic, not measurement. in property sel-row; in property empty; callback play-song(string, int); callback open-category(string); callback open-album(string); callback play-album(string); spacing: 6px; padding-left: 32px; padding-right: Theme.rail-clearance; if root.group-label != "": Text { text: root.group-label; font-size: 15px; font-weight: 800; color: Theme.paper; horizontal-alignment: center; } if root.show-search: HorizontalLayout { alignment: center; spacing: 12px; padding-bottom: 4px; Rectangle { background: Theme.paper; border-radius: Theme.pill; drop-shadow-color: #00101759; drop-shadow-blur: 14px; drop-shadow-offset-y: 4px; width: chip.preferred-width; height: chip.preferred-height; chip := HorizontalLayout { padding-left: 10px; padding-right: 20px; padding-top: 10px; padding-bottom: 10px; spacing: 10px; Rectangle { background: Theme.ink; border-radius: Theme.pill; width: label.preferred-width; label := HorizontalLayout { padding-left: 10px; padding-right: 10px; padding-top: 4px; padding-bottom: 4px; Text { text: root.mode-label; font-size: Theme.text-small + 1px; font-weight: 800; color: white; } } } Text { text: root.search-text; font-size: Theme.text-heading; font-weight: 800; color: Theme.ink; vertical-alignment: center; } } } VerticalLayout { alignment: center; Text { text: root.count-label; font-size: Theme.text-small + 1px; font-weight: 700; color: Theme.text-dim.with-alpha(0.85); } } } if root.empty: VerticalLayout { alignment: center; Text { text: "Nichts gefunden — probier andere Buchstaben!"; font-size: Theme.text-heading; font-weight: 800; color: Theme.paper; horizontal-alignment: center; } } // --- track hits ----------------------------------------------------------- // Capped at 40 rows upstream, so this one does not need virtualizing. Held to a // readable column rather than the full width, which is what lets a long album // line elide instead of running the width of a 1080p screen. The heading rides // inside that column so the two are aligned with each other, not with the page. if root.songs.length > 0: Flickable { content-height: songs-column.preferred-height + 210px; content-width: self.width; HorizontalLayout { alignment: center; songs-column := VerticalLayout { // Without this the column is centred in whatever vertical space is // left over, which on a short result list leaves it floating. alignment: start; width: 860px; spacing: 8px; Text { text: "Titel"; font-size: Theme.text-heading; font-weight: 900; color: Theme.paper; } GlassPanel { height: song-rows.preferred-height; song-rows := VerticalLayout { padding: 12px; spacing: 6px; for song in root.songs: TrackListRow { track: song; selected: root.sel-index == song.nav; play => { root.play-song(song.album-id, song.index); } } } } } } } // --- categories ----------------------------------------------------------- if root.category-grid.length > 0: Text { text: root.category-heading; font-size: Theme.text-heading; font-weight: 900; color: Theme.paper; } if root.category-grid.length > 0: ListView { for row in root.category-grid: HorizontalLayout { height: Metrics.category-row-height; spacing: Theme.grid-gap; alignment: start; for tile in row.tiles: CategoryCard { tile: tile; tile-width: Theme.card-width; selected: root.sel-index == tile.nav; open => { root.open-category(tile.key); } } } } // --- albums --------------------------------------------------------------- if root.grid.length > 0: Text { text: root.album-heading; font-size: Theme.text-heading; font-weight: 900; color: Theme.paper; } if root.grid.length > 0: album-list := ListView { // Follow the keyboard without measuring anything. content-y: -max(0px, min( max(0px, self.content-height - self.height), root.sel-row * Metrics.row-height)); animate content-y { duration: 180ms; easing: ease-out; } for row in root.grid: HorizontalLayout { height: Metrics.row-height; spacing: Theme.grid-gap; alignment: start; for tile in row.tiles: AlbumCard { width: Theme.card-width; tile: tile; selected: root.sel-index == tile.nav; open => { root.open-album(tile.id); } play => { root.play-album(tile.id); } } } } }