Created
February 10, 2023 19:28
-
-
Save mageshsridhar/4fafe577c339c2f5839170a53393c130 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// ContentView.swift | |
// AppleMusicLyricsPlayer | |
// | |
// Created by Magesh Sridhar on 2/5/23. | |
// | |
import SwiftUI | |
import AVKit | |
struct ContentView: View { | |
@State var audioPlayer: AVAudioPlayer! | |
@State var progress: CGFloat = 0.0 | |
@State private var playing: Bool = false | |
@State var duration: Double = 0.0 | |
@State var formattedDuration: String = "" | |
@State var formattedProgress: String = "00:00" | |
var body: some View { | |
VStack(spacing: 0) { | |
HStack { | |
Image("Cover") | |
.resizable() | |
.scaledToFit() | |
.frame(width: 50, height: 50) | |
.cornerRadius(5) | |
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 5) | |
VStack(alignment: .leading) { | |
Text("Dreaming (feat. Danyka Nadeau)") | |
.foregroundStyle(.primary) | |
.bold() | |
.font(.subheadline) | |
Text("Virtual Riot") | |
.foregroundStyle(.secondary) | |
.font(.subheadline) | |
} | |
Spacer() | |
Image(systemName: "ellipsis.circle.fill") | |
.font(.title2) | |
.symbolRenderingMode(.hierarchical) | |
}.padding(.horizontal).padding(.horizontal).padding(.top, 70) | |
LyricView(formattedProgress: $formattedProgress, songProgress: $progress).padding(.top, 30) | |
GeometryReader { geometry in | |
ZStack(alignment: .leading) { | |
Rectangle().frame(width: geometry.size.width , height: 6) | |
.foregroundStyle(.secondary) | |
.opacity(0.3) | |
Rectangle().frame(width: min(progress*geometry.size.width, geometry.size.width), height: 6) | |
.foregroundStyle(.primary) | |
}.cornerRadius(45.0) | |
}.frame(height: 6) | |
.padding(.horizontal) | |
.padding(.horizontal) | |
HStack { | |
Text(formattedProgress) | |
.font(.caption.monospacedDigit()) | |
.foregroundStyle(.secondary) | |
Spacer() | |
Text(formattedDuration) | |
.font(.caption.monospacedDigit()) | |
.foregroundStyle(.secondary) | |
} | |
.padding(.vertical, 10) | |
.padding(.horizontal, 30) | |
HStack(alignment: .center, spacing: 20) { | |
Spacer() | |
Image(systemName: "backward.fill") | |
.font(.largeTitle) | |
.imageScale(.small) | |
Spacer() | |
Button(action: { | |
if audioPlayer.isPlaying { | |
playing = false | |
self.audioPlayer.pause() | |
} else if !audioPlayer.isPlaying { | |
playing = true | |
self.audioPlayer.play() | |
} | |
}) { | |
Image(systemName: playing ? | |
"pause.fill" : "play.fill") | |
.resizable() | |
.scaledToFit() | |
.frame(width: 40, height:40) | |
.foregroundColor(.white) | |
} | |
Spacer() | |
Image(systemName: "forward.fill") | |
.font(.largeTitle) | |
.imageScale(.small) | |
Spacer() | |
}.frame(maxWidth: 100) | |
Text("Developed by Magesh Sridhar using SwiftUI 💜") | |
.font(.caption) | |
.bold() | |
.padding() | |
.padding(.vertical, 10) | |
} | |
.frame(maxHeight: .infinity) | |
.background(Image("BG") | |
.resizable() | |
.scaledToFill() | |
.blur(radius: 80)) | |
.ignoresSafeArea() | |
.onAppear { | |
initialiseAudioPlayer() | |
} | |
} | |
func initialiseAudioPlayer() { | |
let formatter = DateComponentsFormatter() | |
formatter.allowedUnits = [.minute, .second] | |
formatter.unitsStyle = .positional | |
formatter.zeroFormattingBehavior = [ .pad ] | |
let path = Bundle.main.path(forResource: "Dreaming", ofType: "mp3")! | |
self.audioPlayer = try! AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) | |
self.audioPlayer.prepareToPlay() | |
formattedDuration = formatter.string(from: TimeInterval(self.audioPlayer.duration))! | |
duration = self.audioPlayer.duration | |
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in | |
if !audioPlayer.isPlaying { | |
playing = false | |
} | |
progress = CGFloat(audioPlayer.currentTime / audioPlayer.duration) | |
formattedProgress = formatter.string(from: TimeInterval(self.audioPlayer.currentTime))! | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} | |
var lyricsList: [String] = [ | |
"", | |
"Freedom of mind, I'm faster than the speed of sound", | |
"Let's dim the lights and shift into new paradise", | |
"Little child, living wild", | |
"Where do you go when you sleep at night?", | |
"Can you reach the sky or walk on fire?", | |
"Where do you go when you're dreaming", | |
"Dreaming?", | |
"When you're dreaming", | |
"Where do you go when you're dreaming", | |
"Dreaming?", | |
"When you're dreaming", | |
"Tell me where do you go", | |
"Brwrabum", | |
"Bubipiwrabum", | |
"Brrwraabum Tana tun tun", | |
"Brwrabum", | |
"Bubipiwrabum", | |
"Papapapa", | |
"Wrrrrooomm", | |
"Brwrabum", | |
"Bubipiwrabum", | |
"Brrwraabum Tun tun tun", | |
"Brwrabum", | |
"Bubipiwrabum", | |
"Bwan bwan bwan wraaw", | |
"Brwyuhum", | |
"Bubipiyuhum", | |
"Brrrwyuhum Tun tun tun", | |
"Brwyuhm", | |
"Bubipiyuhm", | |
"Papapapa", | |
"Vrrmmmmmmm", | |
"Brwyuhum Bubipiyuhum", | |
"Brwyuhum Bubipiyuhum", | |
"Brrrwyuhum Tun tun tun", | |
"Brwyuhum Bubipiyuhum", | |
"Tell me where do you go", | |
"Tell me where–", | |
"Tell me", | |
"Tell me where do you go", | |
"Tell me where–", | |
"Tell me", | |
"Written by: Christian Valentin Brunn, Danyka Nadeau", | |
] | |
let timestamps : [String:Int] = ["00:00": 0, "00:07":1, "00:14":2, "00:18":3, "00:21":4, "00:25":5, "00:30":6, "00:33":7, "00:38":8, "00:44":9, "00:46": 10, "00:53": 11, "00:54": 12, "00:55": 13, "00:56": 14, "00:57":15, "00:58":16, "00:59":17, "01:00": 18, "01:01":19, "01:02":20, "01:03":21, "01:04":22, "01:05":23, "01:06": 24, "01:08":25, "01:09":26, "01:10":27, "01:11":28, "01:12": 29, "01:13" : 30, "01:14":31, "01:15" : 32, "01:16":33, "01:17":34, "01:19":35, "01:20":36, "01:27":37, "01:28":38, "01:34":39, "01:41":40, "01:42": 41, "01:43" : 42] | |
let numberOfLines : [Int] = [1, 3, 2, 1, 2, 2, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2] | |
let animationLength : [Double] = [0, 7, 7, 6, 3, 4, 5, 3, 5, 6, 3, 7, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 1, 1, 3] | |
var textWidths : [CGFloat] = [] | |
struct LyricView: View { | |
@Binding var formattedProgress: String | |
@Binding var songProgress: CGFloat | |
var body: some View { | |
VStack(alignment: .leading, spacing: 20) { | |
ForEach(0...lyricsList.count - 1, id: \.self) { i in | |
LyricLine(i: i, formattedProgress: $formattedProgress, songProgress: $songProgress) | |
} | |
}.padding() | |
.padding() | |
.offset(y: 1040) | |
.frame(height: 450) | |
.mask { | |
LinearGradient( | |
stops: [ | |
Gradient.Stop(color: .clear, location: .zero), | |
Gradient.Stop(color: .black, location: 0.01), | |
Gradient.Stop(color: .black, location: 0.65), | |
Gradient.Stop(color: .clear, location: 1.0) | |
], | |
startPoint: .top, | |
endPoint: .bottom | |
) | |
} | |
} | |
} | |
struct LyricLine: View { | |
@State var lineNumber: Int = 0 | |
var i: Int | |
@State var phase: CGFloat = 0 | |
@State var textWidth : CGFloat = 0 | |
@State var startShakeEffect = false | |
@Binding var formattedProgress : String | |
@Binding var songProgress : CGFloat | |
@State var offset : CGFloat = 0 | |
var body: some View { | |
Text(lyricsList[i]) | |
.font(getFontType(i: i)) | |
.padding(.vertical, lyricsList[i].count == 5 ? 20 : 0) | |
.bold() | |
.foregroundStyle(.secondary) | |
.fixedSize(horizontal: false, vertical: true) | |
.modifier(i == lineNumber ? AnimatedMask(phase: phase, textWidth: textWidth, lineNumber: lineNumber) : AnimatedMask(textWidth: 0, lineNumber: i)) | |
.blur(radius: i == lineNumber ? 0 : 4) | |
.glow(color: i == lineNumber && (lineNumber == 7 || lineNumber == 10) ? .purple : .clear, radius: phase * 8) | |
.modifier(startShakeEffect ? ShakeEffect(shakeNumber: 16) : ShakeEffect(shakeNumber: 0)) | |
.background(GeometryReader { g in | |
if i == lineNumber { | |
Color.clear.onAppear { | |
textWidth = g.size.width | |
if lineNumber == 18 || lineNumber == 31 { | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { | |
withAnimation(.linear(duration: animationLength[lineNumber])) { | |
startShakeEffect = true | |
} | |
} | |
} else { | |
startShakeEffect = false | |
} | |
} | |
} | |
}) | |
.offset(y: offset) | |
.onChange(of: formattedProgress) { newValue in | |
if let scrollToLine = timestamps[newValue] { | |
withAnimation(.spring()) { | |
lineNumber = scrollToLine + 1 | |
} | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 * Double(i-lineNumber)) { | |
withAnimation(.spring()) { | |
offset = offset - (33.6 * CGFloat(numberOfLines[scrollToLine])) - 20 | |
} | |
} | |
phase = 0 | |
withAnimation(.easeInOut(duration: animationLength[lineNumber])) { | |
phase = 1 | |
} | |
} | |
} | |
.onChange(of: songProgress) { newValue in | |
if newValue > 0 && newValue < 0.0003 { | |
if let scrollToLine = timestamps["00:00"] { | |
withAnimation(.spring()) { | |
lineNumber = scrollToLine + 1 | |
} | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 * Double(i-lineNumber)) { | |
withAnimation(.spring()) { | |
offset = offset - 15 | |
} | |
} | |
phase = 0 | |
withAnimation(.easeInOut(duration: animationLength[lineNumber])) { | |
phase = 1 | |
} | |
} | |
} | |
} | |
} | |
} | |
func getFontType(i: Int) -> Font { | |
return i < lyricsList.count - 1 ? .title : .subheadline | |
} | |
struct OverlayView: View { | |
let width: CGFloat | |
let progress: CGFloat | |
let lineNumber: Int | |
var body: some View { | |
Path() { path in | |
for i in 0...numberOfLines[lineNumber] { | |
let yValue : CGFloat = (18 * CGFloat(i+1)) + (20 * CGFloat(i)) | |
path.move(to: CGPoint(x: 0, y: yValue)) | |
path.addLine(to: CGPoint(x: width, y: yValue)) | |
} | |
}.trim(from: 0, to: progress) | |
.stroke(lineWidth: 38) | |
} | |
} | |
struct AnimatedMask: AnimatableModifier { | |
var phase: CGFloat = 0 | |
var textWidth: CGFloat | |
var lineNumber: Int | |
var animatableData: CGFloat { | |
get { phase } | |
set { phase = newValue } | |
} | |
func body(content: Content) -> some View { | |
content | |
.overlay(OverlayView(width: textWidth, progress: phase, lineNumber: lineNumber)) | |
.mask(MaskTextView(lineNumber: lineNumber)) | |
} | |
} | |
extension View { | |
func glow(color: Color = .red, radius: CGFloat = 20) -> some View { | |
self | |
.shadow(color: color, radius: radius / 3) | |
.shadow(color: color, radius: radius / 3) | |
.shadow(color: color, radius: radius / 3) | |
} | |
} | |
struct MaskTextView : View { | |
var lineNumber: Int | |
var body: some View { | |
Text(lyricsList[lineNumber]) | |
.font(getFontType(i: lineNumber)) | |
.padding(.vertical, lyricsList[lineNumber].count == 5 ? 20 : 0) | |
.bold() | |
.fixedSize(horizontal: false, vertical: true) | |
} | |
} | |
struct ShakeEffect: AnimatableModifier { | |
var shakeNumber: CGFloat = 0 | |
var animatableData: CGFloat { | |
get { | |
shakeNumber | |
} set { | |
shakeNumber = newValue | |
} | |
} | |
func body(content: Content) -> some View { | |
content | |
.offset(x: sin(shakeNumber * .pi * 2) * 5) | |
} | |
} | |
Some notes for this Gist -
- This works properly only on iPhone 14 Pro simulator, you have to make changes in the code to make it work properly on other devices.
- This was made specifically for the song "Virtual Riot - Dreaming"
- You need the song file in the project folder. I recommend buying it from Amazon ($0.99) to support the artist.
- You can use any song but you need to make the changes accordingly in the code to make it work.
- I didn't implement using a ScrollView so you can't scroll through the lyrics.
- You can't scrub through the song, you have to sit through the whole thing or replay the whole thing from the beginning (Sorry)
- Only the Play/Pause button works, rest are there just dummy buttons.
Thanks for checking this out.
Thank you Bruv
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Preview - https://www.reddit.com/r/iOSProgramming/comments/10y0jcp/i_recreated_the_new_progressing_style_lyrics_from/