Image source: unsplash.com/photos/aWQs…

Author: Yan Bing

preface

Apple released WidgetKit at WWDC20 this year, which allows dynamic messages and personalized content to be displayed on the iOS and iPadOS home screens. With the addition of the iOS app drawer, Apple has made a major push to keep the home screen conservative, leading users to expect widgets as well. However, there are many limitations in the operation of widgets, so how to do a good job in user experience on the limited mechanism becomes a challenge to be completed.

Unit description

Widgets can display content and switch functions on the home screen. The system will obtain the timeline from the team and display the data on the timeline according to the current time. Click on the visual element being displayed to jump to the APP and realize the corresponding function.

The widget effects of cloud music are as follows:

Discussion on development Ideas

First of all, it should be made clear that widgets are independent of App environment (i.e. App Extension), and the widget’s life cycle/storage space/running process is different from that of App. Therefore, we need to introduce some infrastructure in this environment, such as network communication framework, image caching framework, data persistence framework and so on.

The lifecycle of the widget itself is an interesting point. The widget lifecycle is frankly consistent with the desktop process, but this does not mean that the widget can execute code at any time to complete the business. Widgets use data defined by Timeline to render the view, and our code can only be executed when refreshing the Timeline (getTimeline) and creating a snapshot (getSnapshot). In general, the network data is captured when the Timeline is refreshed and the appropriate view is rendered when the snapshot is created.

In most cases, you need to use data to drive the view presentation. This data can be obtained via a network request or from the App using the sharing mechanism of App Groups.

After the data is obtained when refreshing the Time Line, the Timeline can be synthesized according to service requirements. Timeline is an array of TimelineEntry elements. The TimelineEntry contains a date time object that tells the system when to use this object to create a snapshot of the widget. You can also inherit TimelineEntry and add data models or other information needed by the business.

In order for widgets to display views, SwiftUI is used to complete the layout and styling of widgets. How to implement the layout and style is described below.

After the user clicks on the widget, the App opens and the AppDelegate’s openURL: method is called. We need to handle this event in openURL: to redirect the user directly to the desired page or to invoke a function.

Finally, if you need custom options open to the user widget, the Intents framework is used to pre-define the data structure and provide the data when the user edits the widget, and the system draws the interface based on the data. The customized data selected by the user is provided in the form of parameters when refreshing the Time Line (getTimeline) and creating a snapshot (getSnapshot). Then, different service logic can be executed based on the customized data.

App Extension

If you already have experience with App Extension development, skip this section.

According to Apple, App Extension extends custom functionality and content beyond an App and provides it to users as they interact with other applications or systems. For example, your application can be displayed as a widget on the home screen. In other words, the widget is an App Extension, and the development work of the widget is basically in the App Extension environment.

What is the relationship between App and App Extension?

Essentially two separate programs, your main program has no access to either the App Extension code or its storage space. It’s completely two processes, two programs. App Extension relies on your App body as a carrier. If you uninstall your App, your App Extension will no longer exist in the system. Moreover, the life cycle of App Extension mostly acts in a specific domain and is managed by system control according to events triggered by users.

Create an App Extension and configuration file

Here’s a brief description of how to create the widget’s App Extension and configure the certificate environment.

Add a Widget Extension to Xcode (file-new-target-ios tabbed-widget Extension). If you need custom functionality for the widget, don’t forget to check the Include Configuration Intent.

Add App Groups to the Target of the Widget Extension and keep the same App Group ID as the main application. If there is no App Groups in the main App, add the App Groups of the main App and define the Group ID.

If your developer account is logged in to Xcode, the application configuration file and App ID will be correct. If you are not logged in to Xcode, you will need to go to the Apple Developer Center and manually create the App ID and configuration file for your App Extension. Don’t forget to configure App Groups in the App ID at this point.

App Groups data communication

Since App and App Extension cannot communicate directly, App Groups should be used to communicate when information needs to be shared. App Groups have two ways of sharing data, NSUserDefaults and NSFileManager.

NSUserDefaults shares data

Initialize the instance with initWithSuiteName: of NSUserDefaults. Suitename passed in the previously defined App GroupID.

