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:
2026-11-10 10:58:16 +00:00
co-authored by Claude Opus 5.5
parent ec84133417
commit f07e153f09
22 changed files with 753 additions and 3 deletions
+228
View File
@@ -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 }
}
}
}