mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather than a file browser, with no new data behind it. - Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter draws for large text, for the banner's name and a title page's name where no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use mappings stripped like the other two cuts. - Artwork fades in over its skeleton on Android and iOS, as it already did on desktop cards; art already decoded draws at once. Desktop episode stills and logos fade in too. - Skeletons shimmer: a soft diagonal highlight instead of a pulse. - Desktop tiles lift towards the viewer under the pointer, capped so a lifted tile never reaches its neighbour, and keyboard focus gets a ring of its own. - Desktop shelves share one `ui::shelf`: edges fade into the page and, under the pointer, round buttons page the row along. - Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio) moves out of the facts line into small outlined marks on all three. - Desktop title page and hero buttons use a tonal style beside the accent one, and the player's top bar a dark glass style, instead of egui's stock buttons. Episode numbers leave monospace for the type ramp. - The phone players' top and bottom bars are gradients rather than flat bands, and a double-tap seek is felt as well as seen.
This commit is contained in:
1 parent
3ebfd1a57b
commit
255e463fc8
33 files changed
+886
-290
No files matched your search
@@ -32,9 +32,12 @@ extension EpisodeRecord {
|
||||
///
|
||||
/// `fallback` is used only when there is no provider artwork or it fails to
|
||||
/// load, which — with metadata off — is the common case rather than the rare
|
||||
/// one. Until either answers, the box pulses as a skeleton; when neither has
|
||||
/// one. Until either answers, the box shimmers as a skeleton; when neither has
|
||||
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
|
||||
/// initials when `labelled` is false, for art with the name already over it.
|
||||
///
|
||||
/// Art that arrives fades in over the skeleton, as on Android; art already
|
||||
/// decoded is drawn in the first frame and has nothing to fade.
|
||||
struct RemoteArtwork: View {
|
||||
let url: String?
|
||||
let description: String
|
||||
@@ -57,11 +60,11 @@ struct RemoteArtwork: View {
|
||||
.overlay {
|
||||
switch load {
|
||||
case .pending:
|
||||
Skeleton()
|
||||
Skeleton().transition(.opacity)
|
||||
case .missing:
|
||||
ArtworkPlaceholder(name: description, labelled: labelled)
|
||||
ArtworkPlaceholder(name: description, labelled: labelled).transition(.opacity)
|
||||
case let .loaded(image):
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
Image(uiImage: image).resizable().scaledToFill().transition(.opacity)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
@@ -71,7 +74,7 @@ struct RemoteArtwork: View {
|
||||
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
|
||||
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
|
||||
if Task.isCancelled { return }
|
||||
load = loaded.map(ArtworkLoad.loaded) ?? .missing
|
||||
withAnimation(Motion.arrive) { load = loaded.map(ArtworkLoad.loaded) ?? .missing }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,15 +139,16 @@ struct RemoteLogo<Fallback: View>: View {
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
|
||||
.accessibilityLabel(description)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
fallback()
|
||||
fallback().transition(.opacity)
|
||||
}
|
||||
}
|
||||
.task(id: url) {
|
||||
if logo != nil { return }
|
||||
let loaded = await ArtworkLoader.shared.logo(url)
|
||||
if Task.isCancelled { return }
|
||||
logo = loaded
|
||||
withAnimation(Motion.arrive) { logo = loaded }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,20 +215,38 @@ private enum ArtworkLoad {
|
||||
case loaded(UIImage)
|
||||
}
|
||||
|
||||
/// Something being loaded: the surface tint, breathing.
|
||||
/// How long things take to change: the desktop's `theme::motion`.
|
||||
enum Motion {
|
||||
/// Artwork arriving over its skeleton.
|
||||
static let arrive = Animation.easeOut(duration: 0.24)
|
||||
/// One pass of a skeleton's highlight.
|
||||
static let shimmer = Animation.linear(duration: 1.4).repeatForever(autoreverses: false)
|
||||
}
|
||||
|
||||
/// Something being loaded: the surface tint, with a soft highlight sweeping
|
||||
/// across it on a diagonal.
|
||||
///
|
||||
/// A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||
/// says what is coming and where, and settles the layout before it arrives.
|
||||
struct Skeleton: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var dim = false
|
||||
@State private var sweep: CGFloat = -1
|
||||
|
||||
var body: some View {
|
||||
scheme.surfaceVariant
|
||||
.opacity(dim ? 0.55 : 1)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
|
||||
.overlay {
|
||||
GeometryReader { geometry in
|
||||
LinearGradient(
|
||||
colors: [.clear, mix(scheme.surfaceVariant, scheme.onSurface, 0.07), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.frame(width: geometry.size.width * 1.2)
|
||||
.offset(x: sweep * geometry.size.width)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.onAppear { withAnimation(Motion.shimmer) { sweep = 2 } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user