Boot splash and periodic announcement banner
Splash: resources/splash/splash.gif built into the binary, played once by SplashOverlay over a black cover present from the first frame (no UI flash), then fades into the UI; hides icons loading. GIF, so no QtMultimedia/gstreamer. Toggle: splash.enabled. Banner (InfoBanner): metro-style band that fades/slides in with a hold pictogram; text that does not fit runs as a seamless ticker for two rounds. Never takes touches; hidden during splash and settings. Config section banner (enabled, text, intervalSec, showSec, position, accentColor), editable in the web UI and on-device settings; shown once after saving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user