mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +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
@@ -123,7 +123,7 @@ struct PlayerView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, showControls ? 132 : 24)
|
||||
.padding(.bottom, showControls ? 160 : 24)
|
||||
|
||||
// Only while nothing else explains the still picture: an error
|
||||
// is the more useful thing to say.
|
||||
@@ -224,8 +224,15 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 28)
|
||||
// A gradient rather than a band: dark where the text is, and gone by
|
||||
// the time it reaches the picture. Black whatever the theme, since
|
||||
// what is under it is a video frame.
|
||||
.background(
|
||||
LinearGradient(colors: [.black.opacity(0.75), .clear], startPoint: .top, endPoint: .bottom)
|
||||
.ignoresSafeArea(edges: [.top, .horizontal])
|
||||
)
|
||||
}
|
||||
|
||||
/// Show or hide the controls; shown and playing, they hide themselves.
|
||||
@@ -242,6 +249,8 @@ struct PlayerView: View {
|
||||
private func doubleTap(at x: CGFloat, width: CGFloat) {
|
||||
let third = width / 3
|
||||
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0
|
||||
// Felt as well as seen: the bubble is under the thumb that asked for it.
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
if seconds == 0 {
|
||||
host.setPaused(!player.paused)
|
||||
} else {
|
||||
@@ -355,8 +364,12 @@ private struct PlayerControls: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 4)
|
||||
.background(
|
||||
LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom)
|
||||
.ignoresSafeArea(edges: [.bottom, .horizontal])
|
||||
)
|
||||
}
|
||||
|
||||
private func toggleMute() {
|
||||
|
||||
@@ -400,3 +400,84 @@ private extension UIColor {
|
||||
return (red, green, blue, alpha)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something the title is filed under — a genre — as a small rounded label.
|
||||
/// Android's `GenreChip`.
|
||||
struct GenreChip: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.textStyle(.labelMedium)
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
/// small outlined mark, the way a box prints them. Android's `SpecBadge`.
|
||||
struct SpecBadge: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.textStyle(.labelSmall, semibold: true)
|
||||
.foregroundStyle(scheme.onSurfaceVariant)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(scheme.onSurfaceVariant.opacity(0.7), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Children left to right, wrapping onto a new line when the next one would
|
||||
/// not fit: Compose's `FlowRow`, which SwiftUI does not have.
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
|
||||
let width = rows.map(\.width).max() ?? 0
|
||||
let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0))
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var y = bounds.minY
|
||||
for row in arrange(width: bounds.width, subviews: subviews) {
|
||||
var x = bounds.minX
|
||||
for index in row.members {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width + spacing
|
||||
}
|
||||
y += row.height + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private struct Row {
|
||||
var members: [Int] = []
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
|
||||
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
|
||||
var rows: [Row] = [Row()]
|
||||
for index in subviews.indices {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
let needed = rows[rows.count - 1].members.isEmpty ? size.width : rows[rows.count - 1].width + spacing + size.width
|
||||
if needed > width, !rows[rows.count - 1].members.isEmpty {
|
||||
rows.append(Row())
|
||||
}
|
||||
var row = rows[rows.count - 1]
|
||||
row.width = row.members.isEmpty ? size.width : row.width + spacing + size.width
|
||||
row.height = max(row.height, size.height)
|
||||
row.members.append(index)
|
||||
rows[rows.count - 1] = row
|
||||
}
|
||||
return rows.filter { !$0.members.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -508,7 +508,7 @@ private struct FeaturedBanner: View {
|
||||
// Always over the dark scrim above, so a logo made for dark
|
||||
// art reads here whatever the theme.
|
||||
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) {
|
||||
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
|
||||
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(.white).lineLimit(2)
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import SwiftUI
|
||||
/// The type ramp, which is the desktop client's type ramp, under Material's
|
||||
/// names so a view ported from the Android client asks for the same style.
|
||||
///
|
||||
/// Inter, Regular and SemiBold only, as on the desktop and on Android: a
|
||||
/// `Medium` role resolves to Regular. The rungs, largest first:
|
||||
/// 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. `TypeRampTests` pins them.
|
||||
/// Inter, Regular and SemiBold, as on the desktop and on Android: a `Medium`
|
||||
/// role resolves to Regular. The rungs, largest first:
|
||||
/// 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. The top one is the hero
|
||||
/// line — the banner's name and the title page's — in Inter Display Bold and
|
||||
/// set tighter. `TypeRampTests` pins them.
|
||||
enum TypeRole: CaseIterable {
|
||||
case displayLarge, displayMedium, displaySmall
|
||||
case headlineLarge, headlineMedium, headlineSmall
|
||||
@@ -15,7 +17,8 @@ enum TypeRole: CaseIterable {
|
||||
|
||||
var size: CGFloat {
|
||||
switch self {
|
||||
case .displayLarge, .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
|
||||
case .displayLarge: Ramp.hero
|
||||
case .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
|
||||
case .displaySmall, .titleLarge: Ramp.title
|
||||
case .headlineSmall: Ramp.heading
|
||||
case .titleMedium: Ramp.section
|
||||
@@ -29,7 +32,7 @@ enum TypeRole: CaseIterable {
|
||||
|
||||
var semibold: Bool {
|
||||
switch self {
|
||||
case .displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
|
||||
case .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
|
||||
true
|
||||
default:
|
||||
false
|
||||
@@ -39,6 +42,7 @@ enum TypeRole: CaseIterable {
|
||||
/// The Dynamic Type style each rung scales with.
|
||||
var relative: Font.TextStyle {
|
||||
switch size {
|
||||
case Ramp.hero: .largeTitle
|
||||
case Ramp.display: .title
|
||||
case Ramp.title: .title2
|
||||
case Ramp.heading: .title3
|
||||
@@ -53,10 +57,19 @@ enum TypeRole: CaseIterable {
|
||||
|
||||
/// Line height is derived rather than named per style: 1.35 of the size,
|
||||
/// as on Android. Inter's own leading is about 1.21, so the rest is spacing.
|
||||
var lineSpacing: CGFloat { size * (Ramp.lineHeight - 1.21) }
|
||||
var lineSpacing: CGFloat {
|
||||
size * ((self == .displayLarge ? Ramp.heroLineHeight : Ramp.lineHeight) - 1.21)
|
||||
}
|
||||
|
||||
/// The PostScript name of the face this style is set in.
|
||||
func face(semibold: Bool? = nil) -> String {
|
||||
if self == .displayLarge, semibold == nil { return "InterDisplay-Bold" }
|
||||
return (semibold ?? self.semibold) ? "Inter-SemiBold" : "Inter-Regular"
|
||||
}
|
||||
}
|
||||
|
||||
enum Ramp {
|
||||
static let hero: CGFloat = 34
|
||||
static let display: CGFloat = 26
|
||||
static let title: CGFloat = 20
|
||||
static let heading: CGFloat = 18
|
||||
@@ -67,11 +80,12 @@ enum Ramp {
|
||||
static let caption: CGFloat = 12
|
||||
static let micro: CGFloat = 11
|
||||
static let lineHeight: CGFloat = 1.35
|
||||
static let heroLineHeight: CGFloat = 1.1
|
||||
}
|
||||
|
||||
extension Font {
|
||||
static func inter(_ style: TypeRole, semibold: Bool? = nil) -> Font {
|
||||
.custom((semibold ?? style.semibold) ? "Inter-SemiBold" : "Inter-Regular", size: style.size, relativeTo: style.relative)
|
||||
.custom(style.face(semibold: semibold), size: style.size, relativeTo: style.relative)
|
||||
}
|
||||
|
||||
static func inter(size: CGFloat, semibold: Bool = false) -> Font {
|
||||
|
||||
@@ -150,10 +150,10 @@ struct TitleView: View {
|
||||
// A light page keeps the name: a clear logo is lettering made for
|
||||
// dark art, white as often as not.
|
||||
if scheme.light {
|
||||
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
|
||||
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
|
||||
} else {
|
||||
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) {
|
||||
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
|
||||
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
@@ -179,6 +179,13 @@ struct TitleView: View {
|
||||
if !facts.isEmpty {
|
||||
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
|
||||
}
|
||||
// What the files are, rather than what the title is.
|
||||
if !title.quality.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
// The one thing the page is for, full width and naming what it will
|
||||
// play: "Resume" alone left the viewer guessing which episode.
|
||||
Button { onPlay(playlist, nextUp) } label: {
|
||||
@@ -213,9 +220,17 @@ struct TitleView: View {
|
||||
.padding(.top, 12)
|
||||
.onTapGesture { overviewOpen.toggle() }
|
||||
}
|
||||
if !title.genres.isEmpty || !title.tags.isEmpty {
|
||||
Text((title.genres + title.tags.prefix(4)).joined(separator: " · "))
|
||||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 10)
|
||||
// Genres as chips, then the provider's tags quieter after them: a
|
||||
// tag is its community describing it, not what it is filed as.
|
||||
if !title.genres.isEmpty {
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(title.genres, id: \.self) { GenreChip(text: $0) }
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
if !title.tags.isEmpty {
|
||||
Text(title.tags.prefix(4).joined(separator: " · "))
|
||||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
|
||||
}
|
||||
// Said plainly, because it changes what a re-match will do.
|
||||
if title.manualMatch {
|
||||
@@ -231,7 +246,7 @@ struct TitleView: View {
|
||||
// A series' running time is its episodes', which each row says.
|
||||
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
|
||||
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
|
||||
return (parts.compactMap { $0 } + title.quality).joined(separator: " · ")
|
||||
return parts.compactMap { $0 }.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {
|
||||
|
||||
@@ -264,10 +264,10 @@ final class ThemeTests: XCTestCase {
|
||||
XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001)
|
||||
}
|
||||
|
||||
/// Every style on one of the nine rungs, every rung used, and leading
|
||||
/// Every style on one of the ten rungs, every rung used, and leading
|
||||
/// derived from the size — Android's `TypeRampTest`.
|
||||
func testEveryStyleIsOnTheDesktopRamp() {
|
||||
let rungs: Set<CGFloat> = [26, 20, 18, 17, 15, 14, 13, 12, 11]
|
||||
let rungs: Set<CGFloat> = [34, 26, 20, 18, 17, 15, 14, 13, 12, 11]
|
||||
let used = Set(TypeRole.allCases.map(\.size))
|
||||
XCTAssertEqual(used, rungs)
|
||||
}
|
||||
@@ -275,10 +275,14 @@ final class ThemeTests: XCTestCase {
|
||||
func testOnlyHeadingsAreSemibold() {
|
||||
XCTAssertEqual(
|
||||
Set(TypeRole.allCases.filter(\.semibold)),
|
||||
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
|
||||
[.displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
|
||||
)
|
||||
}
|
||||
|
||||
func testOnlyTheHeroLineIsSetInTheDisplayCut() {
|
||||
XCTAssertEqual(TypeRole.allCases.filter { $0.face() == "InterDisplay-Bold" }, [.displayLarge])
|
||||
}
|
||||
|
||||
func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
|
||||
let width = 10
|
||||
var alpha = [UInt8](repeating: 0, count: width * 6)
|
||||
|
||||
@@ -31,6 +31,8 @@ targets:
|
||||
buildPhase: resources
|
||||
- path: ../android/app/src/main/res/font/inter_semibold.ttf
|
||||
buildPhase: resources
|
||||
- path: ../android/app/src/main/res/font/inter_display_bold.ttf
|
||||
buildPhase: resources
|
||||
- path: ../android/app/src/main/assets/licenses/GPL-3.0.txt
|
||||
buildPhase: resources
|
||||
- path: ../android/app/src/main/assets/licenses/Inter-OFL.txt
|
||||
@@ -74,6 +76,7 @@ targets:
|
||||
UIAppFonts:
|
||||
- inter_regular.ttf
|
||||
- inter_semibold.ttf
|
||||
- inter_display_bold.ttf
|
||||
# A share link handed over from elsewhere: protonstream://add?url=…,
|
||||
# or the link with its scheme swapped (IncomingShareLink.swift).
|
||||
CFBundleURLTypes:
|
||||
|
||||
Reference in new issue
Block a user