- (instancetype)initWithSuiteName:(NSString *)suitename;
Copy the code

You can then use the access methods of NSUserDefaults instances to store and retrieve shared data. For example, if we need to share current user information with the widget, we can do the following.

// Use the Groups ID to initialize an NSUserDefaults object for App Groups
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.company.appGroupName"];

// Write data
[userDefaults setValue:@ "123456789" forKey:@"userID"];

// Read data
NSString *userIDStr = [userDefaults valueForKey:@"userID"];
Copy the code

NSFileManager shares data

Use NSFileManager containerURLForSecurityApplicationGroupIdentifier: to obtain the App Group Shared storage address, file access operations can be achieved.

- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier;
Copy the code

SwiftUI build components

Presumably due to considerations such as power consumption, Apple requires that widgets only use SwiftUI and cannot be used through UIViewRepresentable Bridges to UIKit.

Widgets interact in a simple, click-only way with a small view. SwiftUI knowledge required for development is relatively simple. It is only necessary to reasonably build the widget view. Generally speaking, data binding and other operations are not involved.

This section focuses on building widgets using SwiftUI, and I will assume that the reader already has a basic knowledge of SwiftUI. If you’re new to SwiftUI, check out the two video tutorials in Resources (15 minutes of SwiftUI Layout / 15 minutes of SwiftUI Style) to improve your understanding. You can also consult the development documentation or related topics on WWDC19/20 for more SwiftUI knowledge.

Complete the widget view using SwiftUI

Here is a simple development example to help you develop the widget view using SwiftUI.

First look at the visual draft of the team:

Here’s a quick look at the view elements in visual artwork:

  1. Cover all background images (Image)
  2. Black gradient from bottom to top (LinearGradient)
  3. Cloud Music Logo(Image)
  4. Calendar icon in the middle of the widget (Image)
  5. Two lines below the calendar icon (Text)

Through analysis, it is not difficult to find that to achieve the effect of visual draft, we need to use three components: Text, Image and LinearGradient.

Classify visual elements 1/2/3 as background views to facilitate reuse by other components. The 4/5 related component content types are then grouped into the foreground view.

To implement the background view:

struct WidgetSmallBackgroundView: View {
    
    // The bottom mask is 40% of the overall height
    var contianerRatio : CGFloat = 0.4
    
    // Background image
    var backgroundImage : Image = Image("backgroundImageName")
    
    // Gradient color from top to bottom
    let gradientTopColor = Color(hex:0x000000, alpha: 0)
    let gradientBottomColor = Color(hex:0x000000, alpha: 0.35)
    
    // Mask view simple encapsulation makes code more intuitive
    func gradientView(a) -> LinearGradient {
        return LinearGradient(gradient: Gradient(colors: [gradientTopColor, gradientBottomColor]), startPoint: .top, endPoint: .bottom)
    }
    
    var body: some View {
        // Use the GeometryReader to get the widget size
        GeometryReader{ geo in
            // Use ZStack to stack the logo icon and the bottom mask
            ZStack{
                // Create a LOGO icon, use frame to determine the size of the icon, and use Position to locate the icon
                Image("icon_logo")
                    .resizable()
                    .scaledToFill()
                    .frame(width: 20, height: 20)
                    .position(x: geo.size.width - (20/2) - 10 , y : (20/2) + 10)
                    .ignoresSafeArea(.all)

                // Create a mask view using frame to determine the size of the mask and Position to position the mask
                gradientView()
                    .frame(width: geo.size.width, height: geo.size.height * CGFloat(contianerRatio))
                    .position(x: geo.size.width / 2.0, y: geo.size.height * (1 - CGFloat(contianerRatio / 2.0)))
            }
            .frame(width: geo.size.width, height: geo.size.height)
            // Add the background image at the bottom of the overlay
            .background(backgroundImage
                            .resizable()
                            .scaledToFill()
            )
        }
    }
}
Copy the code

The background view will look like this:

Placing the background view in the widget view and implementing the icon and copywriting view in the middle completes the visual construction of the entire component:

struct WidgetSmallView : View {
    
    // Set the width and height of the large icon to 40% of the height of the widget
    func bigIconWidgetHeight(viewHeight:CGFloat) -> CGFloat {
        return viewHeight * 0.4
    }
    
