Merge remote-tracking branch 'origin/main' into main
|
@ -15,7 +15,7 @@ jobs:
|
|||
- "JellyfinPlayer"
|
||||
- "JellyfinPlayer tvOS"
|
||||
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-11
|
||||
|
||||
steps:
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
|
|
Before Width: | Height: | Size: 2.4 KiB |
|
@ -6,7 +6,7 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "400x240-back-1.png",
|
||||
"filename" : "Webp.net-resizeimage.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 355 B |
Before Width: | Height: | Size: 20 KiB |
|
@ -6,7 +6,7 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "216-1.png",
|
||||
"filename" : "Webp.net-resizeimage-2.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 40 KiB |
|
@ -1,18 +1,22 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "top shelf.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Untitled-1.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "top shelf-1.png",
|
||||
"idiom" : "tv-marketing",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Untitled-2.png",
|
||||
"idiom" : "tv-marketing",
|
||||
"scale" : "2x"
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 104 KiB |
After Width: | Height: | Size: 104 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 49 KiB |
|
@ -91,7 +91,7 @@ struct LandscapeItemElement: View {
|
|||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
if focused {
|
||||
if(inSeasonView ?? false) {
|
||||
if inSeasonView ?? false {
|
||||
Text("\(item.getEpisodeLocator()) • \(item.name ?? "")")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -15,18 +15,18 @@ struct MediaViewActionButton: View {
|
|||
var icon: String
|
||||
var scrollView: Binding<UIScrollView?>?
|
||||
var iconColor: Color?
|
||||
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(focused ? .black : iconColor ?? .white)
|
||||
.onChange(of: envFocused) { envFocus in
|
||||
if(envFocus == true) {
|
||||
if envFocus == true {
|
||||
scrollView?.wrappedValue?.scrollToTop()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
scrollView?.wrappedValue?.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
self.focused = envFocus
|
||||
}
|
||||
|
|
|
@ -36,8 +36,7 @@ struct PortraitItemElement: View {
|
|||
}
|
||||
}
|
||||
.padding(2)
|
||||
.opacity(1)
|
||||
, alignment: .bottomLeading)
|
||||
.opacity(1), alignment: .bottomLeading)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
|
@ -46,7 +45,7 @@ struct PortraitItemElement: View {
|
|||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
} else {
|
||||
if(item.userData?.unplayedItemCount != nil) {
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
|
|
|
@ -20,7 +20,7 @@ struct ConnectToServerView: View {
|
|||
if viewModel.publicUsers.isEmpty {
|
||||
Section(header: Text(viewModel.lastPublicUsers.isEmpty || username == "" ? "Login to \(ServerEnvironment.current.server.name ?? "")": "")) {
|
||||
if viewModel.lastPublicUsers.isEmpty || username == "" {
|
||||
TextField("Username", text: $username)
|
||||
TextField(NSLocalizedString("Username", comment: ""), text: $username)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
} else {
|
||||
|
@ -33,7 +33,7 @@ struct ConnectToServerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
SecureField("Password (optional)", text: $password)
|
||||
SecureField(NSLocalizedString("Password", comment: ""), text: $password)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
|
@ -108,9 +108,10 @@ struct ConnectToServerView: View {
|
|||
|
||||
Form {
|
||||
Section(header: Text("Server Information")) {
|
||||
TextField("Jellyfin Server URL", text: $uri)
|
||||
TextField(NSLocalizedString("Server URL", comment: ""), text: $uri)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
Button {
|
||||
viewModel.connectToServer()
|
||||
} label: {
|
||||
|
@ -170,6 +171,6 @@ struct ConnectToServerView: View {
|
|||
.onChange(of: password) { password in
|
||||
viewModel.passwordSubject.send(password)
|
||||
}
|
||||
.navigationTitle(viewModel.isConnectedServer ? "Who's watching?" : "Connect to Jellyfin")
|
||||
.navigationTitle(viewModel.isConnectedServer ? NSLocalizedString("Who's watching?", comment: "") : NSLocalizedString("Connect to Jellyfin", comment: ""))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,30 +13,30 @@ import JellyfinAPI
|
|||
struct EpisodeItemView: View {
|
||||
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||
|
||||
@State var actors: [BaseItemPerson] = [];
|
||||
@State var studio: String? = nil;
|
||||
@State var director: String? = nil;
|
||||
|
||||
@State var actors: [BaseItemPerson] = []
|
||||
@State var studio: String?
|
||||
@State var director: String?
|
||||
|
||||
func onAppear() {
|
||||
actors = []
|
||||
director = nil
|
||||
studio = nil
|
||||
var actor_index = 0;
|
||||
var actor_index = 0
|
||||
viewModel.item.people?.forEach { person in
|
||||
if(person.type == "Actor") {
|
||||
if(actor_index < 4) {
|
||||
if person.type == "Actor" {
|
||||
if actor_index < 4 {
|
||||
actors.append(person)
|
||||
}
|
||||
actor_index = actor_index + 1;
|
||||
actor_index = actor_index + 1
|
||||
}
|
||||
if(person.type == "Director") {
|
||||
if person.type == "Director" {
|
||||
director = person.name ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
studio = viewModel.item.studios?.first?.name ?? nil
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash())
|
||||
|
@ -71,10 +71,10 @@ struct EpisodeItemView: View {
|
|||
}
|
||||
Spacer()
|
||||
}.padding(.top, -15)
|
||||
|
||||
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .trailing) {
|
||||
if(studio != nil) {
|
||||
if studio != nil {
|
||||
Text("STUDIO")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -85,8 +85,8 @@ struct EpisodeItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(director != nil) {
|
||||
|
||||
if director != nil {
|
||||
Text("DIRECTOR")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -97,8 +97,8 @@ struct EpisodeItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(!actors.isEmpty) {
|
||||
|
||||
if !actors.isEmpty {
|
||||
Text("CAST")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -117,7 +117,7 @@ struct EpisodeItemView: View {
|
|||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Button {
|
||||
|
@ -150,7 +150,7 @@ struct EpisodeItemView: View {
|
|||
}
|
||||
}.padding(.top, 50)
|
||||
|
||||
if(!viewModel.similarItems.isEmpty) {
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -22,6 +22,11 @@
|
|||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
|
|
@ -28,11 +28,11 @@ struct LibraryView: View {
|
|||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: tracks) {
|
||||
ForEach(viewModel.items, id: \.id) { item in
|
||||
if(item.type != "Folder") {
|
||||
if item.type != "Folder" {
|
||||
NavigationLink(destination: LazyView { ItemView(item: item) }) {
|
||||
PortraitItemElement(item: item)
|
||||
}.buttonStyle(PlainNavigationLinkButtonStyle())
|
||||
.onAppear() {
|
||||
.onAppear {
|
||||
if item == viewModel.items.last && viewModel.hasNextPage {
|
||||
print("Last item visible, load more items.")
|
||||
viewModel.requestNextPageAsync()
|
||||
|
|
|
@ -42,14 +42,14 @@ struct MainTabView: View {
|
|||
HomeView()
|
||||
.offset(y: -1) // don't remove this. it breaks tabview on 4K displays.
|
||||
.tabItem {
|
||||
Text(Tab.home.localized)
|
||||
Text("All Media")
|
||||
Image(systemName: "house")
|
||||
}
|
||||
.tag(Tab.home)
|
||||
|
||||
Text("Library")
|
||||
.tabItem {
|
||||
Text(Tab.allMedia.localized)
|
||||
Text("Home")
|
||||
Image(systemName: "folder")
|
||||
}
|
||||
.tag(Tab.allMedia)
|
||||
|
@ -62,15 +62,6 @@ extension MainTabView {
|
|||
enum Tab: String {
|
||||
case home
|
||||
case allMedia
|
||||
|
||||
var localized: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "Home"
|
||||
case .allMedia:
|
||||
return "All Media"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,36 +14,36 @@ import SwiftUIFocusGuide
|
|||
struct MovieItemView: View {
|
||||
@ObservedObject var viewModel: MovieItemViewModel
|
||||
|
||||
@State var actors: [BaseItemPerson] = [];
|
||||
@State var studio: String? = nil;
|
||||
@State var director: String? = nil;
|
||||
|
||||
@State var wrappedScrollView: UIScrollView?;
|
||||
|
||||
@State var actors: [BaseItemPerson] = []
|
||||
@State var studio: String?
|
||||
@State var director: String?
|
||||
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
@StateObject var focusBag = SwiftUIFocusBag()
|
||||
|
||||
|
||||
@Namespace private var namespace
|
||||
|
||||
|
||||
func onAppear() {
|
||||
actors = []
|
||||
director = nil
|
||||
studio = nil
|
||||
var actor_index = 0;
|
||||
var actor_index = 0
|
||||
viewModel.item.people?.forEach { person in
|
||||
if(person.type == "Actor") {
|
||||
if(actor_index < 4) {
|
||||
if person.type == "Actor" {
|
||||
if actor_index < 4 {
|
||||
actors.append(person)
|
||||
}
|
||||
actor_index = actor_index + 1;
|
||||
actor_index = actor_index + 1
|
||||
}
|
||||
if(person.type == "Director") {
|
||||
if person.type == "Director" {
|
||||
director = person.name ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
studio = viewModel.item.studios?.first?.name ?? nil
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash())
|
||||
|
@ -75,10 +75,10 @@ struct MovieItemView: View {
|
|||
.stroke(Color.secondary, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .trailing) {
|
||||
if(studio != nil) {
|
||||
if studio != nil {
|
||||
Text("STUDIO")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -89,8 +89,8 @@ struct MovieItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(director != nil) {
|
||||
|
||||
if director != nil {
|
||||
Text("DIRECTOR")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -101,8 +101,8 @@ struct MovieItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(!actors.isEmpty) {
|
||||
|
||||
if !actors.isEmpty {
|
||||
Text("CAST")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -117,7 +117,7 @@ struct MovieItemView: View {
|
|||
Spacer()
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
if(!(viewModel.item.taglines ?? []).isEmpty) {
|
||||
if !(viewModel.item.taglines ?? []).isEmpty {
|
||||
Text(viewModel.item.taglines?.first ?? "")
|
||||
.font(.body)
|
||||
.italic()
|
||||
|
@ -128,7 +128,7 @@ struct MovieItemView: View {
|
|||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Button {
|
||||
|
@ -162,7 +162,7 @@ struct MovieItemView: View {
|
|||
}
|
||||
}.padding(.top, 50)
|
||||
|
||||
if(!viewModel.similarItems.isEmpty) {
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -13,13 +13,13 @@ import SwiftUIFocusGuide
|
|||
|
||||
struct SeasonItemView: View {
|
||||
@ObservedObject var viewModel: SeasonItemViewModel
|
||||
@State var wrappedScrollView: UIScrollView?;
|
||||
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
@StateObject var focusBag = SwiftUIFocusBag()
|
||||
|
||||
|
||||
@Environment(\.resetFocus) var resetFocus
|
||||
@Namespace private var namespace
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 1920), bh: viewModel.item.getSeriesBackdropImageBlurHash())
|
||||
|
@ -31,7 +31,7 @@ struct SeasonItemView: View {
|
|||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
HStack {
|
||||
if(viewModel.item.productionYear != nil) {
|
||||
if viewModel.item.productionYear != nil {
|
||||
Text(String(viewModel.item.productionYear!)).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
@ -58,9 +58,9 @@ struct SeasonItemView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if(!(viewModel.item.taglines ?? []).isEmpty) {
|
||||
if !(viewModel.item.taglines ?? []).isEmpty {
|
||||
Text(viewModel.item.taglines?.first ?? "")
|
||||
.font(.body)
|
||||
.italic()
|
||||
|
@ -71,7 +71,7 @@ struct SeasonItemView: View {
|
|||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Button {
|
||||
|
@ -96,7 +96,7 @@ struct SeasonItemView: View {
|
|||
Spacer()
|
||||
}.padding(.top, 50)
|
||||
|
||||
if(!viewModel.episodes.isEmpty) {
|
||||
if !viewModel.episodes.isEmpty {
|
||||
Text("Episodes")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -14,14 +14,14 @@ import SwiftUIFocusGuide
|
|||
struct SeriesItemView: View {
|
||||
@ObservedObject var viewModel: SeriesItemViewModel
|
||||
|
||||
@State var actors: [BaseItemPerson] = [];
|
||||
@State var studio: String? = nil;
|
||||
@State var director: String? = nil;
|
||||
|
||||
@State var wrappedScrollView: UIScrollView?;
|
||||
|
||||
@State var actors: [BaseItemPerson] = []
|
||||
@State var studio: String?
|
||||
@State var director: String?
|
||||
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
@StateObject var focusBag = SwiftUIFocusBag()
|
||||
|
||||
|
||||
@Environment(\.resetFocus) var resetFocus
|
||||
@Namespace private var namespace
|
||||
|
||||
|
@ -29,22 +29,22 @@ struct SeriesItemView: View {
|
|||
actors = []
|
||||
director = nil
|
||||
studio = nil
|
||||
var actor_index = 0;
|
||||
var actor_index = 0
|
||||
viewModel.item.people?.forEach { person in
|
||||
if(person.type == "Actor") {
|
||||
if(actor_index < 4) {
|
||||
if person.type == "Actor" {
|
||||
if actor_index < 4 {
|
||||
actors.append(person)
|
||||
}
|
||||
actor_index = actor_index + 1;
|
||||
actor_index = actor_index + 1
|
||||
}
|
||||
if(person.type == "Director") {
|
||||
if person.type == "Director" {
|
||||
director = person.name ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
studio = viewModel.item.studios?.first?.name ?? nil
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash())
|
||||
|
@ -81,10 +81,10 @@ struct SeriesItemView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .trailing) {
|
||||
if(studio != nil) {
|
||||
if studio != nil {
|
||||
Text("STUDIO")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -95,8 +95,8 @@ struct SeriesItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(director != nil) {
|
||||
|
||||
if director != nil {
|
||||
Text("DIRECTOR")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -107,8 +107,8 @@ struct SeriesItemView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
|
||||
if(!actors.isEmpty) {
|
||||
|
||||
if !actors.isEmpty {
|
||||
Text("CAST")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -123,7 +123,7 @@ struct SeriesItemView: View {
|
|||
Spacer()
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
if(!(viewModel.item.taglines ?? []).isEmpty) {
|
||||
if !(viewModel.item.taglines ?? []).isEmpty {
|
||||
Text(viewModel.item.taglines?.first ?? "")
|
||||
.font(.body)
|
||||
.italic()
|
||||
|
@ -134,7 +134,7 @@ struct SeriesItemView: View {
|
|||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Button {
|
||||
|
@ -145,7 +145,7 @@ struct SeriesItemView: View {
|
|||
Text(viewModel.isFavorited ? "Unfavorite" : "Favorite")
|
||||
.font(.caption)
|
||||
}
|
||||
if(viewModel.nextUpItem != nil) {
|
||||
if viewModel.nextUpItem != nil {
|
||||
VStack {
|
||||
NavigationLink(destination: VideoPlayerView(item: viewModel.nextUpItem!)) {
|
||||
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
||||
|
@ -167,8 +167,8 @@ struct SeriesItemView: View {
|
|||
Spacer()
|
||||
}
|
||||
}.padding(.top, 50)
|
||||
|
||||
if(viewModel.nextUpItem != nil) {
|
||||
|
||||
if viewModel.nextUpItem != nil {
|
||||
Text("Next Up")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -176,8 +176,8 @@ struct SeriesItemView: View {
|
|||
LandscapeItemElement(item: viewModel.nextUpItem!)
|
||||
}.buttonStyle(PlainNavigationLinkButtonStyle()).padding(.bottom, 1)
|
||||
}
|
||||
|
||||
if(!viewModel.seasons.isEmpty) {
|
||||
|
||||
if !viewModel.seasons.isEmpty {
|
||||
Text("Seasons")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
@ -194,8 +194,8 @@ struct SeriesItemView: View {
|
|||
}.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90))
|
||||
.frame(height: 360)
|
||||
}
|
||||
|
||||
if(!viewModel.similarItems.isEmpty) {
|
||||
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -10,11 +10,11 @@
|
|||
import SwiftUI
|
||||
|
||||
class AudioViewController: InfoTabViewController {
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Audio"
|
||||
tabBarItem.title = NSLocalizedString("Audio", comment: "")
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -11,10 +11,9 @@ import TVUIKit
|
|||
import JellyfinAPI
|
||||
|
||||
class InfoTabViewController: UIViewController {
|
||||
var height : CGFloat = 420
|
||||
var height: CGFloat = 420
|
||||
}
|
||||
|
||||
|
||||
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
|
||||
|
||||
var videoPlayer: VideoPlayerViewController?
|
||||
|
@ -39,11 +38,11 @@ class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate
|
|||
audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate)
|
||||
|
||||
subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate)
|
||||
|
||||
|
||||
}
|
||||
|
||||
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
||||
|
||||
|
||||
if let index = tabBar.items?.firstIndex(of: item),
|
||||
let tabViewController = viewControllers?[index] as? InfoTabViewController,
|
||||
let width = videoPlayer?.infoPanelContainerView.frame.width {
|
||||
|
|
|
@ -16,7 +16,7 @@ class MediaInfoViewController: InfoTabViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Info"
|
||||
tabBarItem.title = NSLocalizedString("Info", comment: "")
|
||||
}
|
||||
|
||||
func setMedia(item: BaseItemDto) {
|
||||
|
@ -50,11 +50,11 @@ struct MediaInfoView: View {
|
|||
if item.type == "Episode" {
|
||||
Text(item.seriesName ?? "Series")
|
||||
.fontWeight(.bold)
|
||||
|
||||
|
||||
HStack {
|
||||
Text(item.name ?? "Episode")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
|
||||
Text(item.getEpisodeLocator())
|
||||
|
||||
if let date = item.premiereDate {
|
||||
|
@ -67,7 +67,7 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if(item.type != "Episode") {
|
||||
if item.type != "Episode" {
|
||||
if let year = item.productionYear {
|
||||
Text(String(year))
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ class SubtitlesViewController: InfoTabViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Subtitles"
|
||||
tabBarItem.title = NSLocalizedString("Subtitles", comment: "")
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -227,7 +227,7 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
if let rawStartTicks = manifest.userData?.playbackPositionTicks {
|
||||
mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000))
|
||||
}
|
||||
|
||||
|
||||
subtitleTrackArray.forEach { sub in
|
||||
if sub.id != -1 && sub.delivery == .external {
|
||||
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
|
||||
|
@ -247,13 +247,13 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
|
||||
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [30]
|
||||
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.enableLanguageOptionCommand.isEnabled = true
|
||||
|
||||
|
@ -275,14 +275,14 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
}
|
||||
|
||||
// Add handler for FF command
|
||||
commandCenter.skipForwardCommand.addTarget { skipEvent in
|
||||
commandCenter.skipForwardCommand.addTarget { _ in
|
||||
self.mediaPlayer.jumpForward(30)
|
||||
self.sendProgressReport(eventName: "timeupdate")
|
||||
return .success
|
||||
}
|
||||
|
||||
// Add handler for RW command
|
||||
commandCenter.skipBackwardCommand.addTarget { skipEvent in
|
||||
commandCenter.skipBackwardCommand.addTarget { _ in
|
||||
self.mediaPlayer.jumpBackward(15)
|
||||
self.sendProgressReport(eventName: "timeupdate")
|
||||
return .success
|
||||
|
@ -324,7 +324,7 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
|
||||
if(manifest.type == "Episode") {
|
||||
if manifest.type == "Episode" {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "") • \(manifest.getEpisodeLocator())"
|
||||
}
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
|
||||
|
@ -421,26 +421,26 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
|
||||
let size = infoPanelContainerView.frame.size
|
||||
let y : CGFloat = showingInfoPanel ? 87 : -size.height
|
||||
|
||||
let y: CGFloat = showingInfoPanel ? 87 : -size.height
|
||||
|
||||
infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: Gestures
|
||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
for item in presses {
|
||||
if(item.type == .select) {
|
||||
if item.type == .select {
|
||||
selectButtonTapped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setupGestures() {
|
||||
self.becomeFirstResponder()
|
||||
|
||||
//vlc crap
|
||||
|
||||
// vlc crap
|
||||
videoContentView.gestureRecognizers?.forEach { gr in
|
||||
videoContentView.removeGestureRecognizer(gr)
|
||||
}
|
||||
|
@ -449,17 +449,17 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
sv.removeGestureRecognizer(gr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
|
||||
let playPauseType = UIPress.PressType.playPause
|
||||
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]
|
||||
view.addGestureRecognizer(playPauseGesture)
|
||||
|
||||
|
||||
let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:)))
|
||||
let backPress = UIPress.PressType.menu
|
||||
backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]
|
||||
view.addGestureRecognizer(backTapGesture)
|
||||
|
||||
|
||||
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:)))
|
||||
view.addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
@ -497,7 +497,7 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
|
||||
let translation = panGestureRecognizer.translation(in: view)
|
||||
let velocity = panGestureRecognizer.velocity(in: view)
|
||||
|
||||
|
||||
// Swiped up - Handle dismissing info panel
|
||||
if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) {
|
||||
toggleInfoContainer()
|
||||
|
@ -580,7 +580,7 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
|
|||
// MARK: Jellyfin Playstate updates
|
||||
func sendProgressReport(eventName: String) {
|
||||
updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing)
|
||||
|
||||
|
||||
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
|
||||
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
|
||||
|
||||
|
|
|
@ -45,6 +45,15 @@
|
|||
53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; };
|
||||
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; };
|
||||
5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F756263B7E2E0014BF09 /* KeychainSwift */; };
|
||||
534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF226A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF326A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF526A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
534D4FF826A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */; };
|
||||
535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; };
|
||||
5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870692669D21700D05A09 /* Preview Assets.xcassets */; };
|
||||
|
@ -62,6 +71,12 @@
|
|||
535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; };
|
||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; };
|
||||
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; };
|
||||
53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAC269CFAEA00A2D8B7 /* Puppy */; };
|
||||
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAE269CFAF600A2D8B7 /* Puppy */; };
|
||||
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
53649AB5269D423A00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AB4269D423A00A2D8B7 /* Puppy */; };
|
||||
5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; };
|
||||
|
@ -128,6 +143,8 @@
|
|||
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; };
|
||||
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; };
|
||||
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; };
|
||||
6260FFF926A09754003FA968 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6260FFF826A09754003FA968 /* CombineExt */; };
|
||||
6261A0E026A0AB710072EF1C /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6261A0DF26A0AB710072EF1C /* CombineExt */; };
|
||||
6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
|
||||
6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
|
||||
|
@ -242,6 +259,9 @@
|
|||
532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCastDeviceSelector.swift; sourceTree = "<group>"; };
|
||||
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
|
||||
5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
|
||||
534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
534D4FEF26A7D7CC000A7A48 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JellyfinPlayer tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayer_tvOSApp.swift; sourceTree = "<group>"; };
|
||||
535870662669D21700D05A09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
@ -268,6 +288,7 @@
|
|||
5362E4C4267D40F0000E2F71 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||
5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
|
||||
5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
53649AB0269CFB1900A2D8B7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = "<group>"; };
|
||||
5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = "<group>"; };
|
||||
536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
|
||||
536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -356,9 +377,11 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */,
|
||||
53EC6E1E267E80AC006DD26A /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */,
|
||||
53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */,
|
||||
535870912669D7A800D05A09 /* Introspect in Frameworks */,
|
||||
6261A0E026A0AB710072EF1C /* CombineExt in Frameworks */,
|
||||
62CB3F482685BB3B003D0A6F /* Defaults in Frameworks */,
|
||||
53272535268BF9710035FBF1 /* SwiftUIFocusGuide in Frameworks */,
|
||||
5358708D2669D7A800D05A09 /* KeychainSwift in Frameworks */,
|
||||
|
@ -373,6 +396,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */,
|
||||
62CB3F462685BAF7003D0A6F /* Defaults in Frameworks */,
|
||||
5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */,
|
||||
53EC6E25267EB10F006DD26A /* SwiftyJSON in Frameworks */,
|
||||
|
@ -380,6 +404,7 @@
|
|||
53352571265EA0A0006CCA86 /* Introspect in Frameworks */,
|
||||
621C638026672A30004216EA /* NukeUI in Frameworks */,
|
||||
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */,
|
||||
6260FFF926A09754003FA968 /* CombineExt in Frameworks */,
|
||||
53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -391,6 +416,7 @@
|
|||
628B95332670CAEA0091AF3B /* NukeUI in Frameworks */,
|
||||
628B95242670CABD0091AF3B /* SwiftUI.framework in Frameworks */,
|
||||
531ABF6C2671F5CC00C0FE20 /* WidgetKit.framework in Frameworks */,
|
||||
53649AB5269D423A00A2D8B7 /* Puppy in Frameworks */,
|
||||
536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */,
|
||||
628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */,
|
||||
628B95352670CAEA0091AF3B /* JellyfinAPI in Frameworks */,
|
||||
|
@ -447,6 +473,40 @@
|
|||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FE126A7D7CC000A7A48 /* Translations */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534D4FED26A7D7CC000A7A48 /* zh-Hans.lproj */,
|
||||
534D4FE626A7D7CC000A7A48 /* en.lproj */,
|
||||
534D4FEA26A7D7CC000A7A48 /* ko.lproj */,
|
||||
);
|
||||
path = Translations;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FE626A7D7CC000A7A48 /* en.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534D4FE726A7D7CC000A7A48 /* Localizable.strings */,
|
||||
);
|
||||
path = en.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FEA26A7D7CC000A7A48 /* ko.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534D4FEB26A7D7CC000A7A48 /* Localizable.strings */,
|
||||
);
|
||||
path = ko.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FED26A7D7CC000A7A48 /* zh-Hans.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534D4FEE26A7D7CC000A7A48 /* Localizable.strings */,
|
||||
);
|
||||
path = "zh-Hans.lproj";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
535870612669D21600D05A09 /* JellyfinPlayer tvOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -521,6 +581,7 @@
|
|||
5377CBE8263B596A003A4E83 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534D4FE126A7D7CC000A7A48 /* Translations */,
|
||||
53D5E3DB264B47EE00BADDC8 /* Frameworks */,
|
||||
5377CBF3263B596A003A4E83 /* JellyfinPlayer */,
|
||||
535870612669D21600D05A09 /* JellyfinPlayer tvOS */,
|
||||
|
@ -664,6 +725,7 @@
|
|||
62EC352B26766675000E9F2D /* ServerEnvironment.swift */,
|
||||
62EC352E267666A5000E9F2D /* SessionManager.swift */,
|
||||
536D3D73267BA8170004248C /* BackgroundManager.swift */,
|
||||
53649AB0269CFB1900A2D8B7 /* LogManager.swift */,
|
||||
);
|
||||
path = Singleton;
|
||||
sourceTree = "<group>";
|
||||
|
@ -715,6 +777,8 @@
|
|||
536D3D83267BEA550004248C /* ParallaxView */,
|
||||
62CB3F472685BB3B003D0A6F /* Defaults */,
|
||||
53272534268BF9710035FBF1 /* SwiftUIFocusGuide */,
|
||||
53649AAE269CFAF600A2D8B7 /* Puppy */,
|
||||
6261A0DF26A0AB710072EF1C /* CombineExt */,
|
||||
);
|
||||
productName = "JellyfinPlayer tvOS";
|
||||
productReference = 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */;
|
||||
|
@ -747,6 +811,8 @@
|
|||
625CB5792678C4A400530A6E /* ActivityIndicator */,
|
||||
53EC6E24267EB10F006DD26A /* SwiftyJSON */,
|
||||
62CB3F452685BAF7003D0A6F /* Defaults */,
|
||||
53649AAC269CFAEA00A2D8B7 /* Puppy */,
|
||||
6260FFF826A09754003FA968 /* CombineExt */,
|
||||
);
|
||||
productName = JellyfinPlayer;
|
||||
productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */;
|
||||
|
@ -770,6 +836,7 @@
|
|||
628B95342670CAEA0091AF3B /* JellyfinAPI */,
|
||||
628B95392670CE250091AF3B /* KeychainSwift */,
|
||||
536D3D7C267BD5F90004248C /* ActivityIndicator */,
|
||||
53649AB4269D423A00A2D8B7 /* Puppy */,
|
||||
);
|
||||
productName = WidgetExtensionExtension;
|
||||
productReference = 628B95202670CABD0091AF3B /* WidgetExtension.appex */;
|
||||
|
@ -781,6 +848,7 @@
|
|||
5377CBE9263B596A003A4E83 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = NO;
|
||||
KnownAssetTags = (
|
||||
New,
|
||||
);
|
||||
|
@ -804,7 +872,8 @@
|
|||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
ko,
|
||||
);
|
||||
mainGroup = 5377CBE8263B596A003A4E83;
|
||||
packageReferences = (
|
||||
|
@ -817,6 +886,8 @@
|
|||
53EC6E23267EB10F006DD26A /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
|
||||
62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */,
|
||||
53272533268BF9710035FBF1 /* XCRemoteSwiftPackageReference "SwiftUIFocusGuide" */,
|
||||
53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */,
|
||||
6260FFF726A09754003FA968 /* XCRemoteSwiftPackageReference "CombineExt" */,
|
||||
);
|
||||
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -834,7 +905,10 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */,
|
||||
534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */,
|
||||
535870672669D21700D05A09 /* Assets.xcassets in Resources */,
|
||||
5358707E2669D64F00D05A09 /* bitrates.json in Resources */,
|
||||
|
@ -845,7 +919,10 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
534D4FF326A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */,
|
||||
534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */,
|
||||
AE8C3159265D6F90008AA076 /* bitrates.json in Resources */,
|
||||
5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */,
|
||||
|
@ -857,6 +934,9 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
628B95292670CABE0091AF3B /* Assets.xcassets in Resources */,
|
||||
534D4FF826A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
534D4FF526A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
534D4FF226A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1010,6 +1090,7 @@
|
|||
62CB3F4C2685BB77003D0A6F /* DefaultsExtension.swift in Sources */,
|
||||
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
|
||||
5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */,
|
||||
53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */,
|
||||
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */,
|
||||
53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */,
|
||||
531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */,
|
||||
|
@ -1068,6 +1149,7 @@
|
|||
091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */,
|
||||
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */,
|
||||
53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */,
|
||||
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */,
|
||||
62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
|
||||
625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */,
|
||||
62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */,
|
||||
|
@ -1102,6 +1184,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */,
|
||||
62EC353126766848000E9F2D /* ServerEnvironment.swift in Sources */,
|
||||
6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */,
|
||||
6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */,
|
||||
|
@ -1126,6 +1209,33 @@
|
|||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
534D4FE726A7D7CC000A7A48 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
534D4FE826A7D7CC000A7A48 /* en */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FEB26A7D7CC000A7A48 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
534D4FEC26A7D7CC000A7A48 /* ko */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534D4FEE26A7D7CC000A7A48 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
534D4FEF26A7D7CC000A7A48 /* zh-Hans */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
535870722669D21700D05A09 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
|
@ -1133,9 +1243,10 @@
|
|||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
@ -1149,9 +1260,11 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = me.vigue.jellyfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 14.5;
|
||||
TVOS_DEPLOYMENT_TARGET = 14.1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -1161,9 +1274,10 @@
|
|||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
@ -1177,9 +1291,10 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = me.vigue.jellyfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 14.5;
|
||||
TVOS_DEPLOYMENT_TARGET = 14.1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -1187,6 +1302,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
|
@ -1250,6 +1366,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
|
@ -1312,7 +1429,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -1331,6 +1448,8 @@
|
|||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
@ -1346,7 +1465,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
|
@ -1366,6 +1485,7 @@
|
|||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
@ -1378,7 +1498,7 @@
|
|||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -1391,6 +1511,8 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = me.vigue.jellyfin.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
@ -1403,7 +1525,7 @@
|
|||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_TEAM = 9R8RREG67J;
|
||||
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -1416,6 +1538,7 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = me.vigue.jellyfin.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
@ -1487,6 +1610,14 @@
|
|||
minimumVersion = 19.0.0;
|
||||
};
|
||||
};
|
||||
53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sushichop/Puppy";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.2.0;
|
||||
};
|
||||
};
|
||||
536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/PGSSoft/ParallaxView";
|
||||
|
@ -1527,6 +1658,14 @@
|
|||
minimumVersion = 1.1.0;
|
||||
};
|
||||
};
|
||||
6260FFF726A09754003FA968 /* XCRemoteSwiftPackageReference "CombineExt" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/CombineCommunity/CombineExt";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.3.0;
|
||||
};
|
||||
};
|
||||
62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/Defaults";
|
||||
|
@ -1568,6 +1707,21 @@
|
|||
package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */;
|
||||
productName = NukeUI;
|
||||
};
|
||||
53649AAC269CFAEA00A2D8B7 /* Puppy */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */;
|
||||
productName = Puppy;
|
||||
};
|
||||
53649AAE269CFAF600A2D8B7 /* Puppy */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */;
|
||||
productName = Puppy;
|
||||
};
|
||||
53649AB4269D423A00A2D8B7 /* Puppy */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */;
|
||||
productName = Puppy;
|
||||
};
|
||||
536D3D7C267BD5F90004248C /* ActivityIndicator */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */;
|
||||
|
@ -1608,6 +1762,16 @@
|
|||
package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */;
|
||||
productName = ActivityIndicator;
|
||||
};
|
||||
6260FFF826A09754003FA968 /* CombineExt */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6260FFF726A09754003FA968 /* XCRemoteSwiftPackageReference "CombineExt" */;
|
||||
productName = CombineExt;
|
||||
};
|
||||
6261A0DF26A0AB710072EF1C /* CombineExt */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6260FFF726A09754003FA968 /* XCRemoteSwiftPackageReference "CombineExt" */;
|
||||
productName = CombineExt;
|
||||
};
|
||||
628B95322670CAEA0091AF3B /* NukeUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */;
|
||||
|
|
|
@ -19,6 +19,24 @@
|
|||
"version": "0.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "combine-schedulers",
|
||||
"repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "c37e5ae8012fb654af776cc556ff8ae64398c841",
|
||||
"version": "0.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "CombineExt",
|
||||
"repositoryURL": "https://github.com/CombineCommunity/CombineExt",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "5b8a0c0f178527f9204200505c5fefa6847e528f",
|
||||
"version": "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Defaults",
|
||||
"repositoryURL": "https://github.com/sindresorhus/Defaults",
|
||||
|
@ -38,7 +56,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"package": "JellyfinAPI",
|
||||
"package": "jellyfin-sdk-swift",
|
||||
"repositoryURL": "https://github.com/jellyfin/jellyfin-sdk-swift",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
|
@ -47,7 +65,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"package": "KeychainSwift",
|
||||
"package": "keychain-swift",
|
||||
"repositoryURL": "https://github.com/evgenyneu/keychain-swift",
|
||||
"state": {
|
||||
"branch": null,
|
||||
|
@ -83,7 +101,25 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"package": "Introspect",
|
||||
"package": "Puppy",
|
||||
"repositoryURL": "https://github.com/sushichop/Puppy",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "dc82e65c749cee431ffbb8c0913680b61ccd7e08",
|
||||
"version": "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-log",
|
||||
"repositoryURL": "https://github.com/apple/swift-log.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
||||
"version": "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftUI-Introspect",
|
||||
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect",
|
||||
"state": {
|
||||
"branch": null,
|
||||
|
@ -108,6 +144,15 @@
|
|||
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "xctest-dynamic-overlay",
|
||||
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -11,14 +11,15 @@ import SwiftUI
|
|||
import JellyfinAPI
|
||||
struct PortraitItemView: View {
|
||||
var item: BaseItemDto
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(destination: LazyView { ItemView(item: item) }) {
|
||||
VStack(alignment: .leading) {
|
||||
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
|
||||
.frame(width: 100, height: 150)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 4)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color(red: 172/255, green: 92/255, blue: 195/255))
|
||||
|
@ -39,37 +40,39 @@ struct PortraitItemView: View {
|
|||
}
|
||||
.padding(.leading, 2)
|
||||
.padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9)
|
||||
.opacity(1)
|
||||
, alignment: .bottomLeading)
|
||||
.opacity(1), alignment: .bottomLeading)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
.foregroundColor(.accentColor)
|
||||
.background(Color(.white))
|
||||
.cornerRadius(.infinity)
|
||||
} else {
|
||||
if(item.userData?.unplayedItemCount != nil) {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(minWidth: 20, minHeight: 20, maxHeight: 20)
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
.foregroundColor(.white)
|
||||
.font(.caption2)
|
||||
.padding(2)
|
||||
}
|
||||
}
|
||||
}.padding(2)
|
||||
.fixedSize()
|
||||
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||
Text(item.seriesName ?? item.name ?? "")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
if(item.type == "Movie" || item.type == "Series") {
|
||||
if item.type == "Movie" || item.type == "Series" {
|
||||
Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
} else if(item.type == "Season") {
|
||||
} else if item.type == "Season" {
|
||||
Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
|
|
|
@ -19,10 +19,10 @@ struct ConnectToServerView: View {
|
|||
if viewModel.isConnectedServer {
|
||||
if viewModel.publicUsers.isEmpty {
|
||||
Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) {
|
||||
TextField("Username", text: $username)
|
||||
TextField(NSLocalizedString("Username", comment: ""), text: $username)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
SecureField("Password", text: $password)
|
||||
SecureField(NSLocalizedString("Password", comment: ""), text: $password)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
Button {
|
||||
|
@ -105,19 +105,20 @@ struct ConnectToServerView: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Section(header: Text("Manual Connection")) {
|
||||
TextField("Jellyfin Server URL", text: $uri)
|
||||
Section(header: Text("Connect Manually")) {
|
||||
TextField(NSLocalizedString("Server URL", comment: ""), text: $uri)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
Button {
|
||||
viewModel.connectToServer()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Connect")
|
||||
Spacer()
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoading || uri.isEmpty)
|
||||
|
@ -162,6 +163,6 @@ struct ConnectToServerView: View {
|
|||
.alert(item: $viewModel.errorMessage) { _ in
|
||||
Alert(title: Text("Error"), message: Text($viewModel.errorMessage.wrappedValue!), dismissButton: .default(Text("Try again")))
|
||||
}
|
||||
.navigationTitle("Connect to Server")
|
||||
.navigationTitle(NSLocalizedString("Connect to Server", comment: ""))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,8 @@ struct ContinueWatchingView: View {
|
|||
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
|
||||
.frame(width: 320, height: 180)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 4)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color(red: 172/255, green: 92/255, blue: 195/255))
|
||||
|
|
|
@ -180,6 +180,25 @@ struct EpisodeItemView: View {
|
|||
}.padding(.leading, 16).padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
if !(viewModel.similarItems).isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack {
|
||||
Spacer().frame(height: 8)
|
||||
HStack {
|
||||
Spacer().frame(width: 16)
|
||||
ForEach(viewModel.similarItems, id: \.self) { similarItem in
|
||||
NavigationLink(destination: LazyView { ItemView(item: similarItem) }) {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 3)
|
||||
}
|
||||
}
|
||||
|
@ -357,6 +376,25 @@ struct EpisodeItemView: View {
|
|||
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
}
|
||||
}
|
||||
if !(viewModel.similarItems).isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack {
|
||||
Spacer().frame(height: 8)
|
||||
HStack {
|
||||
Spacer().frame(width: 16)
|
||||
ForEach(viewModel.similarItems, id: \.self) { similarItem in
|
||||
NavigationLink(destination: LazyView { ItemView(item: similarItem) }) {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 195)
|
||||
}.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ struct HomeView: View {
|
|||
|
||||
var body: some View {
|
||||
innerBody
|
||||
.navigationTitle(MainTabView.Tab.home.localized)
|
||||
.navigationTitle(NSLocalizedString("Home", comment: ""))
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
|
|
|
@ -28,12 +28,6 @@
|
|||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices.</string>
|
||||
|
|
|
@ -34,7 +34,7 @@ struct ItemView: View {
|
|||
.statusBar(hidden: true)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.prefersHomeIndicatorAutoHidden(true)
|
||||
}.supportedOrientations(.landscape), isActive: $videoPlayerItem.shouldShowPlayer) {
|
||||
}, isActive: $videoPlayerItem.shouldShowPlayer) {
|
||||
EmptyView()
|
||||
}
|
||||
VStack {
|
||||
|
@ -56,7 +56,6 @@ struct ItemView: View {
|
|||
.navigationBarHidden(false)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
.environmentObject(videoPlayerItem)
|
||||
.supportedOrientations(.all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,42 @@
|
|||
*/
|
||||
|
||||
import SwiftUI
|
||||
import MessageUI
|
||||
import Defaults
|
||||
|
||||
// The notification we'll send when a shake gesture happens.
|
||||
extension UIDevice {
|
||||
static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification")
|
||||
}
|
||||
|
||||
// Override the default behavior of shake gestures to send our notification instead.
|
||||
extension UIWindow {
|
||||
open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A view modifier that detects shaking and calls a function of our choosing.
|
||||
struct DeviceShakeViewModifier: ViewModifier {
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear()
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A View extension to make the modifier easier to use.
|
||||
extension View {
|
||||
func onShake(perform action: @escaping () -> Void) -> some View {
|
||||
self.modifier(DeviceShakeViewModifier(action: action))
|
||||
}
|
||||
}
|
||||
|
||||
extension UIDevice {
|
||||
var hasNotch: Bool {
|
||||
|
@ -138,17 +174,74 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
class EmailHelper: NSObject, MFMailComposeViewControllerDelegate {
|
||||
public static let shared = EmailHelper()
|
||||
private override init() {
|
||||
//
|
||||
}
|
||||
|
||||
func sendLogs(logURL: URL) {
|
||||
if !MFMailComposeViewController.canSendMail() {
|
||||
// Utilities.showErrorBanner(title: "No mail account found", subtitle: "Please setup a mail account")
|
||||
return // EXIT
|
||||
}
|
||||
|
||||
let picker = MFMailComposeViewController()
|
||||
|
||||
let fileManager = FileManager()
|
||||
let data = fileManager.contents(atPath: logURL.path)
|
||||
|
||||
picker.setSubject("SwiftFin Shake Report")
|
||||
picker.setToRecipients(["SwiftFin Bug Reports <swiftfin-bugs@jellyfin.org>"])
|
||||
picker.addAttachmentData(data!, mimeType: "text/plain", fileName: logURL.lastPathComponent)
|
||||
picker.mailComposeDelegate = self
|
||||
|
||||
EmailHelper.getRootViewController()?.present(picker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
||||
EmailHelper.getRootViewController()?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
static func getRootViewController() -> UIViewController? {
|
||||
UIApplication.shared.windows.first?.rootViewController
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct JellyfinPlayerApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@Default(.appAppearance) var appAppearance
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
SplashView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.onAppear(perform: {
|
||||
setupAppearance()
|
||||
})
|
||||
.withHostingWindow { window in
|
||||
window?.rootViewController = PreferenceUIHostingController(wrappedView: SplashView().environment(\.managedObjectContext, persistenceController.container.viewContext))
|
||||
}
|
||||
.onShake {
|
||||
EmailHelper.shared.sendLogs(logURL: LogManager.shared.logFileURL())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupAppearance() {
|
||||
guard let storedAppearance = AppAppearance(rawValue: appAppearance) else { return }
|
||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = storedAppearance.style
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
static var orientationLock = UIInterfaceOrientationMask.all
|
||||
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
AppDelegate.orientationLock
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,19 +29,19 @@ struct LibraryFilterView: View {
|
|||
} else {
|
||||
Form {
|
||||
if viewModel.enabledFilterType.contains(.genre) {
|
||||
MultiSelector(label: "Genres",
|
||||
MultiSelector(label: NSLocalizedString("Genres", comment: ""),
|
||||
options: viewModel.possibleGenres,
|
||||
optionToString: { $0.name ?? "" },
|
||||
selected: $viewModel.modifiedFilters.withGenres)
|
||||
}
|
||||
if viewModel.enabledFilterType.contains(.filter) {
|
||||
MultiSelector(label: "Filters",
|
||||
MultiSelector(label: NSLocalizedString("Filters", comment: ""),
|
||||
options: viewModel.possibleItemFilters,
|
||||
optionToString: { $0.localized },
|
||||
selected: $viewModel.modifiedFilters.filters)
|
||||
}
|
||||
if viewModel.enabledFilterType.contains(.tag) {
|
||||
MultiSelector(label: "Tags",
|
||||
MultiSelector(label: NSLocalizedString("Tags", comment: ""),
|
||||
options: viewModel.possibleTags,
|
||||
optionToString: { $0 },
|
||||
selected: $viewModel.modifiedFilters.tags)
|
||||
|
@ -70,7 +70,7 @@ struct LibraryFilterView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Filter Results", displayMode: .inline)
|
||||
.navigationBarTitle(NSLocalizedString("Filter Results", comment: ""), displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
|
|
|
@ -56,7 +56,7 @@ struct LibraryListView: View {
|
|||
.shadow(radius: 5)
|
||||
.padding(.bottom, 15)
|
||||
|
||||
if(!viewModel.isLoading) {
|
||||
if !viewModel.isLoading {
|
||||
ForEach(viewModel.libraries, id: \.id) { library in
|
||||
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {
|
||||
NavigationLink(destination: LazyView {
|
||||
|
@ -67,7 +67,7 @@ struct LibraryListView: View {
|
|||
.opacity(0.4)
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack() {
|
||||
VStack {
|
||||
Text(library.name ?? "")
|
||||
.foregroundColor(.white)
|
||||
.font(.title2)
|
||||
|
@ -91,8 +91,9 @@ struct LibraryListView: View {
|
|||
}
|
||||
}.padding(.leading, 16)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("All Media")
|
||||
.navigationTitle(NSLocalizedString("All Media", comment: ""))
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
NavigationLink(destination: LazyView {
|
||||
|
|
|
@ -20,35 +20,103 @@ struct LibrarySearchView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer().frame(height: 6)
|
||||
SearchBar(text: $searchQuery)
|
||||
ZStack {
|
||||
if !viewModel.isLoading {
|
||||
ScrollView(.vertical) {
|
||||
if !viewModel.items.isEmpty {
|
||||
Spacer().frame(height: 16)
|
||||
LazyVGrid(columns: tracks) {
|
||||
ForEach(viewModel.items, id: \.id) { item in
|
||||
PortraitItemView(item: item)
|
||||
}
|
||||
Spacer().frame(height: 16)
|
||||
}
|
||||
.onRotate { _ in
|
||||
recalcTracks()
|
||||
}
|
||||
} else {
|
||||
Text("Query returned 0 results.")
|
||||
}
|
||||
}
|
||||
ZStack {
|
||||
VStack {
|
||||
SearchBar(text: $searchQuery)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
if searchQuery.isEmpty {
|
||||
suggestionsListView
|
||||
} else {
|
||||
ProgressView()
|
||||
resultView
|
||||
}
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.onChange(of: searchQuery) { query in
|
||||
viewModel.searchQuerySubject.send(query)
|
||||
}
|
||||
.navigationBarTitle("Search", displayMode: .inline)
|
||||
}
|
||||
|
||||
var suggestionsListView: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
Text("Suggestions")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 8)
|
||||
ForEach(viewModel.suggestions, id: \.id) { item in
|
||||
Button {
|
||||
searchQuery = item.name ?? ""
|
||||
} label: {
|
||||
Text(item.name ?? "")
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
var resultView: some View {
|
||||
let items = items(for: viewModel.selectedItemType)
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
Picker("ItemType", selection: $viewModel.selectedItemType) {
|
||||
ForEach(viewModel.supportedItemTypeList, id: \.self) {
|
||||
Text($0.localized)
|
||||
.tag($0)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
if !items.isEmpty {
|
||||
LazyVGrid(columns: tracks) {
|
||||
ForEach(items, id: \.id) { item in
|
||||
PortraitItemView(item: item)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onRotate { _ in
|
||||
recalcTracks()
|
||||
}
|
||||
}
|
||||
|
||||
func items(for type: ItemType) -> [BaseItemDto] {
|
||||
switch type {
|
||||
case .episode:
|
||||
return viewModel.episodeItems
|
||||
case .movie:
|
||||
return viewModel.movieItems
|
||||
case .series:
|
||||
return viewModel.showItems
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ItemType {
|
||||
|
||||
var localized: String {
|
||||
switch self {
|
||||
case .episode:
|
||||
return "Episodes"
|
||||
case .movie:
|
||||
return "Movies"
|
||||
case .series:
|
||||
return "Shows"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ struct LibraryView: View {
|
|||
Spacer().frame(height: 16)
|
||||
LazyVGrid(columns: tracks) {
|
||||
ForEach(viewModel.items, id: \.id) { item in
|
||||
if(item.type != "Folder") {
|
||||
if item.type != "Folder" {
|
||||
PortraitItemView(item: item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ struct MainTabView: View {
|
|||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.tabItem {
|
||||
Text(Tab.home.localized)
|
||||
Text("Home")
|
||||
Image(systemName: "house")
|
||||
}
|
||||
.tag(Tab.home)
|
||||
|
@ -29,7 +29,7 @@ struct MainTabView: View {
|
|||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.tabItem {
|
||||
Text(Tab.allMedia.localized)
|
||||
Text("All Media")
|
||||
Image(systemName: "folder")
|
||||
}
|
||||
.tag(Tab.allMedia)
|
||||
|
@ -42,14 +42,5 @@ extension MainTabView {
|
|||
enum Tab: String {
|
||||
case home
|
||||
case allMedia
|
||||
|
||||
var localized: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "Home"
|
||||
case .allMedia:
|
||||
return "All Media"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -192,7 +192,26 @@ struct MovieItemView: View {
|
|||
}.padding(.leading, 16).padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
Spacer().frame(height: 3)
|
||||
if !(viewModel.similarItems).isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack {
|
||||
Spacer().frame(height: 8)
|
||||
HStack {
|
||||
Spacer().frame(width: 16)
|
||||
ForEach(viewModel.similarItems, id: \.self) { similarItem in
|
||||
NavigationLink(destination: LazyView { ItemView(item: similarItem) }) {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 16)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -376,6 +395,25 @@ struct MovieItemView: View {
|
|||
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
}
|
||||
}
|
||||
if !(viewModel.similarItems).isEmpty {
|
||||
Text("More Like This")
|
||||
.font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack {
|
||||
Spacer().frame(height: 8)
|
||||
HStack {
|
||||
Spacer().frame(width: 16)
|
||||
ForEach(viewModel.similarItems, id: \.self) { similarItem in
|
||||
NavigationLink(destination: LazyView { ItemView(item: similarItem) }) {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 105)
|
||||
}.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
|
|
@ -15,28 +15,21 @@ struct SearchBar: View {
|
|||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
|
||||
TextField("Search...", text: $text)
|
||||
.padding(7)
|
||||
HStack(spacing: 8) {
|
||||
TextField(NSLocalizedString("Search...", comment: ""), text: $text)
|
||||
.padding(8)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal, 10)
|
||||
.onTapGesture {
|
||||
self.isEditing = true
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
if !text.isEmpty {
|
||||
Button(action: {
|
||||
self.isEditing = false
|
||||
self.text = ""
|
||||
|
||||
}) {
|
||||
Text("Cancel")
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,8 +89,7 @@ struct SeasonItemView: View {
|
|||
}
|
||||
.padding(.leading, 2)
|
||||
.padding(.bottom, episode.userData?.playedPercentage == nil ? 2 : 9)
|
||||
.opacity(1)
|
||||
, alignment: .bottomLeading)
|
||||
.opacity(1), alignment: .bottomLeading)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if episode.userData?.played ?? false {
|
||||
|
|
|
@ -20,10 +20,11 @@ struct SettingsView: View {
|
|||
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
|
||||
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||
@Default(.appAppearance) var appAppearance
|
||||
@State private var username: String = ""
|
||||
|
||||
func onAppear() {
|
||||
username = SessionManager.current.user.username ?? ""
|
||||
username = SessionManager.current.user?.username ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -61,19 +62,25 @@ struct SettingsView: View {
|
|||
set: { autoSelectAudioLangcode = $0.isoCode}
|
||||
)
|
||||
)
|
||||
Picker(NSLocalizedString("Appearance", comment: ""), selection: $appAppearance) {
|
||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
||||
Text(appearance.localizedName).tag(appearance.rawValue)
|
||||
}
|
||||
}.onChange(of: appAppearance, perform: { value in
|
||||
guard let appearance = AppAppearance(rawValue: value) else { return }
|
||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appearance.style
|
||||
})
|
||||
}
|
||||
|
||||
Section {
|
||||
Section(header: Text(ServerEnvironment.current.server.name ?? "")) {
|
||||
HStack {
|
||||
Text("Signed in as \(username)").foregroundColor(.primary)
|
||||
Spacer()
|
||||
Button {
|
||||
let nc = NotificationCenter.default
|
||||
nc.post(name: Notification.Name("didSignOut"), object: nil)
|
||||
|
||||
SessionManager.current.logout()
|
||||
} label: {
|
||||
Text("Log out").font(.callout)
|
||||
Text("Switch user").font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,21 +68,21 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
var hasSentRemoteSeek: Bool = false
|
||||
|
||||
var selectedPlaybackSpeedIndex : Int = 3
|
||||
var selectedPlaybackSpeedIndex: Int = 3
|
||||
var selectedAudioTrack: Int32 = -1
|
||||
var selectedCaptionTrack: Int32 = -1
|
||||
var playSessionId: String = ""
|
||||
var lastProgressReportTime: Double = 0
|
||||
var subtitleTrackArray: [Subtitle] = []
|
||||
var audioTrackArray: [AudioTrack] = []
|
||||
let playbackSpeeds : [Float] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
let playbackSpeeds: [Float] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
var manifest: BaseItemDto = BaseItemDto()
|
||||
var playbackItem = PlaybackItem()
|
||||
var remoteTimeUpdateTimer: Timer?
|
||||
var upNextViewModel: UpNextViewModel = UpNextViewModel()
|
||||
var lastOri: UIDeviceOrientation!
|
||||
|
||||
var lastOri: UIInterfaceOrientation!
|
||||
|
||||
// MARK: IBActions
|
||||
@IBAction func seekSliderStart(_ sender: Any) {
|
||||
if playerDestination == .local {
|
||||
|
@ -221,6 +221,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
// MARK: Cast methods
|
||||
@IBAction func castButtonPressed(_ sender: Any) {
|
||||
if selectedCastDevice == nil {
|
||||
LogManager.shared.log.debug("Presenting Cast modal")
|
||||
castDeviceVC = VideoPlayerCastDeviceSelectorView()
|
||||
castDeviceVC?.delegate = self
|
||||
|
||||
|
@ -229,11 +230,11 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
|
||||
// Present the view controller (in a popover).
|
||||
self.present(castDeviceVC!, animated: true) {
|
||||
print("popover visible, pause playback")
|
||||
self.mediaPlayer.pause()
|
||||
self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal)
|
||||
}
|
||||
} else {
|
||||
LogManager.shared.log.info("Stopping casting session: button was pressed.")
|
||||
castSessionManager.endSessionAndStopCasting(true)
|
||||
selectedCastDevice = nil
|
||||
self.castButton.isEnabled = true
|
||||
|
@ -243,6 +244,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
|
||||
func castPopoverDismissed() {
|
||||
LogManager.shared.log.debug("Cast modal dismissed")
|
||||
castDeviceVC?.dismiss(animated: true, completion: nil)
|
||||
if playerDestination == .local {
|
||||
self.mediaPlayer.play()
|
||||
|
@ -251,7 +253,9 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
|
||||
func castDeviceChanged() {
|
||||
LogManager.shared.log.debug("Cast device changed")
|
||||
if selectedCastDevice != nil {
|
||||
LogManager.shared.log.debug("New device: \(selectedCastDevice?.friendlyName ?? "UNKNOWN")")
|
||||
playerDestination = .remote
|
||||
castSessionManager.add(self)
|
||||
castSessionManager.startSession(with: selectedCastDevice!)
|
||||
|
@ -348,34 +352,34 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
}
|
||||
|
||||
var nowPlayingInfo = [String : Any]()
|
||||
|
||||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
var runTicks = 0
|
||||
var playbackTicks = 0
|
||||
|
||||
|
||||
if let ticks = manifest.runTimeTicks {
|
||||
runTicks = Int(ticks / 10_000_000)
|
||||
}
|
||||
|
||||
|
||||
if let ticks = manifest.userData?.playbackPositionTicks {
|
||||
playbackTicks = Int(ticks / 10_000_000)
|
||||
}
|
||||
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks
|
||||
|
||||
|
||||
if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) {
|
||||
if let artworkImage = UIImage(data: imageData as Data) {
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (size) -> UIImage in
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in
|
||||
return artworkImage
|
||||
})
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
|
@ -383,23 +387,31 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
|
||||
// MARK: viewDidLoad
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
if manifest.type == "Movie" {
|
||||
titleLabel.text = manifest.name ?? ""
|
||||
} else {
|
||||
titleLabel.text = "S\(String(manifest.parentIndexNumber ?? 0)):E\(String(manifest.indexNumber ?? 0)) “\(manifest.name ?? "")”"
|
||||
|
||||
|
||||
setupNextUpView()
|
||||
upNextViewModel.delegate = self
|
||||
}
|
||||
|
||||
lastOri = UIDevice.current.orientation
|
||||
|
||||
if !UIDevice.current.orientation.isLandscape || UIDevice.current.orientation.isFlat {
|
||||
let value = UIInterfaceOrientation.landscapeRight.rawValue
|
||||
UIDevice.current.setValue(value, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
DispatchQueue.main.async {
|
||||
self.lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
AppDelegate.orientationLock = .landscape
|
||||
|
||||
if !self.lastOri.isLandscape {
|
||||
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func didChangedOrientation() {
|
||||
lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
}
|
||||
|
||||
func mediaHasStartedPlaying() {
|
||||
|
@ -434,12 +446,15 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.tabBarController?.tabBar.isHidden = false
|
||||
self.navigationController?.isNavigationBarHidden = false
|
||||
overrideUserInterfaceStyle = .unspecified
|
||||
UIDevice.current.setValue(lastOri.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
super.viewWillDisappear(animated)
|
||||
DispatchQueue.main.async {
|
||||
AppDelegate.orientationLock = .all
|
||||
UIDevice.current.setValue(self.lastOri.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: viewDidAppear
|
||||
|
@ -454,12 +469,12 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
|
||||
mediaPlayer.delegate = self
|
||||
mediaPlayer.drawable = videoContentView
|
||||
|
||||
|
||||
setupMediaPlayer()
|
||||
}
|
||||
|
||||
|
||||
func setupMediaPlayer() {
|
||||
|
||||
|
||||
// Fetch max bitrate from UserDefaults depending on current connection mode
|
||||
let maxBitrate = Defaults[.inNetworkBandwidth]
|
||||
print(maxBitrate)
|
||||
|
@ -475,19 +490,19 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
|
||||
.sink(receiveCompletion: { completion in
|
||||
switch completion {
|
||||
case .finished:
|
||||
break
|
||||
case .failure(let error):
|
||||
if let err = error as? ErrorResponse {
|
||||
switch err {
|
||||
case .error(401, _, _, _):
|
||||
self.delegate?.exitPlayer(self)
|
||||
SessionManager.current.logout()
|
||||
case .error:
|
||||
self.delegate?.exitPlayer(self)
|
||||
}
|
||||
case .finished:
|
||||
break
|
||||
case .failure(let error):
|
||||
if let err = error as? ErrorResponse {
|
||||
switch err {
|
||||
case .error(401, _, _, _):
|
||||
self.delegate?.exitPlayer(self)
|
||||
SessionManager.current.logout()
|
||||
case .error:
|
||||
self.delegate?.exitPlayer(self)
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}, receiveValue: { [self] response in
|
||||
playSessionId = response.playSessionId ?? ""
|
||||
|
@ -580,8 +595,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
|
||||
self.sendPlayReport()
|
||||
playbackItem = item
|
||||
|
||||
//self.setupNowPlayingCC()
|
||||
|
||||
// self.setupNowPlayingCC()
|
||||
}
|
||||
|
||||
startLocalPlaybackEngine(true)
|
||||
|
@ -613,7 +628,6 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
|
||||
func startLocalPlaybackEngine(_ fetchCaptions: Bool) {
|
||||
print("Local playback engine starting.")
|
||||
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
|
||||
mediaPlayer.play()
|
||||
sendPlayReport()
|
||||
|
@ -621,10 +635,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
// 1 second = 10,000,000 ticks
|
||||
var startTicks: Int64 = 0
|
||||
if remotePositionTicks == 0 {
|
||||
print("Using server-reported start time")
|
||||
startTicks = manifest.userData?.playbackPositionTicks ?? 0
|
||||
} else {
|
||||
print("Using remote-reported start time")
|
||||
startTicks = Int64(remotePositionTicks)
|
||||
}
|
||||
|
||||
|
@ -632,7 +644,6 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
let videoPosition = Double(mediaPlayer.time.intValue / 1000)
|
||||
let secondsScrubbedTo = startTicks / 10_000_000
|
||||
let offset = secondsScrubbedTo - Int64(videoPosition)
|
||||
print("Seeking to position: \(secondsScrubbedTo)")
|
||||
if offset > 0 {
|
||||
mediaPlayer.jumpForward(Int32(offset))
|
||||
} else {
|
||||
|
@ -641,8 +652,6 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
}
|
||||
|
||||
if fetchCaptions {
|
||||
print("Fetching captions.")
|
||||
// Pause and load captions into memory.
|
||||
mediaPlayer.pause()
|
||||
subtitleTrackArray.forEach { sub in
|
||||
if sub.id != -1 && sub.delivery == .external {
|
||||
|
@ -664,8 +673,6 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
mediaPlayer.pause()
|
||||
mediaPlayer.play()
|
||||
setupTracksForPreferredDefaults()
|
||||
|
||||
print("Local engine started.")
|
||||
}
|
||||
|
||||
// MARK: VideoPlayerSettings Delegate
|
||||
|
@ -678,21 +685,21 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
selectedAudioTrack = newTrackID
|
||||
mediaPlayer.currentAudioTrackIndex = newTrackID
|
||||
}
|
||||
|
||||
|
||||
func playbackSpeedChanged(index: Int) {
|
||||
selectedPlaybackSpeedIndex = index
|
||||
mediaPlayer.rate = playbackSpeeds[index]
|
||||
}
|
||||
|
||||
|
||||
func smallNextUpView() {
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { [self] in
|
||||
upNextViewModel.largeView = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setupNextUpView() {
|
||||
getNextEpisode()
|
||||
|
||||
|
||||
// Create the swiftUI view
|
||||
let contentView = UIHostingController(rootView: VideoUpNextView(viewModel: upNextViewModel))
|
||||
self.upNextView.addSubview(contentView.view)
|
||||
|
@ -703,7 +710,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
contentView.view.leftAnchor.constraint(equalTo: upNextView.leftAnchor).isActive = true
|
||||
contentView.view.rightAnchor.constraint(equalTo: upNextView.rightAnchor).isActive = true
|
||||
}
|
||||
|
||||
|
||||
func getNextEpisode() {
|
||||
TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.current.user.user_id!, startItemId: manifest.id, limit: 2)
|
||||
.sink(receiveCompletion: { completion in
|
||||
|
@ -717,22 +724,21 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
func setPlayerToNextUp() {
|
||||
mediaPlayer.stop()
|
||||
|
||||
|
||||
ssTargetValueOffset = 0
|
||||
ssStartValue = 0
|
||||
|
||||
|
||||
paused = true
|
||||
lastTime = 0.0
|
||||
startTime = 0
|
||||
controlsAppearTime = 0
|
||||
isSeeking = false
|
||||
|
||||
|
||||
remotePositionTicks = 0
|
||||
|
||||
|
||||
selectedPlaybackSpeedIndex = 3
|
||||
selectedAudioTrack = -1
|
||||
selectedCaptionTrack = -1
|
||||
|
@ -740,22 +746,22 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
|||
lastProgressReportTime = 0
|
||||
subtitleTrackArray = []
|
||||
audioTrackArray = []
|
||||
|
||||
|
||||
manifest = upNextViewModel.item!
|
||||
playbackItem = PlaybackItem()
|
||||
|
||||
|
||||
upNextViewModel.item = nil
|
||||
|
||||
|
||||
upNextView.isHidden = true
|
||||
shouldShowLoadingScreen = true
|
||||
videoControlsView.isHidden = true
|
||||
|
||||
titleLabel.text = "S\(String(manifest.parentIndexNumber ?? 0)):E\(String(manifest.indexNumber ?? 0)) “\(manifest.name ?? "")”"
|
||||
|
||||
|
||||
setupMediaPlayer()
|
||||
getNextEpisode()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - GCKGenericChannelDelegate
|
||||
|
@ -821,7 +827,6 @@ extension PlayerViewController: GCKGenericChannelDelegate {
|
|||
"receiverName": castSessionManager.currentCastSession!.device.friendlyName!,
|
||||
"subtitleBurnIn": false
|
||||
]
|
||||
print(payload)
|
||||
let jsonData = JSON(payload)
|
||||
|
||||
jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil)
|
||||
|
@ -875,23 +880,24 @@ extension PlayerViewController: GCKSessionManagerListener {
|
|||
}
|
||||
|
||||
func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) {
|
||||
print("starting session")
|
||||
self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk")
|
||||
self.sessionDidStart(manager: sessionManager, didStart: session)
|
||||
}
|
||||
|
||||
func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) {
|
||||
self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk")
|
||||
print("resuming session")
|
||||
self.sessionDidStart(manager: sessionManager, didStart: session)
|
||||
}
|
||||
|
||||
func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) {
|
||||
dump(error)
|
||||
LogManager.shared.log.error((error as NSError).debugDescription)
|
||||
}
|
||||
|
||||
func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) {
|
||||
print("didEnd")
|
||||
if error != nil {
|
||||
LogManager.shared.log.error((error! as NSError).debugDescription)
|
||||
}
|
||||
|
||||
playerDestination = .local
|
||||
videoContentView.isHidden = false
|
||||
remoteTimeUpdateTimer?.invalidate()
|
||||
|
@ -900,7 +906,6 @@ extension PlayerViewController: GCKSessionManagerListener {
|
|||
}
|
||||
|
||||
func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) {
|
||||
print("didSuspend")
|
||||
playerDestination = .local
|
||||
videoContentView.isHidden = false
|
||||
remoteTimeUpdateTimer?.invalidate()
|
||||
|
@ -912,36 +917,35 @@ extension PlayerViewController: GCKSessionManagerListener {
|
|||
// MARK: - VLCMediaPlayer Delegates
|
||||
extension PlayerViewController: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||
let currentState: VLCMediaPlayerState = mediaPlayer.state
|
||||
switch currentState {
|
||||
case .stopped :
|
||||
break
|
||||
case .ended :
|
||||
break
|
||||
case .playing :
|
||||
print("Video is playing")
|
||||
sendProgressReport(eventName: "unpause")
|
||||
delegate?.hideLoadingView(self)
|
||||
paused = false
|
||||
|
||||
case .paused :
|
||||
print("Video is paused)")
|
||||
paused = true
|
||||
|
||||
case .opening :
|
||||
print("Video is opening)")
|
||||
|
||||
case .buffering :
|
||||
print("Video is buffering)")
|
||||
delegate?.showLoadingView(self)
|
||||
case .error :
|
||||
print("Video has error)")
|
||||
sendStopReport()
|
||||
case .esAdded:
|
||||
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
let currentState: VLCMediaPlayerState = mediaPlayer.state
|
||||
switch currentState {
|
||||
case .stopped :
|
||||
LogManager.shared.log.debug("Player state changed: STOPPED")
|
||||
break
|
||||
case .ended :
|
||||
LogManager.shared.log.debug("Player state changed: ENDED")
|
||||
break
|
||||
case .playing :
|
||||
LogManager.shared.log.debug("Player state changed: PLAYING")
|
||||
sendProgressReport(eventName: "unpause")
|
||||
delegate?.hideLoadingView(self)
|
||||
paused = false
|
||||
case .paused :
|
||||
LogManager.shared.log.debug("Player state changed: PAUSED")
|
||||
paused = true
|
||||
case .opening :
|
||||
LogManager.shared.log.debug("Player state changed: OPENING")
|
||||
case .buffering :
|
||||
LogManager.shared.log.debug("Player state changed: BUFFERING")
|
||||
delegate?.showLoadingView(self)
|
||||
case .error :
|
||||
LogManager.shared.log.error("Video had error.")
|
||||
sendStopReport()
|
||||
case .esAdded:
|
||||
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||
|
@ -951,8 +955,8 @@ extension PlayerViewController: VLCMediaPlayerDelegate {
|
|||
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
|
||||
seekSlider.setValue(mediaPlayer.position, animated: true)
|
||||
delegate?.hideLoadingView(self)
|
||||
|
||||
if manifest.type == "Episode" && upNextViewModel.item != nil{
|
||||
|
||||
if manifest.type == "Episode" && upNextViewModel.item != nil {
|
||||
if time > 0.96 {
|
||||
upNextView.isHidden = false
|
||||
self.jumpForwardButton.isHidden = true
|
||||
|
@ -961,7 +965,7 @@ extension PlayerViewController: VLCMediaPlayerDelegate {
|
|||
self.jumpForwardButton.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
timeText.text = String(mediaPlayer.remainingTime.stringValue.dropFirst())
|
||||
|
||||
if CACurrentMediaTime() - controlsAppearTime > 5 {
|
||||
|
|
|
@ -73,7 +73,7 @@ struct VideoPlayerCastDeviceSelector: View {
|
|||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Select Cast Destination")
|
||||
.navigationTitle(NSLocalizedString("Select Cast Destination", comment: ""))
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
|
|
|
@ -37,8 +37,8 @@ struct VideoPlayerSettings: View {
|
|||
weak var delegate: PlayerViewController!
|
||||
@State var captionTrack: Int32 = -99
|
||||
@State var audioTrack: Int32 = -99
|
||||
@State var playbackSpeedSelection : Int = 3
|
||||
|
||||
@State var playbackSpeedSelection: Int = 3
|
||||
|
||||
init(delegate: PlayerViewController) {
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ struct VideoPlayerSettings: View {
|
|||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Picker("Closed Captions", selection: $captionTrack) {
|
||||
Picker(NSLocalizedString("Closed Captions", comment: ""), selection: $captionTrack) {
|
||||
ForEach(delegate.subtitleTrackArray, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id)
|
||||
}
|
||||
|
@ -54,29 +54,24 @@ struct VideoPlayerSettings: View {
|
|||
.onChange(of: captionTrack) { track in
|
||||
self.delegate.subtitleTrackChanged(newTrackID: track)
|
||||
}
|
||||
Picker("Audio Track", selection: $audioTrack) {
|
||||
Picker(NSLocalizedString("Audio Track", comment: ""), selection: $audioTrack) {
|
||||
ForEach(delegate.audioTrackArray, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id).lineLimit(1)
|
||||
}
|
||||
}.onChange(of: audioTrack) { track in
|
||||
self.delegate.audioTrackChanged(newTrackID: track)
|
||||
}
|
||||
Picker("Playback Speed", selection: $playbackSpeedSelection) {
|
||||
Picker(NSLocalizedString("Playback Speed", comment: ""), selection: $playbackSpeedSelection) {
|
||||
ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in
|
||||
let speed = delegate.playbackSpeeds[speedIndex]
|
||||
if floor(speed) == speed {
|
||||
Text(String(format: "%.0fx", speed)).tag(speedIndex)
|
||||
}
|
||||
else {
|
||||
Text(String(format: "%.2fx", speed)).tag(speedIndex)
|
||||
}
|
||||
Text("\(String(speed))x").tag(speedIndex)
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackSpeedSelection, perform: { index in
|
||||
self.delegate.playbackSpeedChanged(index: index)
|
||||
})
|
||||
}.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Audio & Captions")
|
||||
.navigationTitle(NSLocalizedString("Audio & Captions", comment: ""))
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
|
|
|
@ -12,9 +12,9 @@ import JellyfinAPI
|
|||
|
||||
class UpNextViewModel: ObservableObject {
|
||||
@Published var largeView: Bool = false
|
||||
@Published var item: BaseItemDto? = nil
|
||||
var delegate: PlayerViewController?
|
||||
|
||||
@Published var item: BaseItemDto?
|
||||
weak var delegate: PlayerViewController?
|
||||
|
||||
func nextUp() {
|
||||
if delegate != nil {
|
||||
delegate?.setPlayerToNextUp()
|
||||
|
@ -23,9 +23,9 @@ class UpNextViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
struct VideoUpNextView: View {
|
||||
|
||||
|
||||
@ObservedObject var viewModel: UpNextViewModel
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
viewModel.nextUp()
|
||||
|
@ -36,7 +36,7 @@ struct VideoUpNextView: View {
|
|||
.foregroundColor(.white)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
Text(viewModel.item!.getEpisodeLocator())
|
||||
Text(viewModel.item?.getEpisodeLocator() ?? "")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
65
README.md
|
@ -1,25 +1,48 @@
|
|||
<h1 align="center">Swiftfin</h1>
|
||||
<h3 align="center">Part of the <a href="https://jellyfin.org">Jellyfin Project</a></h3>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/jellyfin/JellyfinPlayer">
|
||||
<img src="https://img.shields.io/github/license/jellyfin/swiftfin" alt="MPL 2.0 License" />
|
||||
</a>
|
||||
<a href="https://github.com/jellyfin/JellyfinPlayer/releases">
|
||||
<img src="https://img.shields.io/github/v/release/jellyfin/swiftfin" alt="GitHub release (latest SemVer)" />
|
||||
</a>
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
<img alt="SwiftFin" height="125" src="https://github.com/jellyfin/SwiftFin/raw/main/JellyfinPlayer/Assets.xcassets/AppIcon.appiconset/152.png">
|
||||
<h2 align="center">SwiftFin</h2>
|
||||
<a href="https://translate.jellyfin.org/engage/swiftfin/">
|
||||
<img src="https://translate.jellyfin.org/widgets/swiftfin/-/svg-badge.svg"/>
|
||||
</a>
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img src="https://img.shields.io/matrix/jellyfin:matrix.org">
|
||||
</a>
|
||||
<a href="https://sonarcloud.io/dashboard?id=jellyfin_SwiftFin">
|
||||
<img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_SwiftFin&metric=alert_status">
|
||||
</a>
|
||||
<a href="https://discord.gg/zHBxVSXdBV">
|
||||
<img src="https://img.shields.io/badge/Talk%20on-Discord-brightgreen">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>SwiftFin</b> is a modern client for the <a href="https://github.com/jellyfin/jellyfin">Jellyfin</a> media server. Redesigned in Swift to maximize direct play with the power of <b>VLC</b> and look <b>native</b> on all classes of Apple devices.
|
||||
</p>
|
||||
|
||||
---
|
||||
## ⚡️ Links!
|
||||
|
||||
[Join the Jellyfin Discord!](https://discord.gg/zHBxVSXdBV)
|
||||
Also available on Matrix, and IRC. See https://jellyfin.org/contact for options.
|
||||
[Beta test!](https://testflight.apple.com/join/WiN0G62Q)
|
||||
<a href='https://testflight.apple.com/join/WiN0G62Q'><img height='70' alt='Join the Beta on TestFlight' src='https://anotherlens.app/testflight-badge.png'/></a>
|
||||
|
||||
**Don't see SwiftFin in your language?**
|
||||
|
||||
Check out our [Weblate instance](https://translate.jellyfin.org/projects/swiftfin/) to help translate SwiftFin and other projects.
|
||||
|
||||
<a href="https://translate.jellyfin.org/engage/swiftfin/">
|
||||
<img src="https://translate.jellyfin.org/widgets/swiftfin/-/multi-auto.svg"/>
|
||||
</a>
|
||||
|
||||
## ⚙️ Development
|
||||
|
||||
Xcode 13.0 with command line tools.
|
||||
|
||||
### Build Process
|
||||
|
||||
```bash
|
||||
# install Cocoapods (if not installed)
|
||||
$ sudo gem install cocoapods
|
||||
|
||||
# install dependencies
|
||||
$ pod install
|
||||
|
||||
# open workspace and build it
|
||||
$ open JellyfinPlayer.xcworkspace
|
||||
```
|
|
@ -73,7 +73,7 @@ extension BaseItemDto {
|
|||
let urlString = "\(ServerEnvironment.current.server.baseURI!)/Items/\(imageItemId)/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=96&tag=\(imageTag)"
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
|
||||
func getEpisodeLocator() -> String {
|
||||
if let seasonNo = self.parentIndexNumber, let episodeNo = self.indexNumber {
|
||||
return "S\(seasonNo):E\(episodeNo)"
|
||||
|
@ -111,7 +111,7 @@ extension BaseItemDto {
|
|||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = "\(ServerEnvironment.current.server.baseURI!)/Items/\(imageItemId)/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=96&tag=\(imageTag)"
|
||||
//print(urlString)
|
||||
// print(urlString)
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
|
|
|
@ -16,4 +16,5 @@ extension Defaults.Keys {
|
|||
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false)
|
||||
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", default: "Auto")
|
||||
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto")
|
||||
static let appAppearance = Key<String>("appAppearance", default: AppAppearance.system.rawValue)
|
||||
}
|
||||
|
|
|
@ -111,6 +111,7 @@ class DeviceProfileBuilder {
|
|||
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external))
|
||||
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
|
||||
|
|
|
@ -31,7 +31,7 @@ struct ImageView: View {
|
|||
}
|
||||
.failure {
|
||||
Rectangle()
|
||||
.background(Color.gray)
|
||||
.fill(Color.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ public class ServerDiscovery {
|
|||
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) {
|
||||
do {
|
||||
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
|
||||
LogManager.shared.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery")
|
||||
completion(response)
|
||||
} catch {
|
||||
completion(nil)
|
||||
|
@ -85,6 +86,7 @@ public class ServerDiscovery {
|
|||
self.broadcastConn.handler = receiveHandler
|
||||
do {
|
||||
try broadcastConn.sendBroadcast("Who is JellyfinServer?")
|
||||
LogManager.shared.log.debug("Discovery broadcast sent", tag: "ServerDiscovery")
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
/*
|
||||
* 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 Foundation
|
||||
import Puppy
|
||||
|
||||
class LogManager {
|
||||
static let shared = LogManager()
|
||||
let log = Puppy()
|
||||
|
||||
init() {
|
||||
let console = ConsoleLogger("me.vigue.jellyfin.ConsoleLogger")
|
||||
let fileURL = self.getDocumentsDirectory().appendingPathComponent("logs.txt")
|
||||
let FM = FileManager()
|
||||
_ = try? FM.removeItem(at: fileURL)
|
||||
|
||||
do {
|
||||
let file = try FileLogger("me.vigue.jellyfin", fileURL: fileURL)
|
||||
file.format = LogFormatter()
|
||||
log.add(file, withLevel: .debug)
|
||||
} catch let err {
|
||||
log.error("Couldn't initialize file logger.")
|
||||
print(err)
|
||||
}
|
||||
console.format = LogFormatter()
|
||||
log.add(console, withLevel: .debug)
|
||||
log.info("Logger initialized.")
|
||||
}
|
||||
|
||||
func logFileURL() -> URL {
|
||||
return self.getDocumentsDirectory().appendingPathComponent("logs.txt")
|
||||
}
|
||||
|
||||
func getDocumentsDirectory() -> URL {
|
||||
// find all possible documents directories for this user
|
||||
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
|
||||
// just send back the first one, which ought to be the only one
|
||||
return paths[0]
|
||||
}
|
||||
}
|
||||
|
||||
class LogFormatter: LogFormattable {
|
||||
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String,
|
||||
file: String, line: UInt, swiftLogInfo: [String: String],
|
||||
label: String, date: Date, threadID: UInt64) -> String {
|
||||
let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "")
|
||||
return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)"
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ final class ServerEnvironment {
|
|||
}
|
||||
|
||||
func create(with uri: String) -> AnyPublisher<Server, Error> {
|
||||
LogManager.shared.log.debug("Initializing new Server object with raw URI: \"\(uri)\"")
|
||||
var uri = uri
|
||||
if !uri.contains("http") {
|
||||
uri = "https://" + uri
|
||||
|
@ -34,6 +35,7 @@ final class ServerEnvironment {
|
|||
if uri.last == "/" {
|
||||
uri = String(uri.dropLast())
|
||||
}
|
||||
LogManager.shared.log.debug("Normalized URI: \"\(uri)\", attempting to getPublicSystemInfo()")
|
||||
|
||||
JellyfinAPI.basePath = uri
|
||||
return SystemAPI.getPublicSystemInfo()
|
||||
|
|
|
@ -54,17 +54,18 @@ final class SessionManager {
|
|||
var deviceName = UIDevice.current.name
|
||||
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
|
||||
deviceName = String(deviceName.unicodeScalars.filter {CharacterSet.urlQueryAllowed.contains($0) })
|
||||
|
||||
|
||||
var header = "MediaBrowser "
|
||||
#if os(tvOS)
|
||||
header.append("Client=\"Jellyfin tvOS\", ")
|
||||
#else
|
||||
header.append("Client=\"SwiftFin iOS\", ")
|
||||
#endif
|
||||
|
||||
|
||||
header.append("Device=\"\(deviceName)\", ")
|
||||
|
||||
if(devID == nil) {
|
||||
|
||||
if devID == nil {
|
||||
LogManager.shared.log.info("Generating device ID...")
|
||||
#if os(tvOS)
|
||||
header.append("DeviceId=\"tvOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(String(Date().timeIntervalSince1970))\", ")
|
||||
deviceID = "tvOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(String(Date().timeIntervalSince1970))"
|
||||
|
@ -72,13 +73,12 @@ final class SessionManager {
|
|||
header.append("DeviceId=\"iOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(String(Date().timeIntervalSince1970))\", ")
|
||||
deviceID = "iOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(String(Date().timeIntervalSince1970))"
|
||||
#endif
|
||||
print("generated device id: \(deviceID)")
|
||||
} else {
|
||||
print("device id provided: \(devID!)")
|
||||
LogManager.shared.log.info("Using stored device ID...")
|
||||
header.append("DeviceId=\"\(devID!)\", ")
|
||||
deviceID = devID!
|
||||
}
|
||||
|
||||
|
||||
header.append("Version=\"\(appVersion ?? "0.0.1")\", ")
|
||||
|
||||
if authToken != nil {
|
||||
|
@ -116,8 +116,7 @@ final class SessionManager {
|
|||
|
||||
func loginWithSavedSession(user: SignedInUser) {
|
||||
let accessToken = getAuthToken(userID: user.user_id!)
|
||||
print("logging in with saved session");
|
||||
|
||||
|
||||
self.user = user
|
||||
generateAuthHeader(with: accessToken, deviceID: user.device_uuid)
|
||||
print(JellyfinAPI.customHeaders)
|
||||
|
@ -134,7 +133,7 @@ final class SessionManager {
|
|||
user.username = response.user?.name
|
||||
user.user_id = response.user?.id
|
||||
user.device_uuid = self.deviceID
|
||||
|
||||
|
||||
#if os(tvOS)
|
||||
// user.appletv_id = tvUserManager.currentUserIdentifier ?? ""
|
||||
#endif
|
||||
|
@ -161,7 +160,6 @@ final class SessionManager {
|
|||
func logout() {
|
||||
let nc = NotificationCenter.default
|
||||
nc.post(name: Notification.Name("didSignOut"), object: nil)
|
||||
dump(user)
|
||||
let keychain = KeychainSwift()
|
||||
keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain"
|
||||
keychain.delete("AccessToken_\(user?.user_id ?? "")")
|
||||
|
|
|
@ -67,3 +67,10 @@ extension APISortOrder {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ItemType: String {
|
||||
case episode = "Episode"
|
||||
case movie = "Movie"
|
||||
case series = "Series"
|
||||
case season = "Season"
|
||||
}
|
||||
|
|
|
@ -37,15 +37,19 @@ final class ConnectToServerViewModel: ViewModel {
|
|||
|
||||
func getPublicUsers() {
|
||||
if ServerEnvironment.current.server != nil {
|
||||
LogManager.shared.log.debug("Attempting to read public users from \(ServerEnvironment.current.server.baseURI!)", tag: "getPublicUsers")
|
||||
UserAPI.getPublicUsers()
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { completion in
|
||||
self.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { response in
|
||||
self.publicUsers = response
|
||||
LogManager.shared.log.debug("Received \(String(response.count)) public users.", tag: "getPublicUsers")
|
||||
self.isConnectedServer = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
LogManager.shared.log.debug("Not getting users - server is nil", tag: "getPublicUsers")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,35 +64,30 @@ final class ConnectToServerViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func connectToServer() {
|
||||
LogManager.shared.log.debug("Attempting to connect to server at \"\(uriSubject.value)\"", tag: "connectToServer")
|
||||
ServerEnvironment.current.create(with: uriSubject.value)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
let err = error as NSError
|
||||
LogManager.shared.log.critical("Error connecting to server at \"\(self.uriSubject.value)\"", tag: "connectToServer")
|
||||
LogManager.shared.log.critical(err.debugDescription, tag: "login")
|
||||
self.errorMessage = error.localizedDescription
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, receiveValue: { _ in
|
||||
LogManager.shared.log.debug("Connected to server at \"\(self.uriSubject.value)\"", tag: "connectToServer")
|
||||
self.getPublicUsers()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func connectToServer(at url: URL) {
|
||||
ServerEnvironment.current.create(with: url.absoluteString)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, receiveValue: { _ in
|
||||
self.getPublicUsers()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
uriSubject.send(url.absoluteString)
|
||||
self.connectToServer()
|
||||
}
|
||||
|
||||
func discoverServers() {
|
||||
|
@ -108,6 +107,8 @@ final class ConnectToServerViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func login() {
|
||||
LogManager.shared.log.debug("Attempting to login to server at \"\(uriSubject.value)\"", tag: "login")
|
||||
LogManager.shared.log.debug("username == \"\": \(usernameSubject.value.isEmpty), password == \"\": \(passwordSubject.value.isEmpty)", tag: "login")
|
||||
SessionManager.current.login(username: usernameSubject.value, password: passwordSubject.value)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { completion in
|
||||
|
@ -118,8 +119,13 @@ final class ConnectToServerViewModel: ViewModel {
|
|||
if let err = error as? ErrorResponse {
|
||||
switch err {
|
||||
case .error(401, _, _, _):
|
||||
LogManager.shared.log.critical("Error connecting to server at \"\(self.uriSubject.value)\"", tag: "login")
|
||||
LogManager.shared.log.critical("User provided invalid credentials, server returned a 401 error.", tag: "login")
|
||||
self.errorMessage = "Invalid credentials"
|
||||
case .error:
|
||||
let err = error as NSError
|
||||
LogManager.shared.log.critical("Error logging in to server at \"\(self.uriSubject.value)\"", tag: "login")
|
||||
LogManager.shared.log.critical(err.debugDescription, tag: "login")
|
||||
self.errorMessage = err.localizedDescription
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import JellyfinAPI
|
|||
class DetailItemViewModel: ViewModel {
|
||||
@Published var item: BaseItemDto
|
||||
@Published var similarItems: [BaseItemDto] = []
|
||||
|
||||
|
||||
@Published var isWatched = false
|
||||
@Published var isFavorited = false
|
||||
|
||||
|
@ -23,10 +23,10 @@ class DetailItemViewModel: ViewModel {
|
|||
isFavorited = item.userData?.isFavorite ?? false
|
||||
isWatched = item.userData?.played ?? false
|
||||
super.init()
|
||||
|
||||
|
||||
getRelatedItems()
|
||||
}
|
||||
|
||||
|
||||
func getRelatedItems() {
|
||||
LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.current.user.user_id!, limit: 20, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
|
||||
.trackActivity(loading)
|
||||
|
|
|
@ -33,12 +33,14 @@ final class HomeViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func refresh() {
|
||||
LogManager.shared.log.debug("Refresh called.")
|
||||
UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { completion in
|
||||
self.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { response in
|
||||
response.items!.forEach { item in
|
||||
LogManager.shared.log.debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")")
|
||||
if item.collectionType == "movies" || item.collectionType == "tvshows" {
|
||||
self.libraries.append(item)
|
||||
}
|
||||
|
@ -51,6 +53,7 @@ final class HomeViewModel: ViewModel {
|
|||
}, receiveValue: { response in
|
||||
self.libraries.forEach { library in
|
||||
if !(response.configuration?.latestItemsExcludes?.contains(library.id!))! {
|
||||
LogManager.shared.log.debug("Adding library \(library.id!) (\(library.name ?? "nil")) to recently added list")
|
||||
self.librariesShowRecentlyAddedIDs.append(library.id!)
|
||||
}
|
||||
}
|
||||
|
@ -66,6 +69,7 @@ final class HomeViewModel: ViewModel {
|
|||
.sink(receiveCompletion: { completion in
|
||||
self.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { response in
|
||||
LogManager.shared.log.debug("Retrieved \(String(response.items!.count)) resume items")
|
||||
self.resumeItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
|
@ -76,6 +80,7 @@ final class HomeViewModel: ViewModel {
|
|||
.sink(receiveCompletion: { completion in
|
||||
self.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { response in
|
||||
LogManager.shared.log.debug("Retrieved \(String(response.items!.count)) nextup items")
|
||||
self.nextUpItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
|
|
|
@ -25,6 +25,7 @@ final class LatestMediaViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func requestLatestMedia() {
|
||||
LogManager.shared.log.debug("Requesting latest media for user id \(SessionManager.current.user.user_id ?? "NIL")")
|
||||
UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, parentId: libraryID,
|
||||
fields: [
|
||||
.primaryImageAspectRatio,
|
||||
|
@ -40,6 +41,7 @@ final class LatestMediaViewModel: ViewModel {
|
|||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.items = response
|
||||
LogManager.shared.log.debug("Retrieved \(String(self?.items.count ?? 0)) items")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ final class LibraryFilterViewModel: ViewModel {
|
|||
var selectedSortOrder: APISortOrder = .descending
|
||||
@Published
|
||||
var selectedSortBy: SortBy = .name
|
||||
|
||||
|
||||
var parentId: String = ""
|
||||
|
||||
func updateModifiedFilter() {
|
||||
|
|
|
@ -8,12 +8,21 @@
|
|||
*/
|
||||
|
||||
import Combine
|
||||
import CombineExt
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
final class LibrarySearchViewModel: ViewModel {
|
||||
@Published
|
||||
var items = [BaseItemDto]()
|
||||
|
||||
@Published var supportedItemTypeList = [ItemType]()
|
||||
|
||||
@Published var selectedItemType: ItemType = .movie
|
||||
|
||||
@Published var movieItems = [BaseItemDto]()
|
||||
@Published var showItems = [BaseItemDto]()
|
||||
@Published var episodeItems = [BaseItemDto]()
|
||||
|
||||
@Published var suggestions = [BaseItemDto]()
|
||||
|
||||
var searchQuerySubject = CurrentValueSubject<String, Never>("")
|
||||
var parentID: String?
|
||||
|
@ -23,21 +32,99 @@ final class LibrarySearchViewModel: ViewModel {
|
|||
super.init()
|
||||
|
||||
searchQuerySubject
|
||||
.filter { !$0.isEmpty }
|
||||
.debounce(for: 0.25, scheduler: DispatchQueue.main)
|
||||
.sink(receiveValue: search(with:))
|
||||
.sink(receiveValue: search)
|
||||
.store(in: &cancellables)
|
||||
setupPublishersForSupportedItemType()
|
||||
|
||||
requestSuggestions()
|
||||
}
|
||||
|
||||
func setupPublishersForSupportedItemType() {
|
||||
|
||||
let supportedItemTypeListPublishers = Publishers.CombineLatest3($movieItems, $showItems, $episodeItems)
|
||||
.debounce(for: 0.25, scheduler: DispatchQueue.main)
|
||||
.map { arg -> [ItemType] in
|
||||
var typeList = [ItemType]()
|
||||
if !arg.0.isEmpty {
|
||||
typeList.append(.movie)
|
||||
}
|
||||
if !arg.1.isEmpty {
|
||||
typeList.append(.series)
|
||||
}
|
||||
if !arg.2.isEmpty {
|
||||
typeList.append(.episode)
|
||||
}
|
||||
return typeList
|
||||
}
|
||||
|
||||
supportedItemTypeListPublishers
|
||||
.assign(to: \.supportedItemTypeList, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
supportedItemTypeListPublishers
|
||||
.withLatestFrom(supportedItemTypeListPublishers, $selectedItemType)
|
||||
.compactMap { typeList, selectedItemType in
|
||||
if typeList.contains(selectedItemType) {
|
||||
return selectedItemType
|
||||
} else {
|
||||
return typeList.first
|
||||
}
|
||||
}
|
||||
.assign(to: \.selectedItemType, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func requestSuggestions() {
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!,
|
||||
limit: 20,
|
||||
recursive: true,
|
||||
parentId: parentID,
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
sortBy: ["IsFavoriteOrLiked", "Random"],
|
||||
imageTypeLimit: 0,
|
||||
enableTotalRecordCount: false,
|
||||
enableImages: false)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: handleAPIRequestCompletion(completion:)) { [weak self] response in
|
||||
self?.suggestions = response.items ?? []
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func search(with query: String) {
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 60, recursive: true, searchTerm: query,
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 50, recursive: true, searchTerm: query,
|
||||
sortOrder: [.ascending], parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true)
|
||||
includeItemTypes: [ItemType.movie.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.items = response.items ?? []
|
||||
self?.movieItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 50, recursive: true, searchTerm: query,
|
||||
sortOrder: [.ascending], parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
includeItemTypes: [ItemType.series.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.showItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 50, recursive: true, searchTerm: query,
|
||||
sortOrder: [.ascending], parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
includeItemTypes: [ItemType.episode.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.episodeItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
|
|
@ -68,16 +68,17 @@ final class LibraryViewModel: ViewModel {
|
|||
genreIDs = filters.withGenres.compactMap(\.id)
|
||||
}
|
||||
let sortBy = filters.sortBy.map(\.rawValue)
|
||||
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: filters.filters.contains(.isFavorite) ? true : false,
|
||||
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
|
||||
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
|
||||
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) items in library \(self?.parentID ?? "nil")")
|
||||
guard let self = self else { return }
|
||||
let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0)
|
||||
self.totalPages = Int(totalPages)
|
||||
|
@ -87,7 +88,7 @@ final class LibraryViewModel: ViewModel {
|
|||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
func requestItemsAsync(with filters: LibraryFilters) {
|
||||
let personIDs: [String] = [person].compactMap(\.?.id)
|
||||
let studioIDs: [String] = [studio].compactMap(\.?.id)
|
||||
|
@ -98,10 +99,10 @@ final class LibraryViewModel: ViewModel {
|
|||
genreIDs = filters.withGenres.compactMap(\.id)
|
||||
}
|
||||
let sortBy = filters.sortBy.map(\.rawValue)
|
||||
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: filters.filters.contains(.isFavorite) ? true : false,
|
||||
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
|
||||
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
|
||||
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
|
@ -121,7 +122,7 @@ final class LibraryViewModel: ViewModel {
|
|||
currentPage += 1
|
||||
requestItems(with: filters)
|
||||
}
|
||||
|
||||
|
||||
func requestNextPageAsync() {
|
||||
currentPage += 1
|
||||
requestItemsAsync(with: filters)
|
||||
|
|
|
@ -13,15 +13,16 @@ import JellyfinAPI
|
|||
|
||||
final class SeasonItemViewModel: DetailItemViewModel {
|
||||
@Published var episodes = [BaseItemDto]()
|
||||
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
self.item = item
|
||||
|
||||
|
||||
requestEpisodes()
|
||||
}
|
||||
|
||||
func requestEpisodes() {
|
||||
LogManager.shared.log.debug("Getting episodes in season \(self.item.id!) (\(self.item.name!)) of show \(self.item.seriesId!) (\(self.item.seriesName!))")
|
||||
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
seasonId: item.id ?? "")
|
||||
|
@ -30,6 +31,7 @@ final class SeasonItemViewModel: DetailItemViewModel {
|
|||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.episodes = response.items ?? []
|
||||
LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
|
|
@ -14,16 +14,17 @@ import JellyfinAPI
|
|||
final class SeriesItemViewModel: DetailItemViewModel {
|
||||
@Published var seasons = [BaseItemDto]()
|
||||
@Published var nextUpItem: BaseItemDto?
|
||||
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
self.item = item
|
||||
|
||||
|
||||
requestSeasons()
|
||||
getNextUp()
|
||||
}
|
||||
|
||||
|
||||
func getNextUp() {
|
||||
LogManager.shared.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
|
||||
TvShowsAPI.getNextUp(userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: self.item.id!, enableUserData: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
|
@ -33,32 +34,34 @@ final class SeriesItemViewModel: DetailItemViewModel {
|
|||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
func getRunYears() -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy"
|
||||
|
||||
var startYear: String? = nil
|
||||
var endYear: String? = nil
|
||||
|
||||
if(item.premiereDate != nil) {
|
||||
|
||||
var startYear: String?
|
||||
var endYear: String?
|
||||
|
||||
if item.premiereDate != nil {
|
||||
startYear = dateFormatter.string(from: item.premiereDate!)
|
||||
}
|
||||
|
||||
if(item.endDate != nil) {
|
||||
|
||||
if item.endDate != nil {
|
||||
endYear = dateFormatter.string(from: item.endDate!)
|
||||
}
|
||||
|
||||
|
||||
return "\(startYear ?? "Unknown") - \(endYear ?? "Present")"
|
||||
}
|
||||
|
||||
func requestSeasons() {
|
||||
LogManager.shared.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
|
||||
TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestCompletion(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.seasons = response.items ?? []
|
||||
LogManager.shared.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct UserSettings: Decodable {
|
||||
var LocalMaxBitrate: Int
|
||||
|
@ -30,10 +31,32 @@ struct TrackLanguage: Hashable {
|
|||
static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
|
||||
}
|
||||
|
||||
enum AppAppearance: String, CaseIterable {
|
||||
case system
|
||||
case dark
|
||||
case light
|
||||
|
||||
var localizedName: String {
|
||||
return NSLocalizedString(self.rawValue.capitalized, comment: "")
|
||||
}
|
||||
|
||||
var style: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .system:
|
||||
return .unspecified
|
||||
case .dark:
|
||||
return .dark
|
||||
case .light:
|
||||
return .light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsViewModel: ObservableObject {
|
||||
let currentLocale = Locale.current
|
||||
var bitrates: [Bitrates] = []
|
||||
var langs = [TrackLanguage]()
|
||||
let appearances = AppAppearance.allCases
|
||||
|
||||
init() {
|
||||
let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")!
|
||||
|
@ -43,10 +66,10 @@ final class SettingsViewModel: ObservableObject {
|
|||
do {
|
||||
self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData)
|
||||
} catch {
|
||||
print(error)
|
||||
LogManager.shared.log.error("Error converting processed JSON into Swift compatible schema.")
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
LogManager.shared.log.error("Error processing JSON file `bitrates.json`")
|
||||
}
|
||||
|
||||
self.langs = Locale.isoLanguageCodes.compactMap {
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import Nuke
|
||||
import UIKit
|
||||
|
||||
#if !os(tvOS)
|
||||
import WidgetKit
|
||||
|
@ -28,6 +29,7 @@ final class SplashViewModel: ViewModel {
|
|||
|
||||
#if !os(tvOS)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
UIScrollView.appearance().keyboardDismissMode = .onDrag
|
||||
#endif
|
||||
|
||||
let nc = NotificationCenter.default
|
||||
|
@ -36,12 +38,12 @@ final class SplashViewModel: ViewModel {
|
|||
}
|
||||
|
||||
@objc func didLogIn() {
|
||||
print("didLogIn")
|
||||
LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.")
|
||||
isLoggedIn = true
|
||||
}
|
||||
|
||||
@objc func didLogOut() {
|
||||
print("didLogOut")
|
||||
LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.")
|
||||
isLoggedIn = false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,9 +40,12 @@ class ViewModel: ObservableObject {
|
|||
if let err = error as? ErrorResponse {
|
||||
switch err {
|
||||
case .error(401, _, _, _):
|
||||
LogManager.shared.log.error("Request failed: User unauthorized, server returned a 401 error code.")
|
||||
self.errorMessage = err.localizedDescription
|
||||
SessionManager.current.logout()
|
||||
case .error:
|
||||
LogManager.shared.log.error("Request failed.")
|
||||
LogManager.shared.log.error((err as NSError).debugDescription)
|
||||
self.errorMessage = err.localizedDescription
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
|
||||
|
||||
"WIP" = "WIP";
|
||||
"Episodes" = "集";
|
||||
"Empty Next Up" = "接下来";
|
||||
"• %@ - %@" = "• %1$@ - %2$@";
|
||||
"• %@" = "• %@";
|
||||
"Your Favorites" = "你的最爱";
|
||||
"Who's watching?" = "谁在看?";
|
||||
"Username" = "用户名";
|
||||
"Type: %@ not implemented yet :(" = "%@ 类型暂不支持 :(";
|
||||
"Try again" = "重试";
|
||||
"Tags" = "标签";
|
||||
"Switch user" = "更换用户";
|
||||
"Suggestions" = "建议";
|
||||
"Studios:" = "电影公司:";
|
||||
"STUDIO" = "电音公司";
|
||||
"Sort by" = "排序方式";
|
||||
"Signed in as %@" = "作为%@登陆";
|
||||
"Server URL" = "服务器地址";
|
||||
"Server Information" = "服务器信息";
|
||||
"Select Cast Destination" = "选择投放设备";
|
||||
"See All" = "查看所有";
|
||||
"Seasons" = "季";
|
||||
"Search..." = "查找...";
|
||||
"S%@:E%@" = "第%1$季@:%2$@集";
|
||||
"Reset" = "重置";
|
||||
"Playback Speed" = "回放速度";
|
||||
"Playback settings" = "回放设置";
|
||||
"Play • %@" = "播放• %@";
|
||||
"Play Next" = "播放下一个";
|
||||
"Password" = "密码";
|
||||
"Page %@ of %@" = "%2$@的第%1$@页";
|
||||
"Other User" = "其他用户";
|
||||
"Ok" = "确定";
|
||||
"No results." = "没有结果。";
|
||||
"No Cast devices found.." = "没有可以投屏的设备..";
|
||||
"Next Up" = "接下来";
|
||||
"More Like This" = "更多此类型";
|
||||
"Login to %@" = "登陆到 %@";
|
||||
"Login" = "登陆";
|
||||
"Local Servers" = "本地服务器";
|
||||
"Loading" = "加载中";
|
||||
"Library" = "资料库";
|
||||
"Latest %@" = "最新 %@";
|
||||
"Home" = "首页";
|
||||
"Genres:" = "类别:";
|
||||
"Genres" = "类别";
|
||||
"Filters" = "过滤";
|
||||
"Filter Results" = "过滤结果";
|
||||
"Error" = "错误";
|
||||
"Display order" = "显示顺序";
|
||||
"Discovered Servers" = "可用的服务器";
|
||||
"DIRECTOR" = "导演";
|
||||
"Continue Watching" = "继续观看";
|
||||
"Connect to Server" = "连接服务器";
|
||||
"Connect to Jellyfin" = "连接到Jellyfin";
|
||||
"Connect Manually" = "手动连接";
|
||||
"Connect" = "连接";
|
||||
"Closed Captions" = "已关闭字幕";
|
||||
"Change Server" = "更换服务器";
|
||||
"CAST" = "阵容";
|
||||
"Back" = "返回";
|
||||
"Audio Track" = "音轨";
|
||||
"Audio & Captions" = "音频和字幕";
|
||||
"Apply" = "确认";
|
||||
"All Media" = "所有媒体";
|
||||
"All Genres" = "所有类别";
|
||||
"Accessibility" = "可访问性";
|
||||
"%@x" = "%@x";
|
||||
"%@ • %@" = "%1$@ • %2$@";
|
||||
"%@ · S%@:E%@" = "\"%1$@ · 季%2$@:集%3$@\"";
|
||||
"%@" = "%@";
|