jellyflood/WidgetExtension/NextUpWidget.swift

470 lines
21 KiB
Swift

/* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Combine
import JellyfinAPI
import Nuke
import SwiftUI
import WidgetKit
enum WidgetError: String, Error {
case unknown
case emptyServer
case emptyUser
case emptyHeader
}
struct NextUpWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> NextUpEntry {
NextUpEntry(date: Date(), items: [], error: nil)
}
func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) {
guard let currentLogin = SessionManager.main.currentLogin else { return }
let currentDate = Date()
let server = currentLogin.server
let savedUser = currentLogin.user
var tempCancellables = Set<AnyCancellable>()
JellyfinAPI.basePath = server.uri
TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb])
.subscribe(on: DispatchQueue.global(qos: .background))
.sink(receiveCompletion: { result in
switch result {
case .finished:
break
case let .failure(error):
completion(NextUpEntry(date: currentDate, items: [], error: error))
}
}, receiveValue: { response in
let dispatchGroup = DispatchGroup()
let items = response.items ?? []
var downloadedItems = [(BaseItemDto, UIImage?)]()
items.enumerated().forEach { _, item in
dispatchGroup.enter()
ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in
guard case let .success(image) = result else {
dispatchGroup.leave()
return
}
downloadedItems.append((item, image.image))
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil))
}
})
.store(in: &tempCancellables)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
guard let currentLogin = SessionManager.main.currentLogin else { return }
let currentDate = Date()
let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!
let server = currentLogin.server
let savedUser = currentLogin.user
var tempCancellables = Set<AnyCancellable>()
JellyfinAPI.basePath = server.uri
TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb])
.subscribe(on: DispatchQueue.global(qos: .background))
.sink(receiveCompletion: { result in
switch result {
case .finished:
break
case let .failure(error):
completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate)))
}
}, receiveValue: { response in
let dispatchGroup = DispatchGroup()
let items = response.items ?? []
var downloadedItems = [(BaseItemDto, UIImage?)]()
items.enumerated().forEach { _, item in
dispatchGroup.enter()
ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in
guard case let .success(image) = result else {
dispatchGroup.leave()
return
}
downloadedItems.append((item, image.image))
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
completion(Timeline(entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)],
policy: .after(entryDate)))
}
})
.store(in: &tempCancellables)
}
}
struct NextUpEntry: TimelineEntry {
let date: Date
let items: [(BaseItemDto, UIImage?)]
let error: Error?
}
struct NextUpEntryView: View {
var entry: NextUpWidgetProvider.Entry
@Environment(\.widgetFamily)
var family
@ViewBuilder
var body: some View {
Group {
if let error = entry.error {
HStack {
Image(systemName: "exclamationmark.octagon")
Text((error as? WidgetError)?.rawValue ?? "")
}
.background(Color.blue)
} else if entry.items.isEmpty {
L10n.emptyNextUp.text
.font(.body)
.bold()
.foregroundColor(.primary)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
} else {
switch family {
case .systemSmall:
small(item: entry.items.first)
case .systemMedium:
medium(items: entry.items)
case .systemLarge:
large(items: entry.items)
default:
EmptyView()
}
}
}
.background(Color(.secondarySystemBackground))
}
}
extension NextUpEntryView {
var smallVideoPlaceholderView: some View {
VStack(alignment: .leading) {
Color(.systemGray)
.aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill)
.cornerRadius(8)
.shadow(radius: 8)
Color(.systemGray2)
.frame(width: 100, height: 10)
Color(.systemGray3)
.frame(width: 80, height: 10)
}
}
var largeVideoPlaceholderView: some View {
HStack(spacing: 20) {
Color(.systemGray)
.aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill)
.cornerRadius(8)
.shadow(radius: 8)
VStack(alignment: .leading, spacing: 8) {
Color(.systemGray2)
.frame(width: 100, height: 10)
Color(.systemGray3)
.frame(width: 80, height: 10)
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
}
extension NextUpEntryView {
var headerSymbol: some View {
Image("WidgetHeaderSymbol")
.resizable()
.frame(width: 12, height: 12)
.cornerRadius(4)
.shadow(radius: 8)
}
func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View {
let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")!
return Link(destination: url, label: {
VStack(alignment: .leading) {
if let image = item.1 {
Image(uiImage: image)
.resizable()
.aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill)
.clipped()
.cornerRadius(8)
.shadow(radius: 8)
}
Text(item.0.seriesName ?? "")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.lineLimit(1)
}
})
}
func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View {
let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")!
return Link(destination: url, label: {
HStack(spacing: 20) {
if let image = item.1 {
Image(uiImage: image)
.resizable()
.aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill)
.clipped()
.cornerRadius(8)
.shadow(radius: 8)
}
VStack(alignment: .leading, spacing: 8) {
Text(item.0.seriesName ?? "")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.primary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
})
}
}
extension NextUpEntryView {
func small(item: (BaseItemDto, UIImage?)?) -> some View {
VStack(alignment: .trailing) {
headerSymbol
if let item = item {
smallVideoView(item: item)
} else {
smallVideoPlaceholderView
}
}
.padding(12)
}
func medium(items: [(BaseItemDto, UIImage?)]) -> some View {
VStack(alignment: .trailing) {
headerSymbol
HStack(spacing: 16) {
if let firstItem = items[safe: 0] {
smallVideoView(item: firstItem)
} else {
smallVideoPlaceholderView
}
if let secondItem = items[safe: 1] {
smallVideoView(item: secondItem)
} else {
smallVideoPlaceholderView
}
}
}
.padding(12)
}
func large(items: [(BaseItemDto, UIImage?)]) -> some View {
VStack(spacing: 0) {
if let firstItem = items[safe: 0] {
let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")!
Link(destination: url,
label: {
ZStack(alignment: .topTrailing) {
ZStack(alignment: .bottomLeading) {
if let image = firstItem.1 {
Image(uiImage: image)
.centerCropped()
.innerShadow(color: Color.black.opacity(0.5), radius: 0.5)
}
VStack(alignment: .leading, spacing: 8) {
Text(firstItem.0.seriesName ?? "")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
Text("\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
.shadow(radius: 8)
.padding(12)
}
headerSymbol
.padding(12)
}
.clipped()
.shadow(radius: 8)
})
}
VStack(spacing: 8) {
if let secondItem = items[safe: 1] {
largeVideoView(item: secondItem)
} else {
largeVideoPlaceholderView
}
Divider()
if let thirdItem = items[safe: 2] {
largeVideoView(item: thirdItem)
} else {
largeVideoPlaceholderView
}
}
.padding(12)
}
}
}
struct NextUpWidget: Widget {
let kind: String = "NextUpWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind,
provider: NextUpWidgetProvider()) { entry in
NextUpEntryView(entry: entry)
}
.configurationDisplayName(L10n.nextUp)
.description("Keep watching where you left off or see what's up next.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct NextUpWidget_Previews: PreviewProvider {
static var previews: some View {
Group {
NextUpEntryView(entry: .init(date: Date(),
items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol"))],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemSmall))
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemMedium))
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemLarge))
NextUpEntryView(entry: .init(date: Date(),
items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol"))],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.preferredColorScheme(.dark)
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemMedium))
.preferredColorScheme(.dark)
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemLarge))
.preferredColorScheme(.dark)
NextUpEntryView(entry: .init(date: Date(),
items: [],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.preferredColorScheme(.dark)
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemMedium))
.preferredColorScheme(.dark)
NextUpEntryView(entry: .init(date: Date(),
items: [
(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"),
UIImage(named: "WidgetHeaderSymbol")),
(.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"),
UIImage(named: "WidgetHeaderSymbol"))
],
error: nil))
.previewContext(WidgetPreviewContext(family: .systemLarge))
.preferredColorScheme(.dark)
}
}
}
import SwiftUI
private extension View {
func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View {
modifier(InnerShadow(color: color, radius: min(max(0, radius), 1)))
}
}
private struct InnerShadow: ViewModifier {
var color: Color = .gray
var radius: CGFloat = 0.1
private var colors: [Color] {
[color.opacity(0.75), color.opacity(0.0), .clear]
}
func body(content: Content) -> some View {
GeometryReader { geo in
content
.overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom)
.frame(height: self.radius * self.minSide(geo)),
alignment: .top)
.overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top)
.frame(height: self.radius * self.minSide(geo)),
alignment: .bottom)
}
}
func minSide(_ geo: GeometryProxy) -> CGFloat {
CGFloat(3) * min(geo.size.width, geo.size.height) / 2
}
}