    var body: some View {
        
        GeometryReader{ geo in
            VStack(alignment: .center, spacing : 2) {Image("iconImageName")
                    .resizable()
                    .scaledToFill()
                    .frame(width: bigIconWidgetHeight(viewHeight: geo.size.height), height: bigIconWidgetHeight(viewHeight: geo.size.height))
                
                Text("Recommended of the Day")
                    .foregroundColor(.white)
                    .font(.system(size: 15))
                    .fontWeight(.medium)
                    .lineLimit(1)
                    .frame(height: 21)
                
                Text("A daily surprise for you.")
                    .foregroundColor(.white)
                    .font(.system(size: 13))
                    .fontWeight(.regular)
                    .opacity(0.8)
                    .lineLimit(1)
                    .frame(height: 18)}// Add padding so that Text is too long to touch the widget border
            .padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
            .frame(width: geo.size.width, height: geo.size.height, alignment: .center)
            // Set the background view
            .background(WidgetSmallBackgroundView()}}}Copy the code

As you can see from the simple example above, in a regular streaming layout, using VStack and HStack can achieve the layout effect. If you want to achieve the effect of the logo in the example, you need to use position/offset to change the positioning coordinates to achieve the goal.

A quick note about the Link view

Link is a clickable view that opens in the associated application if possible, but otherwise in the user’s default Web browser. Medium/large widgets can use it to set different jump parameters for the click area. Because the above example is a small component, it is not possible to use Link to distinguish jumps, so it is added here.

Link("View Our Terms of Service", destination: URL(string: "https://www.example.com/TOS.html")!)
Copy the code

To get the data

Network request

You can use URLSession in the widget, so the network request is basically the same as in the App, so I won’t go into details here.

Points to note:

  1. Using third-party frameworks requires importing the Target of the widget.
  2. The network request is invoked when the Timeline is refreshed.
  3. If you need to share information with the App, you need to access the information through the App Group.

Image loading cache

Image caching is different from App. Currently the Image view in SwiftUI does not support passing in urls to load web images. It is also not possible to load network images asynchronously by obtaining Data of network images. The data of all network pictures on the Timeline can only be obtained by refreshing the Timeline and invoking the network request after completion.

    func getTimeline(for configuration: Intent.in context: Context.completion: @escaping (Timeline<Entry>) - > ()) {
        // Initiate a network request
        widgetManager.requestAPI(family : context.family, configuration: configuration) { widgetResponse, date in
            // Generate the Timeline entry in the interface callback
            let entry = WidgetEntry(date: Date(), configuration: configuration, response: widgetResponse, family : context.family)
            // Parse out the network image required for the Timeline entry
            let urls = entry.urlsNeedDownload()
            // Query the local cache and download network images
            WidgetImageManager().getImages(urls: urls) {
                let entries = [entry]
                let timeline = Timeline(entries: entries, policy: .after(date))
                completion(timeline)
            }
        }
    }
Copy the code

In the getImages method, we need to maintain a queue to query the local cache and download the network image if the cache misses.

    public func getImages(urls : [String].complition : @escaping() - > ()){
        
        // Create directory
        WidgetImageManager.createImageSaveDirIfNeeded()
        
        / / to heavy
        let urlSet = Set(urls)
        let urlArr = Array(urlSet)
        
        self.complition = complition
        
        self.queue = OperationQueue.main
        self.queue?.maxConcurrentOperationCount = 2

        let finishBlock = BlockOperation {
            self.complition?()}for url in urlArr {
            let op = SwiftOperation { finish in
                self.getImage(url: url) {
                    finish(true)
                }
            }
            
            finishBlock.addDependency(op)
            self.queue?.addOperation(op)
        }
        
        self.queue?.addOperation(finishBlock)
    }
    
