import QtQuick 2.12 // Periodic announcement band, metro style ("Для вызова клининга удерживайте // кнопку"). Fades/slides in, text glides in; text that does not fit runs as // a seamless ticker for scrollLoops rounds and stops back on its beginning; // then the band fades out. Never takes touches. // Config section "banner": enabled, text, intervalSec, showSec, position, // accentColor (SettingsContainer clamps the numbers). Item { id: banner // Set by Main.qml: splash playing or a settings dialog open. property bool suppressed: false readonly property bool atBottom: settingsContainer.bannerPosition !== "top" readonly property color accent: settingsContainer.bannerAccentColor readonly property int slide: atBottom ? 28 : -28 // Durations computed in show(): an animation group cannot follow // bindings while it runs. property int scrollPause: 0 property int scrollMs: 0 // one ticker round property int restMs: 0 property real cycle: 0 // text width + gap = one round readonly property int scrollLoops: 2 readonly property int tickerGap: 80 readonly property real tickerSpeed: 70 // px/s height: 66 visible: band.opacity > 0 // Preview: show as soon as nothing covers the screen (the settings // dialog may still be closing when this is called). property bool pending: false function preview() { pending = true show() } function show() { if (seq.running || banner.suppressed) return pending = false if (settingsContainer.bannerText.length === 0) return var overflow = label.implicitWidth > textBox.width cycle = overflow ? label.implicitWidth + tickerGap : 0 scrollPause = overflow ? 1200 : 0 scrollMs = overflow ? Math.round(cycle / tickerSpeed * 1000) : 0 var fixed = 500 + 450 + scrollPause + scrollMs * scrollLoops + 600 restMs = Math.max(overflow ? 1000 : 1500, settingsContainer.bannerShowSec * 1000 - fixed) seq.restart() } Timer { id: period interval: settingsContainer.bannerIntervalSec * 1000 repeat: true running: settingsContainer.bannerEnabled onTriggered: banner.show() } // Show once soon after the settings change (saved from the web or the // device), so the result can be seen right away. readonly property string signature: [settingsContainer.bannerEnabled, settingsContainer.bannerText, settingsContainer.bannerShowSec, settingsContainer.bannerPosition, settingsContainer.bannerAccentColor].join("|") property bool ready: false onSignatureChanged: if (ready && settingsContainer.bannerEnabled) previewTimer.restart() Component.onCompleted: ready = true Timer { id: previewTimer interval: 1200 onTriggered: banner.preview() } onSuppressedChanged: { if (suppressed && seq.running) { seq.stop() band.opacity = 0 } else if (!suppressed && pending) { show() } } Rectangle { id: band anchors.fill: parent opacity: 0 radius: 12 color: "#e6101426" border.color: "#30ffffff" border.width: 1 clip: true transform: Translate { id: shift; y: 0 } // Soft sheen across the band. Rectangle { anchors.fill: parent radius: parent.radius gradient: Gradient { GradientStop { position: 0.0; color: "#18ffffff" } GradientStop { position: 0.5; color: "#00ffffff" } } } // Line-colour stripe, like a metro line marker; wipes in. Rectangle { id: stripe property real reveal: 0 anchors.left: parent.left anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter width: 6 height: (parent.height - 18) * reveal radius: 3 color: banner.accent } // "Hold" pictogram: a ring that fills up, then restarts. Canvas { id: holdIcon property real progress: 0 anchors.left: stripe.right anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter width: 40 height: 40 onProgressChanged: requestPaint() onPaint: { var ctx = getContext("2d") ctx.reset() var c = width / 2, r = width / 2 - 3 ctx.lineWidth = 3 ctx.strokeStyle = "rgba(255,255,255,0.25)" ctx.beginPath(); ctx.arc(c, c, r, 0, 2 * Math.PI); ctx.stroke() ctx.strokeStyle = settingsContainer.bannerAccentColor ctx.lineCap = "round" ctx.beginPath() ctx.arc(c, c, r, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * progress) ctx.stroke() ctx.fillStyle = "#ffffff" ctx.beginPath(); ctx.arc(c, c, 6 + 3 * progress, 0, 2 * Math.PI); ctx.fill() } NumberAnimation on progress { from: 0; to: 1 duration: 1600 loops: Animation.Infinite running: banner.visible } } Item { id: textBox anchors.left: holdIcon.right anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 14 anchors.top: parent.top anchors.bottom: parent.bottom clip: true // Text + its copy one gap behind: after a full round the copy // sits exactly where the text started, so the loop is seamless. Row { id: ticker height: parent.height opacity: 0 spacing: banner.tickerGap Text { id: label height: parent.height text: settingsContainer.bannerText color: "#ffffff" font.pixelSize: 21 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } Text { visible: banner.cycle > 0 height: parent.height text: label.text color: label.color font: label.font verticalAlignment: Text.AlignVCenter } } } } SequentialAnimation { id: seq ScriptAction { script: { ticker.x = 28 ticker.opacity = 0 stripe.reveal = 0 } } ParallelAnimation { NumberAnimation { target: band; property: "opacity"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic } NumberAnimation { target: shift; property: "y"; from: banner.slide; to: 0; duration: 500; easing.type: Easing.OutCubic } SequentialAnimation { PauseAnimation { duration: 150 } NumberAnimation { target: stripe; property: "reveal"; to: 1; duration: 350; easing.type: Easing.OutCubic } } } ParallelAnimation { NumberAnimation { target: ticker; property: "x"; to: 0; duration: 450; easing.type: Easing.OutCubic } NumberAnimation { target: ticker; property: "opacity"; to: 1; duration: 450 } } PauseAnimation { duration: banner.scrollPause } NumberAnimation { target: ticker; property: "x" from: 0; to: -banner.cycle duration: banner.scrollMs loops: banner.scrollLoops } PauseAnimation { duration: banner.restMs } ParallelAnimation { NumberAnimation { target: band; property: "opacity"; to: 0; duration: 600; easing.type: Easing.InCubic } NumberAnimation { target: shift; property: "y"; to: banner.slide / 2; duration: 600; easing.type: Easing.InCubic } } } }