Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
Aiden Vigue 2021-07-27 16:10:36 -04:00
commit 68b3adf8cc
No known key found for this signature in database
GPG Key ID: E7570472648F4544
78 changed files with 1179 additions and 449 deletions

View File

@ -15,7 +15,7 @@ jobs:
- "JellyfinPlayer"
- "JellyfinPlayer tvOS"
runs-on: macos-latest
runs-on: macos-11
steps:
- uses: maxim-lobanov/setup-xcode@v1

View File

@ -6,7 +6,7 @@
"scale" : "1x"
},
{
"filename" : "400x240-back-1.png",
"filename" : "Webp.net-resizeimage.png",
"idiom" : "tv",
"scale" : "2x"
}

View File

@ -6,7 +6,7 @@
"scale" : "1x"
},
{
"filename" : "216-1.png",
"filename" : "Webp.net-resizeimage-2.png",
"idiom" : "tv",
"scale" : "2x"
}

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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)

View File

@ -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
}

View File

@ -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))

View File

@ -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: ""))
}
}

View File

@ -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)

View File

@ -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>

View File

@ -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()

View File

@ -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"
}
}
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -10,11 +10,11 @@
import SwiftUI
class AudioViewController: InfoTabViewController {
override func viewDidLoad() {
super.viewDidLoad()
tabBarItem.title = "Audio"
tabBarItem.title = NSLocalizedString("Audio", comment: "")
}

View File

@ -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 {

View File

@ -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))
}

View File

@ -14,7 +14,7 @@ class SubtitlesViewController: InfoTabViewController {
override func viewDidLoad() {
super.viewDidLoad()
tabBarItem.title = "Subtitles"
tabBarItem.title = NSLocalizedString("Subtitles", comment: "")
}

View File

@ -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")

View File

@ -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" */;

View File

@ -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"
}
}
]
},

View File

@ -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)

View File

@ -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: ""))
}
}

View File

@ -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))

View File

@ -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)
}

View File

@ -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 {

View File

@ -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>

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 ""
}
}
}

View File

@ -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)
}
}

View File

@ -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"
}
}
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
```

View File

@ -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)!
}

View File

@ -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)
}

View File

@ -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))

View File

@ -31,7 +31,7 @@ struct ImageView: View {
}
.failure {
Rectangle()
.background(Color.gray)
.fill(Color.gray)
}
}
}

View File

@ -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)
}

View File

@ -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)"
}
}

View File

@ -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()

View File

@ -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 ?? "")")

View File

@ -67,3 +67,10 @@ extension APISortOrder {
}
}
}
enum ItemType: String {
case episode = "Episode"
case movie = "Movie"
case series = "Series"
case season = "Season"
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -39,7 +39,7 @@ final class LibraryFilterViewModel: ViewModel {
var selectedSortOrder: APISortOrder = .descending
@Published
var selectedSortBy: SortBy = .name
var parentId: String = ""
func updateModifiedFilter() {

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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$@\"";
"%@" = "%@";