    public func getImage(url : String , complition : @escaping() - > ()) -> Void {
        let path = WidgetImageManager.pathFromUrl(url: url)
        if FileManager.default.fileExists(atPath: path) {
            complition()
            return
        }
        
        let safeUrl = WidgetImageManager.filterUrl(url: url)
        WidgetHttpClient.shareInstance.download(url: safeUrl, dstPath: path) { (result) in
            complition()
        }
    }

Copy the code

Preview state data acquisition

When the user adds a widget, a view of the widget is displayed in the preview screen. At this point, the system will trigger the widget’s placeholder method, where we need to return a Timeline to render the preview view.

In order to ensure the user experience, it is necessary to prepare a local pocket data for the interface call, ensure that the user can see the real view in the preview interface, try not to show the skeleton screen without data.

TimeLine

Widget content changes are dependent on Timeline. Widgets are essentially a series of static views driven by Timeline.

Understand the TimeLine

As mentioned earlier, Timeline is an array of TimelineEntry elements. The TimelineEntry contains a date time object that tells the system when to use this object to create a snapshot of the widget. You can also inherit TimelineEntry and add data models or other information needed by the business.

Until a new Timeline is generated, the system uses the last generated Timeline to display data.

If there is only one entry in the Timeline array, the view is immutable. If you need widgets to change over time, you can generate multiple entries in the Timeline and assign appropriate times to them. The system will use the entries to drive the view at the specified time.

Reload

The widget refresh is a refresh of the Timeline, which changes the widget view driven by the Timeline data.

There are two refresh methods:

  1. System reloads
  2. App-driven reloads

System reloads

Timeline refreshes initiated by the system. The System determines the frequency of System Reloads for each Timeline. Refresh requests that exceed the frequency will not take effect. Widgets that are used more frequently get more refresh frequency.

ReloadPolicy: When generating the Timeline, you can define a ReloadPolicy that tells the system when to update the Timeline. ReloadPolicy comes in three forms:

  • atEnd
    • Refresh the Timeline after all entries provided by the Timeline are displayed. That is, the current Timeline will not be refreshed as long as there are still entries that are not displayed

  • after(date)
    • Date is the time when the system refreshes the Timeline.

  • never
    • The ReloadPolicy never refreshes the Timeline, and the widget remains displayed after the last entry has been displayed

The timing of the Timeline Reload is uniformly controlled by the system. In order to ensure performance, the system will decide whether to refresh the Timeline at a certain time according to the refresh timing required by the APP according to the importance level of each Reload request. Therefore, if the request to refresh the Timeline is too frequent, it is likely to be limited by the system and not achieve the desired refresh effect. In other words, the timing defined in atEnd, after(date) above can be regarded as the earliest time to refresh the Timeline, but depending on the system schedule, these timing may be delayed.

App-driven reloads

Refresh the Timeline of the team triggered by the App. When App is in the background, background push can trigger reload. When the App is in the foreground, WidgetCenter can actively trigger reload.

A call to WidgetCenter can refresh some or all of the widgets based on the KIND identifier.

/// Reloads the timelines for all widgets of a particular kind.
/// - Parameter kind: A string that identifies the widget and matches the
/// value you used when you created the widget's configuration.
public func reloadTimelines(ofKind kind: String)

/// Reloads the timelines for all configured widgets belonging to the
/// containing app.
public func reloadAllTimelines(a)

Copy the code

Click on the ground

When the user clicks the content or function entry on the widget, it needs to respond to the user’s needs correctly after opening the App and present the corresponding content or function to the user. This needs to be done in two parts. First, define different parameters for different click areas in the widget, and then present different interfaces according to different parameters in the App openURL:.

Distinguish between different click areas

To define different parameters for different regions, use widgetURL and Link together.

widgetURL

WidgetURL scope is the entire widget, and there can only be one widgetURL on a widget. Additional widgetURL parameters do not take effect.

The code is as follows:

struct WidgetLargeView : View {
    var body: some View {
        GeometryReader{ geo in
            WidgetLargeTopView(a).
        }
        .widgetURL(URL(string: "jump://Large")!)}}Copy the code

Link

The Link scope is the actual size of the Link component. There is no limit to the number of links that can be added. Note that the widget’s systemSmall type does not use the Link API.

The code is as follows:

struct WidgetLargeView : View {
    var body: some View {
        GeometryReader{ geo in
            WidgetLargeTopView(a)Link(destination: URL(string: Custom Scheme://Unit)!) {
                WidgetLargeUnitView()}.
        }
        .widgetURL(URL(string: "Custom Scheme://Large")!)}}Copy the code

URL Schemes

URL Schemes are a bridge for groups to jump to apps and for apps to jump to each other. It should be familiar to the average developer.

It’s easy to register a custom URL Scheme with info.plist –> URL Types –> Item0 –> URL Schemes –> custom Schemes.

After that, in the widget, you can open your App through the URL object made of custom Scheme://. After ://, you can add parameters to indicate the required function or content.

Note: When adding parameters, the Chinese characters should be escaped. Here you can concatenate jump URL strings using NSURLComponents and NSURLQueryItem. Own escape effect and operation URL more standard.

NSURLComponents *components = [NSURLComponents componentsWithString:@custom Scheme://];
NSMutableArray<NSURLQueryItem *> *queryItems = @[].mutableCopy;
NSURLQueryItem *aItem = [NSURLQueryItem queryItemWithName:@"a" value:A "@" parameter];
[queryItems addObject:aItem];
NSURLQueryItem *bItem = [NSURLQueryItem queryItemWithName:@"b" value:Parameter b "@"];
[queryItems addObject:bItem];
components.queryItems = queryItems;

NSURL *url = components.URL;
Copy the code

Treatment after landing App

Clicking the widget to jump to the App triggers the AppDelegate openURL method.

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey.id> *)options
Copy the code

In the openURL method, the function jump or content display required by users can be determined by analyzing URL parameters, and then the corresponding implementation can be carried out. This has put forward certain requirements for the routing capability of the project, because it is not closely related to the development of team parts, so I will not elaborate on it.

Dynamically configure widgets

Widgets allow users to configure custom data without opening the app. Using the Intents framework, you can define the configuration page that users see when editing widgets. The terms used here are defined rather than drawn because the configuration data can only be generated by Intents, and the system builds the configuration page based on the generated data.

Build a simple custom function

There are two steps to building a simple custom function:

