import { Theme } from "theme.slint"; // A frosted surface wrapped around a shelf or a list, so it reads as something // floating over the stage rather than text on a gradient. export component GlassPanel inherits Rectangle { background: Theme.glass; border-width: 1px; border-color: Theme.glass-border; border-radius: Theme.radius-panel; drop-shadow-color: #00101759; drop-shadow-blur: 24px; drop-shadow-offset-y: 8px; } // Album art. `source` is whatever the cover store has decoded; until it has one, the // Rust side hands over generated art built from the album's own three colours, so this // never has an empty frame to show. // // The shape comes from the media type and from nowhere else: square for an album, // taller than wide for an audiobook, everywhere either appears. export component Cover inherits Rectangle { in property source; in property is-book: false; in property radius: Theme.radius-card; clip: true; border-radius: root.radius; // A book keeps a sliver of its generated spine showing past real square artwork, so // the shelf metaphor survives contact with a photograph. background: root.is-book ? Theme.book-edge-2 : transparent; Image { source: root.source; image-fit: cover; width: root.is-book ? parent.width * 1.05 : parent.width; height: parent.height; x: 0; } } // Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon // font: there is no icon font on the device, and a missing glyph is a blank button. export component TransportButton inherits Rectangle { in property size: 48px; in property primary: false; in property playing: false; in property direction: "play"; // "play" | "next" | "previous" callback clicked(); width: root.size; height: root.size; border-radius: root.size / 2; background: root.primary ? (touch.pressed ? Theme.accent-dim : Theme.accent) : (touch.pressed ? #2a4f57 : Theme.ink); drop-shadow-color: root.primary ? #de73bd80 : #00101766; drop-shadow-blur: root.primary ? 20px : 10px; drop-shadow-offset-y: 4px; animate background { duration: 100ms; } // Pause: two bars. Play and the skips: a triangle, clipped out of a square. if root.direction == "play" && root.playing: HorizontalLayout { alignment: center; spacing: root.size * 0.1; Rectangle { width: root.size * 0.1; height: root.size * 0.34; border-radius: 2px; background: white; } Rectangle { width: root.size * 0.1; height: root.size * 0.34; border-radius: 2px; background: white; } } if root.direction != "play" || !root.playing: HorizontalLayout { alignment: center; spacing: root.size * 0.06; // "Previous" is the same triangle mirrored, plus the bar it butts against. if root.direction == "previous": Rectangle { width: root.size * 0.07; height: root.size * 0.3; border-radius: 1px; background: white; } Rectangle { width: root.size * 0.3; height: root.size * 0.3; Path { width: 100%; height: 100%; fill: white; viewbox-width: 100; viewbox-height: 100; commands: root.direction == "previous" ? "M 100 0 L 100 100 L 0 50 Z" : "M 6 0 L 100 50 L 6 100 Z"; } } if root.direction == "next": Rectangle { width: root.size * 0.07; height: root.size * 0.3; border-radius: 1px; background: white; } } touch := TouchArea { clicked => { root.clicked(); } } } export component Transport inherits HorizontalLayout { in property playing; in property size: 48px; callback toggle(); callback next(); callback previous(); alignment: center; spacing: root.size * 0.21; VerticalLayout { alignment: center; TransportButton { direction: "previous"; size: root.size; clicked => { root.previous(); } } } VerticalLayout { alignment: center; TransportButton { direction: "play"; primary: true; playing: root.playing; // The play button is the one target a child aims at from across the room. size: root.size * 1.26; clicked => { root.toggle(); } } } VerticalLayout { alignment: center; TransportButton { direction: "next"; size: root.size; clicked => { root.next(); } } } } // The seek bar. // // `position` is only pushed twice a second, so the fill is *animated* between frames // rather than recomputed on a client-side clock. That puts the interpolation on the // render side, where it costs a property evaluation per frame instead of a layout pass, // and it is why there is no equivalent of the web client's `usePlaybackClock` here. // // The animation is suppressed whenever `smooth` goes false - while dragging, and for // the frame a track changes on - because sliding the fill across two unrelated // positions is exactly the "it moves slightly backwards" artefact the web UI has. export component ProgressBar inherits Rectangle { in property progress; // 0..1 in property interactive: true; in property bar-height: 10px; in property smooth: true; /// Set on the player bar, whose background is darker than the seek bar's own /// track would otherwise show against. in property on-dark: false; callback seek(float); height: max(root.bar-height, 28px); track := Rectangle { y: (parent.height - root.bar-height) / 2; height: root.bar-height; width: 100%; border-radius: root.bar-height / 2; background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6); clip: true; fill := Rectangle { x: 0; width: parent.width * clamp(root.progress, 0.0, 1.0); height: 100%; border-radius: root.bar-height / 2; background: Theme.accent; animate width { duration: root.smooth ? 500ms : 0ms; easing: linear; } } } // A grab handle only while it is worth having one: at player-bar size the bar is // the target, and a knob would be smaller than the thing it sits on. if root.interactive && root.bar-height >= 12px: Rectangle { x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2)); y: (parent.height - self.height) / 2; width: root.bar-height * 1.7; height: root.bar-height * 1.7; border-radius: self.width / 2; background: Theme.paper; drop-shadow-color: #00101773; drop-shadow-blur: 8px; } if root.interactive: TouchArea { width: 100%; height: 100%; // Both a click and a drag resolve to "the position under the finger", so // scrubbing works without a separate drag state machine. moved => { if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); } } clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); } } } // Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits // a level, where a slider would hand back whatever pixel they landed on. export component VolumeBars inherits HorizontalLayout { in property volume; // 0..100 in property bar-width: 11px; in property bar-height: 30px; callback set-volume(int); callback mute(); spacing: root.bar-width * 0.3; alignment: center; VerticalLayout { alignment: center; Rectangle { width: root.bar-height * 0.8; height: root.bar-height * 0.8; border-radius: self.width / 2; background: speaker.has-hover ? #ffffff26 : transparent; Text { text: root.volume == 0 ? "🔇" : "🔊"; font-size: root.bar-width * 1.6; vertical-alignment: center; horizontal-alignment: center; } speaker := TouchArea { clicked => { root.mute(); } } } } for level[i] in [20, 40, 60, 80, 100]: VerticalLayout { alignment: center; Rectangle { width: root.bar-width; // Stepped heights, so the control reads as a volume ramp at a glance. height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5); border-radius: 5px; background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4); animate background { duration: 120ms; } TouchArea { clicked => { root.set-volume(level); } } } } } // A round icon button - back, close, and the fixed corner controls. export component RoundButton inherits Rectangle { in property label; in property size: 44px; in property highlighted: false; callback clicked(); width: root.size; height: root.size; border-radius: root.size / 2; background: root.highlighted ? Theme.accent : (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95)); drop-shadow-color: #00101766; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px; Text { text: root.label; font-size: root.size * 0.45; font-weight: 900; color: root.highlighted ? white : Theme.ink; vertical-alignment: center; horizontal-alignment: center; } touch := TouchArea { clicked => { root.clicked(); } } }