cinematic views for tvOS and more final work
This commit is contained in:
parent
40b6e5c680
commit
3eb92cd325
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
/*
|
||||
* 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 Introspect
|
||||
import SwiftUI
|
||||
|
||||
struct CinematicEpisodeItemView: View {
|
||||
|
||||
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||
@State var verticalScrollViewOffset: CGFloat = 0
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
GeometryReader { overlayGeoReader in
|
||||
Text("")
|
||||
.onAppear {
|
||||
self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200
|
||||
}
|
||||
}
|
||||
.frame(height: 50)
|
||||
}
|
||||
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
|
||||
bh: viewModel.item.getBackdropImageBlurHash())
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
Spacer(minLength: verticalScrollViewOffset)
|
||||
|
||||
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
|
||||
.focusSection()
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
CinematicItemAboutView(viewModel: viewModel)
|
||||
|
||||
EpisodesRowView(viewModel: viewModel)
|
||||
.focusSection()
|
||||
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems)
|
||||
}
|
||||
|
||||
ItemDetailsView(viewModel: viewModel)
|
||||
|
||||
// HStack {
|
||||
// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {})
|
||||
// .frame(width: 60, height: 60)
|
||||
// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {})
|
||||
// .frame(width: 60, height: 60)
|
||||
// }
|
||||
// .padding(.horizontal, 50)
|
||||
}
|
||||
.padding(.top, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
.introspectScrollView { scrollView in
|
||||
wrappedScrollView = scrollView
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
/*
|
||||
* 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 SwiftUI
|
||||
|
||||
struct CinematicItemAboutView: View {
|
||||
|
||||
@ObservedObject var viewModel: ItemViewModel
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 230))
|
||||
.frame(width: 230, height: 380)
|
||||
.cornerRadius(10)
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
Color(UIColor.darkGray).opacity(focused ? 0.2 : 0)
|
||||
.cornerRadius(30)
|
||||
.frame(height: 380)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("About")
|
||||
.font(.title3)
|
||||
|
||||
Text(viewModel.item.overview ?? "No details available")
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.focused($focused)
|
||||
.padding(.horizontal, 50)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
//
|
||||
/*
|
||||
* 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 SwiftUI
|
||||
|
||||
struct CinematicItemViewTopRow: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@ObservedObject var viewModel: ItemViewModel
|
||||
@Environment(\.isFocused) var envFocused: Bool
|
||||
@State var focused: Bool = false
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.frame(height: 210)
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .PlayInformationAlignmentGuide) {
|
||||
CinematicItemViewTopRowButton(wrappedScrollView: wrappedScrollView) {
|
||||
Button {
|
||||
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||
} label: {
|
||||
ZStack {
|
||||
Color.white.frame(width: 230, height: 100)
|
||||
|
||||
Text("Play")
|
||||
.font(.title3)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
}
|
||||
.buttonStyle(CardButtonStyle())
|
||||
.disabled(viewModel.itemVideoPlayerViewModel == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(viewModel.item.name ?? "")
|
||||
.font(.title2)
|
||||
.lineLimit(2)
|
||||
|
||||
if let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() {
|
||||
Text("\(seriesName) - \(episodeLocator)")
|
||||
}
|
||||
|
||||
HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) {
|
||||
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
Text(runtime)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
if let productionYear = viewModel.item.productionYear {
|
||||
Text(String(productionYear))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let officialRating = viewModel.item.officialRating {
|
||||
Text(officialRating)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
||||
.overlay(RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Color.secondary, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
.onChange(of: envFocused) { envFocus in
|
||||
if envFocus == true {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
self.focused = envFocus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HorizontalAlignment {
|
||||
|
||||
private struct TitleSubtitleAlignment: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
context[HorizontalAlignment.leading]
|
||||
}
|
||||
}
|
||||
|
||||
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self)
|
||||
}
|
||||
|
||||
extension VerticalAlignment {
|
||||
|
||||
private struct PlayInformationAlignment: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
context[VerticalAlignment.bottom]
|
||||
}
|
||||
}
|
||||
|
||||
static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self)
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
/*
|
||||
* 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 SwiftUI
|
||||
|
||||
struct CinematicItemViewTopRowButton<Content: View>: View {
|
||||
@Environment(\.isFocused) var envFocused: Bool
|
||||
@State var focused: Bool = false
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
var content: () -> Content
|
||||
|
||||
@FocusState private var buttonFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.focused($buttonFocused)
|
||||
.onChange(of: envFocused) { envFocus in
|
||||
if envFocus == true {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
self.focused = envFocus
|
||||
}
|
||||
}
|
||||
.onChange(of: buttonFocused) { newValue in
|
||||
if newValue {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
wrappedScrollView?.scrollToTop()
|
||||
}
|
||||
print("Scroll to top")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
/*
|
||||
* 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 Introspect
|
||||
import SwiftUI
|
||||
|
||||
struct CinematicMovieItemView: View {
|
||||
|
||||
@ObservedObject var viewModel: MovieItemViewModel
|
||||
@State var verticalScrollViewOffset: CGFloat = 0
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
GeometryReader { overlayGeoReader in
|
||||
Text("")
|
||||
.onAppear {
|
||||
self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200
|
||||
}
|
||||
}
|
||||
.frame(height: 50)
|
||||
}
|
||||
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
|
||||
bh: viewModel.item.getBackdropImageBlurHash())
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
Spacer(minLength: verticalScrollViewOffset)
|
||||
|
||||
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
|
||||
.focusSection()
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
CinematicItemAboutView(viewModel: viewModel)
|
||||
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems)
|
||||
}
|
||||
|
||||
ItemDetailsView(viewModel: viewModel)
|
||||
|
||||
// HStack {
|
||||
// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {})
|
||||
// .frame(width: 60, height: 60)
|
||||
// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {})
|
||||
// .frame(width: 60, height: 60)
|
||||
// }
|
||||
// .padding(.horizontal, 50)
|
||||
}
|
||||
.padding(.top, 50)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.introspectScrollView { scrollView in
|
||||
wrappedScrollView = scrollView
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -59,10 +59,13 @@ struct EpisodeItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
Text(runtime).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if viewModel.item.officialRating != nil {
|
||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
/*
|
||||
* 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 JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct EpisodesRowView: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
Text("Episodes")
|
||||
.font(.title3)
|
||||
.padding(.horizontal, 50)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
ScrollViewReader { reader in
|
||||
HStack(alignment: .top) {
|
||||
ForEach(viewModel.seasonEpisodes, id:\.self) { episode in
|
||||
Button {
|
||||
itemRouter.route(to: \.item, episode)
|
||||
} label: {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
ImageView(src: episode.getBackdropImage(maxWidth: 445),
|
||||
bh: episode.getBackdropImageBlurHash())
|
||||
.mask(Rectangle().frame(width: 500, height: 280))
|
||||
.frame(width: 500, height: 280)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(episode.getEpisodeLocator() ?? "")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(episode.name ?? "")
|
||||
.font(.footnote)
|
||||
.padding(.bottom, 1)
|
||||
Text(episode.overview ?? "")
|
||||
.font(.caption)
|
||||
.fontWeight(.light)
|
||||
.lineLimit(4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.id(episode.name)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.vertical)
|
||||
.onAppear {
|
||||
reader.scrollTo(viewModel.item.name)
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
//
|
||||
/*
|
||||
* 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 SwiftUI
|
||||
|
||||
struct ItemDetailsView: View {
|
||||
|
||||
@ObservedObject var viewModel: ItemViewModel
|
||||
private let detailItems: [(String, String)]
|
||||
private let mediaItems: [(String, String)]
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
init(viewModel: ItemViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
var initialDetailItems: [(String, String)] = []
|
||||
|
||||
if let productionYear = viewModel.item.productionYear {
|
||||
initialDetailItems.append(("Released", "\(productionYear)"))
|
||||
}
|
||||
|
||||
if let rating = viewModel.item.officialRating {
|
||||
initialDetailItems.append(("Rated", "\(rating)"))
|
||||
}
|
||||
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
initialDetailItems.append(("Runtime", "\(runtime)"))
|
||||
}
|
||||
|
||||
var initialMediatems: [(String, String)] = []
|
||||
|
||||
if let container = viewModel.item.container {
|
||||
let containerList = container.split(separator: ",")
|
||||
if containerList.count > 1 {
|
||||
initialMediatems.append(("Containers", containerList.joined(separator: ", ")))
|
||||
} else {
|
||||
initialMediatems.append(("Container", containerList.joined(separator: ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
|
||||
|
||||
if !itemVideoPlayerViewModel.audioStreams.isEmpty {
|
||||
let audioList = itemVideoPlayerViewModel.audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ")
|
||||
initialMediatems.append(("Audio", audioList))
|
||||
}
|
||||
|
||||
if !itemVideoPlayerViewModel.subtitleStreams.isEmpty {
|
||||
let subtitlesList = itemVideoPlayerViewModel.subtitleStreams.compactMap({ $0.displayTitle }).joined(separator: ", ")
|
||||
initialMediatems.append(("Subtitles", subtitlesList))
|
||||
}
|
||||
}
|
||||
|
||||
detailItems = initialDetailItems
|
||||
mediaItems = initialMediatems
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
|
||||
Color(UIColor.darkGray).opacity(focused ? 0.2 : 0)
|
||||
.cornerRadius(30, corners: [.topLeft, .topRight])
|
||||
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Details")
|
||||
.font(.title3)
|
||||
.padding(.bottom, 5)
|
||||
|
||||
ForEach(detailItems, id: \.self.0) { (title, content) in
|
||||
ItemDetail(title: title, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Media")
|
||||
.font(.title3)
|
||||
.padding(.bottom, 5)
|
||||
|
||||
ForEach(mediaItems, id: \.self.0) { (title, content) in
|
||||
ItemDetail(title: title, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.focusable()
|
||||
.focused($focused)
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ItemDetail: View {
|
||||
|
||||
let title: String
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
Text(content)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||
}
|
||||
}
|
|
@ -32,13 +32,13 @@ struct ItemView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
if item.type == "Movie" {
|
||||
MovieItemView(viewModel: .init(item: item))
|
||||
CinematicMovieItemView(viewModel: MovieItemViewModel(item: item))
|
||||
} else if item.type == "Series" {
|
||||
SeriesItemView(viewModel: .init(item: item))
|
||||
} else if item.type == "Season" {
|
||||
SeasonItemView(viewModel: .init(item: item))
|
||||
} else if item.type == "Episode" {
|
||||
EpisodeItemView(viewModel: .init(item: item))
|
||||
CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item))
|
||||
} else {
|
||||
Text(L10n.notImplementedYetWithType(item.type ?? ""))
|
||||
}
|
||||
|
|
|
@ -59,10 +59,12 @@ struct MovieItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
Text(runtime).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if viewModel.item.officialRating != nil {
|
||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
/*
|
||||
* 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 SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct PortraitItemsRowView: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
|
||||
let rowTitle: String
|
||||
let items: [BaseItemDto]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
Text(rowTitle)
|
||||
.font(.title3)
|
||||
.padding(.horizontal, 50)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
|
||||
VStack(spacing: 15) {
|
||||
Button {
|
||||
itemRouter.route(to: \.item, item)
|
||||
} label: {
|
||||
ImageView(src: item.portraitHeaderViewURL(maxWidth: 200))
|
||||
.frame(width: 200, height: 334)
|
||||
}
|
||||
.frame(height: 334)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Text(item.title)
|
||||
.lineLimit(2)
|
||||
.frame(width: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -374,6 +374,15 @@
|
|||
E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
|
||||
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; };
|
||||
E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; };
|
||||
E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */; };
|
||||
E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */; };
|
||||
E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */; };
|
||||
E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */; };
|
||||
E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */; };
|
||||
E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */; };
|
||||
E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; };
|
||||
E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */; };
|
||||
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; };
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
|
||||
E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
|
||||
E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */; };
|
||||
|
@ -648,6 +657,15 @@
|
|||
E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = "<group>"; };
|
||||
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
|
||||
E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = "<group>"; };
|
||||
E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicEpisodeItemView.swift; sourceTree = "<group>"; };
|
||||
E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = "<group>"; };
|
||||
E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRow.swift; sourceTree = "<group>"; };
|
||||
E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicMovieItemView.swift; sourceTree = "<group>"; };
|
||||
E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRowButton.swift; sourceTree = "<group>"; };
|
||||
E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemsRowView.swift; sourceTree = "<group>"; };
|
||||
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = "<group>"; };
|
||||
E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = "<group>"; };
|
||||
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = "<group>"; };
|
||||
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; };
|
||||
E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = "<group>"; };
|
||||
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
|
||||
|
@ -1277,6 +1295,7 @@
|
|||
53DF641D263D9C0600A7CD1A /* LibraryView.swift */,
|
||||
53892771263C8C6F0035E14B /* LoadingView.swift */,
|
||||
5389276F263C25230035E14B /* NextUpView.swift */,
|
||||
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */,
|
||||
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
|
||||
E13DD3E427177D15009D4DAF /* ServerListView.swift */,
|
||||
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */,
|
||||
|
@ -1361,9 +1380,13 @@
|
|||
E193D54E271942C000900D82 /* ItemView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1E5D53C2783A85F00692DFE /* CinematicItemView */,
|
||||
53272538268C20100035FBF1 /* EpisodeItemView.swift */,
|
||||
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */,
|
||||
E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */,
|
||||
53CD2A3F268A49C2002ABD4E /* ItemView.swift */,
|
||||
53CD2A41268A4B38002ABD4E /* MovieItemView.swift */,
|
||||
E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */,
|
||||
53272536268C1DBB0035FBF1 /* SeasonItemView.swift */,
|
||||
53116A16268B919A003024C9 /* SeriesItemView.swift */,
|
||||
);
|
||||
|
@ -1414,6 +1437,18 @@
|
|||
path = Objects;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1E5D53C2783A85F00692DFE /* CinematicItemView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */,
|
||||
E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */,
|
||||
E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */,
|
||||
E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */,
|
||||
E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */,
|
||||
);
|
||||
path = CinematicItemView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1FCD08E26C466F3007C8DCF /* Errors */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1838,6 +1873,7 @@
|
|||
files = (
|
||||
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
|
||||
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */,
|
||||
E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */,
|
||||
E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */,
|
||||
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
|
||||
6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */,
|
||||
|
@ -1849,10 +1885,12 @@
|
|||
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
|
||||
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
|
||||
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
|
||||
E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */,
|
||||
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */,
|
||||
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
|
||||
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
|
||||
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
|
||||
E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */,
|
||||
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */,
|
||||
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
|
||||
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */,
|
||||
|
@ -1866,6 +1904,7 @@
|
|||
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
|
||||
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
|
||||
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
|
||||
E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */,
|
||||
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */,
|
||||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
|
||||
|
@ -1873,6 +1912,7 @@
|
|||
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
|
||||
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
|
||||
62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */,
|
||||
E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */,
|
||||
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
|
||||
E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
|
||||
|
@ -1917,6 +1957,7 @@
|
|||
E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */,
|
||||
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */,
|
||||
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
|
||||
E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */,
|
||||
5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */,
|
||||
53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */,
|
||||
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
|
||||
|
@ -1950,6 +1991,8 @@
|
|||
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
|
||||
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */,
|
||||
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
|
||||
E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */,
|
||||
E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */,
|
||||
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
|
||||
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */,
|
||||
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
|
||||
|
@ -2015,6 +2058,7 @@
|
|||
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
|
||||
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
|
||||
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
|
||||
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */,
|
||||
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
|
||||
53892770263C25230035E14B /* NextUpView.swift in Sources */,
|
||||
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
|
||||
|
|
|
@ -81,10 +81,12 @@ struct EpisodeCardVStackView: View {
|
|||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(item.getItemRuntime())
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
if let runtime = item.getItemRuntime() {
|
||||
Text(runtime)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
|
|
@ -28,11 +28,13 @@ struct ItemLandscapeTopBarView: View {
|
|||
|
||||
if viewModel.item.itemType.showDetails {
|
||||
// MARK: Runtime
|
||||
Text(viewModel.item.getItemRuntime())
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 16)
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
Text(runtime)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Details
|
||||
|
|
|
@ -38,11 +38,13 @@ struct PortraitHeaderOverlayView: View {
|
|||
if viewModel.item.itemType.showDetails {
|
||||
// MARK: Runtime
|
||||
if viewModel.shouldDisplayRuntime() {
|
||||
Text(viewModel.item.getItemRuntime())
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if let runtime = viewModel.item.getItemRuntime() {
|
||||
Text(runtime)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
/*
|
||||
* 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 Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct OverlaySettingsView: View {
|
||||
|
||||
@Default(.overlayType) var overlayType
|
||||
@Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem
|
||||
@Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem
|
||||
@Default(.shouldShowAutoPlay) var shouldShowAutoPlay
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Overlay")) {
|
||||
Picker("Overlay Type", selection: $overlayType) {
|
||||
ForEach(OverlayType.allCases, id: \.self) { overlay in
|
||||
Text(overlay.label).tag(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem)
|
||||
Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem)
|
||||
Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,54 +21,32 @@ struct SettingsView: View {
|
|||
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||
@Default(.appAppearance) var appAppearance
|
||||
@Default(.overlayType) var overlayType
|
||||
@Default(.videoPlayerJumpForward) var jumpForwardLength
|
||||
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
||||
@Default(.jumpGesturesEnabled) var jumpGesturesEnabled
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: EmptyView()) {
|
||||
HStack {
|
||||
Text("User")
|
||||
Spacer()
|
||||
Text(viewModel.user.username)
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
|
||||
// There is a bug where the SettingsView attmempts to remake itself upon signing out
|
||||
// so this check is made
|
||||
if SessionManager.main.currentLogin == nil {
|
||||
Button {
|
||||
settingsRouter.route(to: \.serverDetail)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("User")
|
||||
Text("Server")
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("")
|
||||
Text(viewModel.server.name)
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
|
||||
Button {
|
||||
settingsRouter.route(to: \.serverDetail)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Server")
|
||||
Spacer()
|
||||
Text("")
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text("User")
|
||||
Spacer()
|
||||
Text(SessionManager.main.currentLogin.user.username)
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
|
||||
Button {
|
||||
settingsRouter.route(to: \.serverDetail)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Server")
|
||||
Spacer()
|
||||
Text(SessionManager.main.currentLogin.server.name)
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,7 +55,7 @@ struct SettingsView: View {
|
|||
SessionManager.main.logout()
|
||||
}
|
||||
} label: {
|
||||
Text("Sign out")
|
||||
Text("Switch User")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
|
@ -108,6 +86,21 @@ struct SettingsView: View {
|
|||
Text(length.label).tag(length.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled)
|
||||
|
||||
Button {
|
||||
settingsRouter.route(to: \.overlaySettings)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Overlay")
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(overlayType.label)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: L10n.accessibility.text) {
|
||||
|
|
|
@ -19,15 +19,29 @@ struct VLCPlayerOverlayView: View {
|
|||
|
||||
@ViewBuilder
|
||||
private var mainButtonView: some View {
|
||||
switch viewModel.playerState {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
case .playing:
|
||||
Image(systemName: "pause")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
default:
|
||||
ProgressView()
|
||||
if viewModel.overlayType == .normal {
|
||||
switch viewModel.playerState {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 56, weight: .semibold, design: .default))
|
||||
case .playing:
|
||||
Image(systemName: "pause")
|
||||
.font(.system(size: 56, weight: .semibold, design: .default))
|
||||
default:
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
}
|
||||
} else if viewModel.overlayType == .compact {
|
||||
switch viewModel.playerState {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
case .playing:
|
||||
Image(systemName: "pause")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,11 +52,13 @@ struct VLCPlayerOverlayView: View {
|
|||
// MARK: Top Bar
|
||||
ZStack {
|
||||
|
||||
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.frame(height: 80)
|
||||
if viewModel.overlayType == .compact {
|
||||
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.frame(height: 80)
|
||||
}
|
||||
|
||||
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
|
||||
|
||||
|
@ -236,44 +252,71 @@ struct VLCPlayerOverlayView: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
if viewModel.overlayType == .normal {
|
||||
HStack(spacing: 80) {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||
} label: {
|
||||
mainButtonView
|
||||
}
|
||||
.frame(width: 200)
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 48))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// MARK: Bottom Bar
|
||||
ZStack {
|
||||
|
||||
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.frame(height: 70)
|
||||
if viewModel.overlayType == .compact {
|
||||
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.frame(height: 70)
|
||||
}
|
||||
|
||||
HStack {
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
|
||||
.padding(.horizontal, 5)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||
} label: {
|
||||
mainButtonView
|
||||
.frame(minWidth: 30, maxWidth: 30)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel)
|
||||
.padding(.horizontal, 5)
|
||||
if viewModel.overlayType == .compact {
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
|
||||
.padding(.horizontal, 5)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||
} label: {
|
||||
mainButtonView
|
||||
.frame(minWidth: 30, maxWidth: 30)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||
} label: {
|
||||
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel)
|
||||
.padding(.horizontal, 5)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 24, weight: .semibold, design: .default))
|
||||
}
|
||||
.font(.system(size: 24, weight: .semibold, design: .default))
|
||||
|
||||
Text(viewModel.leftLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
|
@ -296,6 +339,7 @@ struct VLCPlayerOverlayView: View {
|
|||
thumbInteractiveSize: CGSize.Circle(radius: 40),
|
||||
options: .defaultOptions)
|
||||
)
|
||||
.frame(maxHeight: 50)
|
||||
|
||||
Text(viewModel.rightLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
|
@ -312,11 +356,22 @@ struct VLCPlayerOverlayView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
mainBody
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||
}
|
||||
if viewModel.overlayType == .normal {
|
||||
mainBody
|
||||
.background {
|
||||
Color(uiColor: .black.withAlphaComponent(0.5))
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mainBody
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -104,6 +104,8 @@ class VLCPlayerViewController: UIViewController {
|
|||
// they aren't unnecessarily set more than once
|
||||
vlcMediaPlayer.delegate = self
|
||||
vlcMediaPlayer.drawable = videoContentView
|
||||
|
||||
// TODO: Custom subtitle sizes
|
||||
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
||||
|
||||
setupMediaPlayer(newViewModel: viewModel)
|
||||
|
@ -152,15 +154,20 @@ class VLCPlayerViewController: UIViewController {
|
|||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
|
||||
|
||||
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
|
||||
rightSwipeGesture.direction = .right
|
||||
|
||||
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe))
|
||||
leftSwipeGesture.direction = .left
|
||||
|
||||
view.addGestureRecognizer(singleTapGesture)
|
||||
view.addGestureRecognizer(rightSwipeGesture)
|
||||
view.addGestureRecognizer(leftSwipeGesture)
|
||||
|
||||
if viewModel.jumpGesturesEnabled {
|
||||
view.addGestureRecognizer(rightSwipeGesture)
|
||||
view.addGestureRecognizer(leftSwipeGesture)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
|
@ -420,7 +427,7 @@ extension VLCPlayerViewController {
|
|||
extension VLCPlayerViewController {
|
||||
|
||||
private func flashJumpBackwardOverlay() {
|
||||
guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return }
|
||||
guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return }
|
||||
|
||||
currentJumpBackwardOverlayView.layer.removeAllAnimations()
|
||||
|
||||
|
@ -440,7 +447,7 @@ extension VLCPlayerViewController {
|
|||
}
|
||||
|
||||
private func flashJumpFowardOverlay() {
|
||||
guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return }
|
||||
guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return }
|
||||
|
||||
currentJumpForwardOverlayView.layer.removeAllAnimations()
|
||||
|
||||
|
|
|
@ -17,13 +17,19 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
|||
|
||||
@Root var start = makeStart
|
||||
@Route(.push) var serverDetail = makeServerDetail
|
||||
@Route(.push) var overlaySettings = makeOverlaySettings
|
||||
|
||||
@ViewBuilder func makeServerDetail() -> some View {
|
||||
let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server)
|
||||
ServerDetailView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder func makeOverlaySettings() -> some View {
|
||||
OverlaySettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder func makeStart() -> some View {
|
||||
SettingsView(viewModel: .init())
|
||||
let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user)
|
||||
SettingsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ extension BaseItemDto {
|
|||
}
|
||||
}
|
||||
|
||||
let subtitlesEnabled = Defaults[.subtitlesEnabledIfDefault] && defaultSubtitleStream != nil
|
||||
let subtitlesEnabled = defaultSubtitleStream != nil
|
||||
|
||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
|
||||
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
||||
|
|
|
@ -142,7 +142,7 @@ public extension BaseItemDto {
|
|||
|
||||
// MARK: Calculations
|
||||
|
||||
func getItemRuntime() -> String {
|
||||
func getItemRuntime() -> String? {
|
||||
let timeHMSFormatter: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
|
@ -151,7 +151,7 @@ public extension BaseItemDto {
|
|||
}()
|
||||
|
||||
guard let runTimeTicks = runTimeTicks,
|
||||
let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return "" }
|
||||
let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil }
|
||||
|
||||
return text
|
||||
}
|
||||
|
|
|
@ -13,5 +13,13 @@ import Foundation
|
|||
enum OverlayType: String, CaseIterable, Defaults.Serializable {
|
||||
case normal
|
||||
case compact
|
||||
case bottom
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .normal:
|
||||
return "Normal"
|
||||
case .compact:
|
||||
return "Compact"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,10 +39,9 @@ extension Defaults.Keys {
|
|||
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
|
||||
|
||||
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let gesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let subtitlesEnabledIfDefault = Key<Bool>("subtitlesEnabledIfDefault", default: false, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
|
||||
// Should show video player items
|
||||
|
|
|
@ -13,7 +13,15 @@ import JellyfinAPI
|
|||
import Stinsen
|
||||
|
||||
final class EpisodeItemViewModel: ItemViewModel {
|
||||
|
||||
@RouterObject var itemRouter: ItemCoordinator.Router?
|
||||
var seasonEpisodes: [BaseItemDto] = []
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
|
||||
getSeasonEpisodes()
|
||||
}
|
||||
|
||||
override func getItemDisplayName() -> String {
|
||||
guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" }
|
||||
|
@ -47,4 +55,18 @@ final class EpisodeItemViewModel: ItemViewModel {
|
|||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func getSeasonEpisodes() {
|
||||
guard let seriesID = item.seriesId else { return }
|
||||
TvShowsAPI.getEpisodes(seriesId: seriesID,
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
seasonId: item.seasonId ?? "")
|
||||
.sink { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { [weak self] item in
|
||||
self?.seasonEpisodes = item.items ?? []
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,8 +15,14 @@ final class SettingsViewModel: ObservableObject {
|
|||
|
||||
var bitrates: [Bitrates] = []
|
||||
var langs: [TrackLanguage] = []
|
||||
|
||||
let server: SwiftfinStore.State.Server
|
||||
let user: SwiftfinStore.State.User
|
||||
|
||||
init() {
|
||||
init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
|
||||
|
||||
self.server = server
|
||||
self.user = user
|
||||
|
||||
// Bitrates
|
||||
let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")!
|
||||
|
|
|
@ -34,8 +34,16 @@ final class VideoPlayerViewModel: ViewModel {
|
|||
@Published var selectedSubtitleStreamIndex: Int
|
||||
@Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel?
|
||||
@Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel?
|
||||
@Published var jumpBackwardLength: VideoPlayerJumpLength
|
||||
@Published var jumpForwardLength: VideoPlayerJumpLength
|
||||
@Published var jumpBackwardLength: VideoPlayerJumpLength {
|
||||
willSet {
|
||||
Defaults[.videoPlayerJumpBackward] = newValue
|
||||
}
|
||||
}
|
||||
@Published var jumpForwardLength: VideoPlayerJumpLength {
|
||||
willSet {
|
||||
Defaults[.videoPlayerJumpForward] = newValue
|
||||
}
|
||||
}
|
||||
@Published var sliderIsScrubbing: Bool = false
|
||||
@Published var sliderPercentage: Double = 0 {
|
||||
willSet {
|
||||
|
@ -64,6 +72,7 @@ final class VideoPlayerViewModel: ViewModel {
|
|||
let audioStreams: [MediaStream]
|
||||
let subtitleStreams: [MediaStream]
|
||||
let overlayType: OverlayType
|
||||
let jumpGesturesEnabled: Bool
|
||||
|
||||
// Full response kept for convenience
|
||||
let response: PlaybackInfoResponse
|
||||
|
@ -124,6 +133,7 @@ final class VideoPlayerViewModel: ViewModel {
|
|||
|
||||
self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward]
|
||||
self.jumpForwardLength = Defaults[.videoPlayerJumpForward]
|
||||
self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled]
|
||||
|
||||
super.init()
|
||||
|
||||
|
@ -242,6 +252,9 @@ extension VideoPlayerViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// Potential for experimental feature of syncing subtitle states among adjacent episodes
|
||||
// when using previous & next item buttons and auto-play
|
||||
|
||||
private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) {
|
||||
if !masterViewModel.subtitlesEnabled {
|
||||
matchSubtitlesEnabled(with: masterViewModel)
|
||||
|
|
Loading…
Reference in New Issue