  1. Create and configure the IntentDefinition file
  2. Change Widget parameters to support ConfigurationIntent.

1. Create and configure the IntentDefinition file

If you select Include Configuration Intent when creating the widget Target, Xcode automatically generates the IntentDefinition file.

If the Include Configuration Intent option is not checked, then you need to manually add the IntentDefinition file.

Go to File -> New -> File and find Siri Intent Definition File and add it to the widget Target.

Once the file is created, open the.intentDefinition file to configure it.

The first thing to remember is the name of the Custom Class on the left. Xcode automatically generates a ConfigurationIntent Class after compilation that stores the user configuration information. Of course, you can also specify a class name, note that the project will not generate this class after compilation.

Then we need to create a custom Parameter template. Click the + sign under Parameter to create a Parameter. You can then define the Type of the created Parameter. In addition to the relatively straightforward system Type, there are also two more difficult to understand columns Enums and Types.

System type

Specific types have further customization options to customize the input UI. For example, a Decimal type can choose from a Number Field input or a Slider input, and can customize the upper and lower limits of the input. The Duration type can be customized to input values in seconds, minutes, or hours. Date Components can specify the input Date or time, specify the format of the Date, and so on.

Enums is a static configuration that is written to a.intentDefinition file and can only be updated if it is issued.

Type Types are much more flexible and can be generated dynamically at run time. Generally we use Types for custom options.

Multiple values can be entered

Most types of arguments support entering multiple values, that is, an array. It also supports limiting the fixed length of arrays depending on the Widget size.

Controls the display conditions of configuration items

You can control one configuration item and display it only if the other configuration item contains any/specific values. The Calendar App’s Up Next Widget, shown below, displays the Calendars configuration item only if the Mirror Calendar App option is not selected.

In an Intent definition file, set one Parameter, A, to the Parent Parameter of another Parameter, B, so that the display of Parameter B depends on the value of Parameter A.

For example, in the figure below, the Calendar parameter is only displayed when the mirrorCalendarApp parameter has a value of false:

2. Modify the parameters of the Widget to support ConfigurationIntent

Replace the Widget classStaticConfigurationIntentConfiguration

Old:

@main
struct MyWidget: Widget {
    let kind: String = "MyWidget"
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
        }
    }
}
Copy the code

