mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
On Android and iOS a swipe up or down on the left half of the picture sets the brightness and on the right half the volume; a sideways swipe seeks, showing where it will land and seeking only when the finger lifts. Swipes that start at the top or bottom edge are left to the system. Android turns the device's media volume; iOS lets no app do that, so there the swipe turns the player's own, as its slider does. Brightness set by a swipe is handed back when the player goes. Lock touch, in the player's menu, makes the picture ignore touches and the back gesture; a tap brings up Unlock for a few seconds. The sleep timer pauses after 30 or 60 minutes, or stops at the end of the episode playing instead of going on to the next, with no up-next card. On the desktop it is a panel like the others, behind a moon button that shows what is left; on Android and iOS it lives with the player host, so it keeps running with the screen off.
1012 lines
45 KiB
Swift
1012 lines
45 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
/// How long the controls stay up after a tap.
|
||
private let controlsSeconds = 4.0
|
||
|
||
/// The narrowest layout the in-app volume slider is worth the room it costs.
|
||
/// Below it — a phone held upright — the hardware volume buttons are the
|
||
/// better answer.
|
||
private let volumeSliderMinWidth: CGFloat = 720
|
||
|
||
/// Narrower than this, the pickers past subtitles go behind "More".
|
||
private let inlineControlsMinWidth: CGFloat = 560
|
||
|
||
/// How far a double tap on either side of the picture seeks.
|
||
private let doubleTapSeconds = 10
|
||
|
||
/// How much of the file one swipe across the whole width is worth, in seconds.
|
||
private let seekSpanSeconds = 90.0
|
||
|
||
/// A finger that comes down this close to the top or bottom of the screen is
|
||
/// the system's: Control Centre, the home indicator.
|
||
private let systemEdge: CGFloat = 48
|
||
|
||
/// How long the unlock button stays up after a tap on a locked player.
|
||
private let unlockShownSeconds = 3.0
|
||
|
||
/// The rates worth offering. A short list rather than a slider: the useful
|
||
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
|
||
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||
|
||
/// The player, over the whole window, notch included.
|
||
///
|
||
/// The episode itself lives in `PlayerHost`, so this view can go — minimised to
|
||
/// the mini transport, or with the app in the background — while it plays on.
|
||
struct PlayerView: View {
|
||
let host: PlayerHost
|
||
let player: MpvPlayer
|
||
@Environment(\.scheme) private var scheme
|
||
@State private var showControls = true
|
||
@State private var hideTask: Task<Void, Never>?
|
||
@State private var landscapeLocked = OrientationLock.landscape
|
||
/// Set by the rotation button: from then on the viewer decides.
|
||
@State private var rotationChosen = false
|
||
@State private var sheet: PlayerSheet?
|
||
/// Which side was double-tapped, shown for a moment so the seek is seen
|
||
/// to have happened; the count makes a second tap on one side show again.
|
||
@State private var nudge: (seconds: Int, count: Int)?
|
||
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOver
|
||
/// Locked, the picture takes no touches but one: the button that unlocks
|
||
/// it, which a tap brings up for a moment.
|
||
@State private var locked = false
|
||
@State private var unlockShown = 0
|
||
/// What a swipe on the picture is doing, while it is under way.
|
||
@State private var swipe: Swipe?
|
||
@State private var swipeStart: (axis: SwipeAxis, level: Double, position: Double)?
|
||
/// The screen's brightness before the first swipe changed it, put back
|
||
/// when the player goes.
|
||
@State private var brightnessBefore: CGFloat?
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
MpvView(player: player).ignoresSafeArea()
|
||
// Double-tap a side to seek ten seconds, as every phone video
|
||
// player has taught; the middle plays or pauses. A single tap
|
||
// waits out the double-tap, which is the price of both.
|
||
Color.clear
|
||
.contentShape(Rectangle())
|
||
.ignoresSafeArea()
|
||
.onTapGesture(count: 2) { location in
|
||
if !locked { doubleTap(at: location.x, width: geometry.size.width) }
|
||
}
|
||
.onTapGesture {
|
||
if locked { unlockShown += 1 } else { setControls(!showControls) }
|
||
}
|
||
.gesture(swipeGesture(geometry.size), including: locked ? .none : .all)
|
||
.accessibilityElement()
|
||
.accessibilityLabel(showControls ? "Hide controls" : "Show controls")
|
||
.accessibilityAddTraits(.isButton)
|
||
.accessibilityAction { setControls(!showControls) }
|
||
|
||
if let nudge {
|
||
Text(nudge.seconds < 0 ? "−\(-nudge.seconds) s" : "+\(nudge.seconds) s")
|
||
.textStyle(.labelLarge)
|
||
.foregroundStyle(.white)
|
||
.frame(width: 72, height: 72)
|
||
.background(Circle().fill(scheme.scrim.opacity(0.5)))
|
||
.padding(.horizontal, 48)
|
||
.frame(maxWidth: .infinity, alignment: nudge.seconds < 0 ? .leading : .trailing)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
.task(id: nudge.count) {
|
||
try? await Task.sleep(for: .milliseconds(700))
|
||
if !Task.isCancelled { self.nudge = nil }
|
||
}
|
||
}
|
||
|
||
if let swipe {
|
||
SwipeBubble(swipe: swipe).allowsHitTesting(false).accessibilityHidden(true)
|
||
}
|
||
|
||
if locked, unlockShown > 0 {
|
||
Button("Unlock", systemImage: "lock.open") {
|
||
locked = false
|
||
unlockShown = 0
|
||
setControls(true)
|
||
}
|
||
.buttonStyle(.tonal)
|
||
.transition(.opacity)
|
||
.task(id: unlockShown) {
|
||
try? await Task.sleep(for: .seconds(unlockShownSeconds))
|
||
if !Task.isCancelled { unlockShown = 0 }
|
||
}
|
||
}
|
||
|
||
VStack(spacing: 0) {
|
||
if showControls, !locked { topBar.transition(.opacity) }
|
||
Spacer(minLength: 0)
|
||
if showControls, !locked {
|
||
PlayerControls(
|
||
host: host,
|
||
player: player,
|
||
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
|
||
roomForAll: geometry.size.width >= inlineControlsMinWidth,
|
||
landscapeLocked: landscapeLocked,
|
||
onRotate: {
|
||
rotationChosen = true
|
||
landscapeLocked = OrientationLock.toggle()
|
||
},
|
||
onSheet: { sheet = $0 },
|
||
onInteract: { setControls(true) },
|
||
onLock: {
|
||
locked = true
|
||
unlockShown = 1
|
||
setControls(false)
|
||
}
|
||
)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
// Drawn whether or not the rest of the controls are up: an
|
||
// opening lasts ninety seconds, and a button you have to wake
|
||
// with a tap is one nobody reaches in time.
|
||
VStack(alignment: .trailing, spacing: 8) {
|
||
Spacer()
|
||
if let offer = host.offer, !host.autoSkip {
|
||
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
||
}
|
||
// A file with no chapters has no credits to count over, so
|
||
// the card takes its last seconds instead, on the file's
|
||
// own clock: the end of file is what advances.
|
||
let tailLeft: Double? = {
|
||
let left = player.duration - player.position
|
||
guard host.chapters.creditsStart == nil,
|
||
player.duration > Double(upNextSeconds * 6),
|
||
left <= Double(upNextSeconds) else { return nil }
|
||
return left
|
||
}()
|
||
UpNextCard(
|
||
next: host.next,
|
||
visible: host.autoplay && !host.stay && !host.stopsAfterThis && !player.paused && player.endReason == nil
|
||
&& host.playingOpenEpisode
|
||
&& (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true),
|
||
secondsLeft: tailLeft.map { Int($0.rounded(.up)) },
|
||
onPlayNow: host.advance,
|
||
onStay: { host.stay = true }
|
||
)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
.padding(.trailing, 16)
|
||
.padding(.bottom, showControls ? 160 : 24)
|
||
|
||
// Only while nothing else explains the still picture: an error
|
||
// is the more useful thing to say.
|
||
if (host.opening || player.stalled) && host.playbackError == nil {
|
||
VStack(spacing: 12) {
|
||
ProgressView().controlSize(.large).tint(scheme.primary)
|
||
if host.opening {
|
||
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||
} else if player.buffering {
|
||
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
|
||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
}
|
||
|
||
// Played to the end with nothing starting after it: the last
|
||
// episode, autoplay off, or the viewer stayed.
|
||
if host.playingOpenEpisode, player.endReason == .eof,
|
||
host.next == nil || !host.autoplay || host.stay || host.stopsAfterThis {
|
||
EndCard(host: host)
|
||
}
|
||
|
||
if let message = host.playbackError ?? player.problem {
|
||
VStack(spacing: 4) {
|
||
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
||
// Dismissible, because the message is not always fatal
|
||
// — a subtitle track that failed to load leaves an
|
||
// episode that plays perfectly well behind it.
|
||
HStack {
|
||
Button("Dismiss", action: host.dismissProblem)
|
||
.buttonStyle(.quiet)
|
||
.tint(scheme.onErrorContainer)
|
||
Button("Retry", action: host.retry)
|
||
.buttonStyle(.quiet)
|
||
.tint(scheme.onErrorContainer)
|
||
}
|
||
}
|
||
.textStyle(.bodyLarge)
|
||
.padding(16)
|
||
.background(scheme.errorContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(32)
|
||
}
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||
.statusBarHidden(!showControls)
|
||
.persistentSystemOverlays(.hidden)
|
||
.onAppear { setControls(true) }
|
||
// Brightness set by a swipe is the player's, not the phone's.
|
||
.onDisappear {
|
||
if let brightnessBefore { Screen.brightness = brightnessBefore }
|
||
}
|
||
.onChange(of: player.paused) { setControls(showControls) }
|
||
// VoiceOver moves between controls by swiping, and a control that
|
||
// fades four seconds after it is reached cannot be found again.
|
||
.onChange(of: voiceOver) { setControls(true) }
|
||
// A wide picture fills a phone only on its side, so it turns there by
|
||
// itself, once per episode, until the viewer picks for themselves.
|
||
.onChange(of: player.videoWidth > player.videoHeight && player.videoHeight > 0) { _, wide in
|
||
guard wide, host.playingOpenEpisode, !rotationChosen, !landscapeLocked else { return }
|
||
OrientationLock.set(true)
|
||
landscapeLocked = true
|
||
}
|
||
.sheet(item: $sheet) { which in
|
||
switch which {
|
||
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
|
||
case .episodes: EpisodeSheet(episodes: host.episodes, playing: host.index, onSelect: host.jump)
|
||
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
|
||
case let .tracks(type):
|
||
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
|
||
case .sleep:
|
||
SleepSheet(current: host.sleep, media: host.key, onSelect: host.setSleep)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var topBar: some View {
|
||
HStack(spacing: 0) {
|
||
// Two ways out, because they mean different things: this leaves the
|
||
// video and keeps the episode playing, ✕ ends it.
|
||
IconButton("chevron.down", "Leave the video", tint: .white) {
|
||
// The rest of the app is not a landscape app.
|
||
landscapeLocked = false
|
||
OrientationLock.set(false)
|
||
host.minimized = true
|
||
}
|
||
// The episode leads and the show follows it: which episode is the
|
||
// thing a viewer who has just woken the controls is checking.
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
|
||
// The episode's name, never its file name: a release group's
|
||
// tag is no caption, and it is what a screenshot would carry away.
|
||
Text([host.title?.displayName, host.episode?.shownName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
|
||
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
IconButton("xmark", "Stop playback", tint: .white, action: host.close)
|
||
if player.duration > 0, player.duration - player.position >= 1 {
|
||
Text("\(clock(player.duration - player.position)) left")
|
||
.textStyle(.labelMedium).foregroundStyle(.white.opacity(0.7))
|
||
.padding(.leading, 12).padding(.trailing, 4)
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.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.
|
||
private func setControls(_ shown: Bool) {
|
||
showControls = shown
|
||
hideTask?.cancel()
|
||
guard shown, !player.paused, !voiceOver else { return }
|
||
hideTask = Task {
|
||
try? await Task.sleep(for: .seconds(controlsSeconds))
|
||
if !Task.isCancelled { showControls = false }
|
||
}
|
||
}
|
||
|
||
/// The swipes every phone video player has taught: up and down on the
|
||
/// left for brightness, on the right for volume, and sideways to seek —
|
||
/// shown while the finger is down and only seeking when it lifts. Volume
|
||
/// is the player's own: iOS lets no app set the device's.
|
||
private func swipeGesture(_ size: CGSize) -> some Gesture {
|
||
DragGesture(minimumDistance: 16)
|
||
.onChanged { drag in
|
||
if swipeStart == nil {
|
||
let start = drag.startLocation
|
||
guard start.y > systemEdge, start.y < size.height - systemEdge else { return }
|
||
let axis: SwipeAxis = abs(drag.translation.width) > abs(drag.translation.height)
|
||
? .seek : start.x < size.width / 2 ? .brightness : .volume
|
||
let level: Double = switch axis {
|
||
case .brightness: Double(Screen.brightness)
|
||
case .volume: player.volume / 100
|
||
case .seek: 0
|
||
}
|
||
if axis == .brightness, brightnessBefore == nil { brightnessBefore = Screen.brightness }
|
||
swipeStart = (axis, level, player.position)
|
||
}
|
||
guard let start = swipeStart else { return }
|
||
switch start.axis {
|
||
// A swipe the whole height of the screen goes from none to all.
|
||
case .brightness:
|
||
let level = min(max(start.level - drag.translation.height / size.height, 0.01), 1)
|
||
Screen.brightness = CGFloat(level)
|
||
swipe = .brightness(level)
|
||
case .volume:
|
||
let level = min(max(start.level - drag.translation.height / size.height, 0), 1)
|
||
player.setVolume(level * 100)
|
||
swipe = .volume(level)
|
||
case .seek:
|
||
guard player.duration > 0 else { return }
|
||
let target = min(max(start.position + drag.translation.width / size.width * seekSpanSeconds, 0), player.duration)
|
||
swipe = .seek(from: start.position, to: target)
|
||
}
|
||
}
|
||
.onEnded { _ in
|
||
if case let .seek(from, to) = swipe, to != from {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
host.seek(to: to)
|
||
}
|
||
if case let .volume(level) = swipe { host.rememberVolume(volume: level * 100) }
|
||
swipe = nil
|
||
swipeStart = nil
|
||
}
|
||
}
|
||
|
||
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 {
|
||
host.seek(to: max(player.position + Double(seconds), 0))
|
||
nudge = (seconds, (nudge?.count ?? 0) + 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
enum PlayerSheet: Identifiable {
|
||
case speed, episodes, chapters, sleep
|
||
case tracks(String)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case .speed: "speed"
|
||
case .episodes: "episodes"
|
||
case .chapters: "chapters"
|
||
case .sleep: "sleep"
|
||
case let .tracks(type): "tracks-\(type)"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Which way a swipe went, settled by its first movement.
|
||
private enum SwipeAxis { case brightness, volume, seek }
|
||
|
||
/// What a swipe on the picture is doing, for the bubble that says so.
|
||
private enum Swipe {
|
||
/// From 0 to 1.
|
||
case brightness(Double)
|
||
case volume(Double)
|
||
/// Where the playhead will land when the finger lifts, and where it was.
|
||
case seek(from: Double, to: Double)
|
||
}
|
||
|
||
/// The screen the player is on, for its brightness.
|
||
@MainActor
|
||
private enum Screen {
|
||
static var brightness: CGFloat {
|
||
get { scene?.screen.brightness ?? 0.5 }
|
||
set { scene?.screen.brightness = newValue }
|
||
}
|
||
|
||
private static var scene: UIWindowScene? {
|
||
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||
}
|
||
}
|
||
|
||
/// The bubble in the middle of the picture while a swipe is under way.
|
||
private struct SwipeBubble: View {
|
||
let swipe: Swipe
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
switch swipe {
|
||
case let .brightness(value): level(icon: "sun.max.fill", label: "Brightness", value: value)
|
||
case let .volume(value): level(icon: "speaker.wave.2.fill", label: "Volume", value: value)
|
||
case let .seek(from, to):
|
||
Image(systemName: to < from ? "backward.fill" : "forward.fill").font(.system(size: 28))
|
||
Text((to < from ? "−" : "+") + clock(abs(to - from))).textStyle(.titleMedium)
|
||
Text(clock(to)).textStyle(.labelMedium).opacity(0.7)
|
||
}
|
||
}
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 16)
|
||
.background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 20))
|
||
}
|
||
|
||
@ViewBuilder private func level(icon: String, label: String, value: Double) -> some View {
|
||
Image(systemName: icon).font(.system(size: 28))
|
||
Text("\(label) \(Int((value * 100).rounded()))%").textStyle(.labelLarge)
|
||
ProgressView(value: value).tint(scheme.primary).frame(width: 120)
|
||
}
|
||
}
|
||
|
||
/// Everything the transport offers, in two rows: the seek bar on its own,
|
||
/// then the transport cluster on the left, where a thumb already is, and the
|
||
/// pickers on the right.
|
||
private struct PlayerControls: View {
|
||
let host: PlayerHost
|
||
let player: MpvPlayer
|
||
let roomForVolume: Bool
|
||
let roomForAll: Bool
|
||
let landscapeLocked: Bool
|
||
let onRotate: () -> Void
|
||
let onSheet: (PlayerSheet) -> Void
|
||
let onInteract: () -> Void
|
||
let onLock: () -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
// Where the thumb is while a finger is on it: a seek per drag step is a
|
||
// block fetch per drag step, and the thumb would snap back under the
|
||
// finger between readings.
|
||
@State private var scrubbing: Double?
|
||
@State private var dragVolume: Double?
|
||
|
||
var body: some View {
|
||
let duration = max(player.duration, 1)
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
Text(clock(scrubbing ?? player.position)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||
AccentTrack(
|
||
value: Binding(
|
||
get: { scrubbing ?? min(max(player.position, 0), duration) },
|
||
set: { scrubbing = $0 }
|
||
),
|
||
range: 0 ... duration,
|
||
buffered: player.duration > 0 ? player.cachedUntil / player.duration : 0,
|
||
ticks: player.duration > 0 && host.chapters.entries.count > 1
|
||
? host.chapters.entries.dropFirst().map { $0.start / player.duration } : [],
|
||
onEditing: { editing in
|
||
onInteract()
|
||
if !editing, let target = scrubbing {
|
||
host.seek(to: target)
|
||
scrubbing = nil
|
||
}
|
||
}
|
||
)
|
||
Text(clock(player.duration)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||
}
|
||
HStack(spacing: 0) {
|
||
CompactIcon("backward.end.fill", "Previous episode", enabled: host.index > 0, action: host.retreat)
|
||
CompactIcon("gobackward.10", "Back ten seconds") { host.seek(to: player.position - 10) }
|
||
Button { host.setPaused(!player.paused); onInteract() } label: {
|
||
Image(systemName: player.paused ? "play.fill" : "pause.fill")
|
||
.font(.system(size: 30))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 48, height: 48)
|
||
}
|
||
.accessibilityLabel(player.paused ? "Play" : "Pause")
|
||
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
|
||
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
|
||
Spacer(minLength: 4)
|
||
if roomForAll {
|
||
pickers
|
||
} else {
|
||
// A phone held upright has room for the transport and two
|
||
// more buttons at a thumb's size; the rest go behind More
|
||
// rather than off the edge.
|
||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||
Menu {
|
||
if host.episodes.count > 1 {
|
||
Button("Episodes", systemImage: "rectangle.stack") { onSheet(.episodes) }
|
||
}
|
||
if host.chapters.entries.count > 1 {
|
||
Button("Chapters", systemImage: "list.bullet") { onSheet(.chapters) }
|
||
}
|
||
Button("Playback speed", systemImage: "speedometer") { onSheet(.speed) }
|
||
Button("Audio track", systemImage: "waveform") { onSheet(.tracks("audio")) }
|
||
Button(player.muted ? "Unmute" : "Mute", systemImage: player.muted ? "speaker.wave.2.fill" : "speaker.slash.fill", action: toggleMute)
|
||
Button(
|
||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||
systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||
action: onRotate
|
||
)
|
||
Button(SleepTimer.label(host.sleep), systemImage: "moon.zzz") { onSheet(.sleep) }
|
||
Button("Lock touch", systemImage: "lock", action: onLock)
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.accessibilityLabel("More")
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.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() {
|
||
player.setMuted(!player.muted)
|
||
host.rememberVolume(muted: !player.muted)
|
||
}
|
||
|
||
/// The pickers, laid out in a row where there is room for them all.
|
||
@ViewBuilder private var pickers: some View {
|
||
if host.episodes.count > 1 {
|
||
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||
}
|
||
if host.chapters.entries.count > 1 {
|
||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||
}
|
||
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
|
||
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
|
||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", player.muted ? "Unmute" : "Mute", action: toggleMute)
|
||
// The app's own volume, which is not the device's: a file
|
||
// mastered quiet is turned up here and stays up for the next.
|
||
if roomForVolume {
|
||
Slider(
|
||
value: Binding(
|
||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||
set: { dragVolume = $0; player.setVolume($0) }
|
||
),
|
||
in: 0 ... 100
|
||
) { editing in
|
||
onInteract()
|
||
if !editing, let chosen = dragVolume {
|
||
dragVolume = nil
|
||
host.rememberVolume(volume: chosen)
|
||
}
|
||
}
|
||
.tint(scheme.primary)
|
||
.frame(width: 96)
|
||
}
|
||
CompactIcon(
|
||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||
action: onRotate
|
||
)
|
||
CompactIcon("moon.zzz", SleepTimer.label(host.sleep)) { onSheet(.sleep) }
|
||
CompactIcon("lock", "Lock touch", action: onLock)
|
||
}
|
||
}
|
||
|
||
private struct CompactIcon: View {
|
||
let icon: String
|
||
let label: String
|
||
var enabled = true
|
||
let action: () -> Void
|
||
|
||
init(_ icon: String, _ label: String, enabled: Bool = true, action: @escaping () -> Void) {
|
||
self.icon = icon
|
||
self.label = label
|
||
self.enabled = enabled
|
||
self.action = action
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
|
||
// 44pt: the smallest target Apple's guidelines allow.
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(PressableStyle(circle: true, wash: .white))
|
||
.disabled(!enabled)
|
||
.accessibilityLabel(label)
|
||
}
|
||
}
|
||
|
||
/// The countdown into the next episode, shown once the credits run starts.
|
||
///
|
||
/// "Watch till the end" holds for the rest of the file: someone who wants the
|
||
/// post-credits scene should have to say so once, not once every ten seconds.
|
||
private struct UpNextCard: View {
|
||
let next: EpisodeRecord?
|
||
let visible: Bool
|
||
/// Counting the end of a file with no credits chapter, on the file's own
|
||
/// clock; the second button then cancels rather than deferring.
|
||
let secondsLeft: Int?
|
||
let onPlayNow: () -> Void
|
||
let onStay: () -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
@State private var held = false
|
||
@State private var remaining = upNextSeconds
|
||
@State private var heldFor: String?
|
||
|
||
var body: some View {
|
||
let showing = visible && next != nil && !(held && heldFor == next?.linkId)
|
||
Group {
|
||
if showing, let upNext = next {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 12) {
|
||
// The next episode's still, so the card says which
|
||
// episode without being read; the ring over it is both
|
||
// the countdown and the button that skips it.
|
||
Button(action: onPlayNow) {
|
||
RemoteArtwork(upNext.stillUrl, upNext.label, fallback: upNext.thumbnailSource, labelled: false)
|
||
.frame(width: 112)
|
||
.aspectRatio(16 / 9, contentMode: .fit)
|
||
.overlay {
|
||
ZStack {
|
||
Circle().fill(.black.opacity(0.5))
|
||
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||
Circle()
|
||
.trim(from: 0, to: Double(secondsLeft ?? remaining) / Double(upNextSeconds))
|
||
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||
.rotationEffect(.degrees(-90))
|
||
.animation(.linear(duration: 1), value: secondsLeft ?? remaining)
|
||
Image(systemName: "play.fill").foregroundStyle(.white)
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
}
|
||
.buttonStyle(PressableStyle(wash: .white))
|
||
.accessibilityLabel("Play now")
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Up next in \(secondsLeft ?? remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||
}
|
||
}
|
||
Button(secondsLeft == nil ? "Watch till the end" : "Cancel") {
|
||
held = true
|
||
heldFor = upNext.linkId
|
||
if secondsLeft != nil { onStay() }
|
||
}
|
||
.buttonStyle(.quiet)
|
||
}
|
||
.padding(12)
|
||
.frame(width: 280)
|
||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
.task(id: upNext.linkId) {
|
||
guard secondsLeft == nil else { return }
|
||
remaining = upNextSeconds
|
||
while remaining > 0 {
|
||
try? await Task.sleep(for: .seconds(1))
|
||
if Task.isCancelled { return }
|
||
remaining -= 1
|
||
}
|
||
onPlayNow()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the player says once an episode ends and nothing starts after it.
|
||
private struct EndCard: View {
|
||
let host: PlayerHost
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(spacing: 12) {
|
||
Text(host.next == nil
|
||
? "That was the last episode of \(host.title?.displayName ?? "this title")"
|
||
: "Finished \(host.episode?.label ?? "this episode")")
|
||
.textStyle(.titleMedium)
|
||
.foregroundStyle(scheme.onSurface)
|
||
.multilineTextAlignment(.center)
|
||
HStack(spacing: 8) {
|
||
if host.next != nil {
|
||
Button("Play next", action: host.advance).buttonStyle(.accent)
|
||
} else {
|
||
Button("Watch again", action: host.replay).buttonStyle(.quiet)
|
||
}
|
||
Button("Close", action: host.close).buttonStyle(.quiet)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(maxWidth: 360)
|
||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(32)
|
||
}
|
||
}
|
||
|
||
/// Off, the end of this episode, or a number of minutes.
|
||
private struct SleepSheet: View {
|
||
let current: SleepTimer?
|
||
/// The open episode, which "End of this episode" is about.
|
||
let media: String?
|
||
let onSelect: (SleepTimer?) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
DialogSheet("Sleep timer") {
|
||
if case .at = current {
|
||
Text(SleepTimer.label(current).replacingOccurrences(of: "Sleep timer: ", with: "") + " left")
|
||
.textStyle(.bodyMedium)
|
||
}
|
||
choice("Off", chosen: current == nil) { nil }
|
||
if let media {
|
||
choice("End of this episode", chosen: current == .endOf(media)) { .endOf(media) }
|
||
}
|
||
ForEach([30, 60], id: \.self) { minutes in
|
||
choice("\(minutes) minutes", chosen: false) { .at(.now.addingTimeInterval(Double(minutes) * 60)) }
|
||
}
|
||
} buttons: {
|
||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||
}
|
||
}
|
||
|
||
private func choice(_ label: String, chosen: Bool, timer: @escaping () -> SleepTimer?) -> some View {
|
||
Button {
|
||
onSelect(timer())
|
||
dismiss()
|
||
} label: {
|
||
Text((chosen ? "✓ " : "") + label).frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.edgedWide)
|
||
}
|
||
}
|
||
|
||
private struct SpeedSheet: View {
|
||
let current: Double
|
||
let onSelect: (Double) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
DialogSheet("Playback speed") {
|
||
ForEach(speeds, id: \.self) { rate in
|
||
Button {
|
||
onSelect(rate)
|
||
dismiss()
|
||
} label: {
|
||
Text((abs(rate - current) < 0.001 ? "✓ " : "") + (rate == 1 ? "Normal" : "\(rate.formatted())×"))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.edgedWide)
|
||
}
|
||
} buttons: {
|
||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ChapterSheet: View {
|
||
let chapters: [ChapterEntry]
|
||
let position: Double
|
||
let onSelect: (ChapterEntry) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
let current = chapters.lastIndex { position + 0.001 >= $0.start }
|
||
DialogSheet("Chapters") {
|
||
ForEach(Array(chapters.enumerated()), id: \.element.index) { position, chapter in
|
||
Button {
|
||
onSelect(chapter)
|
||
dismiss()
|
||
} label: {
|
||
Text((position == current ? "✓ " : "") + "\(clock(chapter.start)) \(chapter.label)")
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.edgedWide)
|
||
}
|
||
} buttons: {
|
||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct TrackSheet: View {
|
||
let type: String
|
||
let tracks: [MpvTrack]
|
||
let onSelect: (MpvTrack?) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
DialogSheet(type == "audio" ? "Audio track" : "Subtitle track") {
|
||
if type == "sub" {
|
||
Button {
|
||
onSelect(nil)
|
||
dismiss()
|
||
} label: {
|
||
Text(tracks.contains(where: \.selected) ? "Off" : "✓ Off").frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.edgedWide)
|
||
}
|
||
ForEach(tracks) { track in
|
||
Button {
|
||
onSelect(track)
|
||
dismiss()
|
||
} label: {
|
||
Text(track.selected ? "✓ \(track.label)" : track.label).frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.edgedWide)
|
||
}
|
||
} buttons: {
|
||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The title's episodes, from the player, opened scrolled to the one playing
|
||
/// so the next few are in view. Each row has its still, its name, and whether
|
||
/// it is playing, watched or part way.
|
||
private struct EpisodeSheet: View {
|
||
let episodes: [EpisodeRecord]
|
||
let playing: Int
|
||
let onSelect: (Int) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||
.padding(.horizontal, 24).padding(.vertical, 8)
|
||
ScrollViewReader { reader in
|
||
ScrollView {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(Array(episodes.enumerated()), id: \.element.linkId) { position, episode in
|
||
row(position, episode).id(position)
|
||
}
|
||
}
|
||
.padding(.bottom, 24)
|
||
}
|
||
.onAppear { reader.scrollTo(max(playing - 1, 0), anchor: .top) }
|
||
}
|
||
}
|
||
.padding(.top, 20)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
.presentationBackground(scheme.surfaceContainerLow)
|
||
.presentationCornerRadius(Corner.sheet)
|
||
}
|
||
|
||
private func row(_ position: Int, _ episode: EpisodeRecord) -> some View {
|
||
let current = position == playing
|
||
return Button {
|
||
dismiss()
|
||
onSelect(position)
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
RemoteArtwork(episode.stillUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
||
.frame(width: 112)
|
||
.aspectRatio(16 / 9, contentMode: .fit)
|
||
.overlay(alignment: .bottom) {
|
||
if let progress = episode.progress, !episode.watched, !current { AccentProgress(progress: progress) }
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(episode.providerName ?? episode.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||
Text(current ? "Playing"
|
||
: episode.watched ? "\(episode.label) · Watched"
|
||
: episode.resumeAt != nil ? "\(episode.label) · Part way" : episode.label)
|
||
.textStyle(.bodySmall)
|
||
.foregroundStyle(current ? scheme.primary : scheme.onSurfaceVariant)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
.background(current ? scheme.secondaryContainer : .clear)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.pressable)
|
||
}
|
||
}
|
||
|
||
/// The bar that says playback is still going, and gets back to it.
|
||
///
|
||
/// Leaving the player does not stop it — the episode keeps playing — so there
|
||
/// has to be something on screen that says so and takes one tap to return to.
|
||
struct MiniTransport: View {
|
||
let host: PlayerHost
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
if let episode = host.episode, let title = host.title {
|
||
HStack(spacing: 0) {
|
||
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: episode.thumbnailSource)
|
||
.frame(width: 64)
|
||
.aspectRatio(16 / 9, contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(episode.label).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||
Text(title.displayName).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 12)
|
||
let paused = host.player?.paused ?? true
|
||
IconButton(paused ? "play.fill" : "pause.fill", paused ? "Play" : "Pause", tint: scheme.onSurface) {
|
||
host.setPaused(!paused)
|
||
}
|
||
IconButton("xmark", "Stop playback", tint: scheme.onSurface, action: host.close)
|
||
}
|
||
.padding(8)
|
||
.card()
|
||
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
|
||
.padding(.horizontal, 8)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { host.minimized = false }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The view libmpv draws into: its Metal layer, sized to this view.
|
||
struct MpvView: UIViewRepresentable {
|
||
let player: MpvPlayer
|
||
|
||
func makeUIView(context _: Context) -> LayerView {
|
||
LayerView(player: player)
|
||
}
|
||
|
||
func updateUIView(_: LayerView, context _: Context) {}
|
||
|
||
final class LayerView: UIView {
|
||
private let player: MpvPlayer
|
||
private let video: CAMetalLayer
|
||
|
||
init(player: MpvPlayer) {
|
||
self.player = player
|
||
video = player.layer
|
||
super.init(frame: .zero)
|
||
backgroundColor = .black
|
||
video.removeFromSuperlayer()
|
||
layer.addSublayer(video)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder _: NSCoder) { nil }
|
||
|
||
/// The drawable is sized here, not left to the layer. MoltenVK sets
|
||
/// `drawableSize` once when it builds the swapchain and Core Animation
|
||
/// stops tracking the bounds from then on, while mpv decides its
|
||
/// output size from `drawableSize` alone. Left alone, a rotation, a
|
||
/// Split View resize or the player reopening at another size kept the
|
||
/// old drawable, which Core Animation stretched over the new bounds —
|
||
/// a squashed or cropped picture until something else resized it.
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
video.frame = bounds
|
||
video.contentsScale = scale
|
||
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||
CATransaction.commit()
|
||
// A new drawable is not enough on its own: mpv has to be told.
|
||
player.outputResized()
|
||
}
|
||
|
||
override func didMoveToWindow() {
|
||
super.didMoveToWindow()
|
||
// Reattached after the player was minimised: the layer is the one
|
||
// from before, still sized for wherever it last was.
|
||
setNeedsLayout()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lock to landscape, or hand rotation back to the device.
|
||
@MainActor
|
||
enum OrientationLock {
|
||
private(set) static var landscape = false
|
||
|
||
/// What the app delegate answers for every window.
|
||
static var mask: UIInterfaceOrientationMask { landscape ? .landscape : .all }
|
||
|
||
/// Reports which way it went.
|
||
static func toggle() -> Bool {
|
||
set(!landscape)
|
||
return landscape
|
||
}
|
||
|
||
static func set(_ locked: Bool) {
|
||
landscape = locked
|
||
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else { return }
|
||
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||
}
|
||
}
|