New:

@main
struct MyWidget: Widget {
    let kind: String = "MyWidget"
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: WidgetConfiguratIntent.self, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
        }
    }
}
Copy the code

Add the ConfigurationIntent parameter to the Timeline Entry class

The code is as follows:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: WidgetConfiguratIntent
}
Copy the code

Modify inheritance of IntentTimelineProvider

Change the Provider inheritance to IntentTimelineProvider and add the Intent type alias.

Old:

struct Provider: TimelineProvider {
    .
}
Copy the code

New:

struct Provider: IntentTimelineProvider {
    typealias Intent = WidgetConfiguratIntent
    .
}
Copy the code

Change the input parameters of getSnapshot/getTimeline in turn to add support for customization. When creating the Timeline Entry, pass in the Configuration.

Build custom entries using interface data

In the Intent Target, find the IntentHandler file and comply with the ConfigurationIntenthandling protocol in the ConfigurationIntent generation class.

Implementation agreement required provideModeArrOptionsCollectionForConfiguration: withCompletion: method.

In this method, you can call the interface to retrieve the custom data and generate the data source input parameters required by the Completion Block.

- (void)provideModeArrOptionsCollectionForConfiguration:(WidgetConfiguratIntent *)intent withCompletion:(void (^)(INObjectCollection<NMWidgetModel *> * _Nullable modeArrOptionsCollection, NSError * _Nullable error))completion {
    
    [self apiRequest:(NSDictionary *result){
        // Process the obtained data.NSMutableArray*allModelArr = .... ;// Generate the data required for configuration
        INObjectCollection *collection = [[INObjectCollection alloc] initWithItems:allModeArr];
        completion(collection,nil);
    }];
}

Copy the code

Widgets get custom parameters

When the widget generates a view based on the Timeline Entry, the configuration property of the Entry can be read to obtain whether the user has customized attributes and the detailed value of the customized attributes.

conclusion

Advantages and disadvantages coexist

Widget is a thing with obvious advantages and disadvantages. It is really convenient to use on the desktop point-and-use, but the lack of interaction mode and the inability to update data in real time are very big defects. As Apple says, “Widgets are not mini-apps. “Don’t think of Widgets as App development. Widgets are just static views driven by a bunch of data.

Advantage:

  1. Resident desktop, greatly increased exposure to the product.
  2. Using network interfaces and data sharing, personalized content can be presented that is relevant to the user.
  3. Shortens the access path of functionality. Users can touch the desired function with one click.
  4. It can be repeated many times, with custom and recommendation algorithms, adding multiple widget styles and data can be different.
  5. The custom configuration is simple.
  6. A variety of sizes, large size can carry high complexity of content display.

Disadvantages:

  1. Data cannot be updated in real time.
  2. Only click interaction.
  3. The background of the widget cannot be set with a transparency effect.
  4. Cannot display moving images (video/GIfs).

The tail

This brings us to the end of the widget development practice, and you can see that even a small component requires a lot of knowledge. You need to learn about frameworks and concepts that are hard to get access to during normal development, such as Timeline, Intents, and SwiftUI.

The weak interaction and data refresh mechanism of the widget are its Achilles heel. Apple is very restrained about the capabilities of widgets. During development, many ideas and requirements were limited by the framework’s capabilities, so hopefully Apple will open up new capabilities in subsequent iterations. For example, the support section does not need to launch the App in the form of interaction.

However, the defects do not outweigh the drawbacks. It is the right way to develop widgets at present to show users the content they like or provide users with the function entry they want and amplify the advantages of widgets.

The resources

  • Understanding components
  • Widgets write -1 as you read
  • Widgets write -2 as you read
  • Widgets write -3 as you read
  • Enable your widgets to support personalization & intelligent presentation
  • IOS 14 widgets from a developer’s perspective
  • 【 15 minutes to learn SwiftUI
  • 【 15 minutes to learn SwiftUI

This article is published from netease Cloud Music big front end team, the article is prohibited to be reproduced in any form without authorization. Grp.music – Fe (at) Corp.Netease.com We recruit front-end, iOS and Android all year long. If you are ready to change your job and you like cloud music, join us!